From d3a02f617dbb924725527ef15e076020d5f6e168 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 11:26:25 -0800 Subject: [PATCH 001/100] MCP: hidden library ids name their library in requests and are rewritten only as whole ids Follows #6608. ui.inspect shows a library loaded from outside the read roots as loaded/. A request may now name the library or its panel by that id in a panel, library or panels param, in a docking destination (anchor, before, onto, above, below) and in window.panel.layout's action: ui.set {panel} reopens the closed panel instead of floating a new one. The reply to such a request, an error included, shows the shown id. ui.inspect rewrites only whole ids, and only in the fields that hold library and panel ids, so a readable library's id that begins with a hidden one keeps its path, and status text, the command palette query and a script's report keep their text. A hidden library does not take the id of a library loaded without a path. ui.inspect also leaves out of recorded action steps the path and folder params, params named like destinationFolder and the entries of a paths list that lie outside the read roots, also inside a batch step's commands. It hides a library id holding a path written with either separator, and checks a folder preference against the roots as written, without trimming spaces. --- crates/ui-egui/src/control.rs | 231 ++++++++++++++++++++++--- crates/ui-egui/src/tests/automation.rs | 183 ++++++++++++++++++++ docs/mcp.md | 18 +- 3 files changed, 405 insertions(+), 27 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index b02c3b6e1d..3cbe9f920c 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -309,10 +309,22 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value { "fileDialog": app.picks.waiting_kind(), }); let ids = unreadable_library_ids(app); - hide_library_ids(&mut inspected, &ids); + for field in PANEL_FIELDS.iter().chain(&LIBRARY_ID_FIELDS) { + if let Some(v) = inspected["ui"].get_mut(*field) { + hide_library_ids(v, &ids); + } + } + let pairs: Vec<_> = ids.iter().map(|(id, shown)| (id.as_str(), shown.as_str())).collect(); + if let Some(sets) = inspected["ui"].get_mut("action_sets") { + swap_library_params(sets, &pairs); + } inspected } +/// The fields of the UI state, by their keys in `ui.inspect`'s `ui`, that hold library ids or panel +/// ids besides [`PANEL_FIELDS`]: the library panels, the Color Guide's limit and the workspaces. +const LIBRARY_ID_FIELDS: [&str; 4] = ["library_panels", "color_guide_limit", "custom_workspaces", "workspace_changes"]; + /// The fields of the UI state, by their keys in `ui.inspect`'s `ui`, that hold panel ids: the panel /// popped out of the collapsed column, the docking layout, closed panels' places and groups, the panels /// shown as icons or collapsed, and the older floating panels' record. @@ -322,9 +334,11 @@ const PANEL_FIELDS: [&str; 7] = /// The ids of the loaded libraries (`loaded/`) that the UI state refers to and whose file the /// request may not read, each with the id a reply shows instead: `loaded/`, as a library /// loaded without a path is named. The UI state refers to a library by its bare id in a library panel -/// and the Color Guide's limit, and by a panel id (`library::loaded/`) in the fields of -/// [`PANEL_FIELDS`] and the workspaces' layouts. Text the person types (a command palette query, a -/// workspace's name) is not read. When another of those ids already shows that name, the next free one +/// and the Color Guide's limit, by a panel id (`library::loaded/`) in the fields of +/// [`PANEL_FIELDS`] and the workspaces' layouts, and by either in the `library` and `panel` params of +/// recorded actions' steps. Text the person types (a command palette query, a workspace's or an +/// action's name) is not read. When another of those ids, or a library loaded in the session, already +/// has that id, the next free one /// of `loaded/ (2)`, `(3)` and so on is shown, in the sorted order of the full ids, so that /// two libraries stay two and `ui.inspect` and `ui.docking` show a library under the same id. fn unreadable_library_ids(app: &VectorcraftApp) -> Vec<(String, String)> { @@ -333,6 +347,7 @@ fn unreadable_library_ids(app: &VectorcraftApp) -> Vec<(String, String)> { for field in PANEL_FIELDS { collect_library_panel_ids(&ui[field], &mut ids); } + collect_library_params(&ui["action_sets"], &mut ids); for workspace in ["custom_workspaces", "workspace_changes"].iter().filter_map(|k| ui[k].as_array()).flatten() { for (_, layout) in workspace.as_object().into_iter().flatten().filter(|(k, _)| k.as_str() != "name") { collect_library_panel_ids(layout, &mut ids); @@ -343,10 +358,15 @@ fn unreadable_library_ids(app: &VectorcraftApp) -> Vec<(String, String)> { add_library_id(id, &mut ids); } let mut hidden = Vec::new(); - let mut taken = std::collections::BTreeSet::new(); + // The ids of the libraries loaded in the session, so that a hidden one does not take the id of + // one loaded without a path. + let s = &app.session; + let loaded = s.swatch_libraries.infos().chain(s.style_libraries.infos()).chain(s.symbol_libraries.infos()).chain(s.brush_libraries.infos()); + let mut taken: std::collections::BTreeSet = loaded.map(|info| info.id.clone()).collect(); for id in ids { let path = &id["loaded/".len()..]; - let name = vectorcraft_engine::cmd::fileio::file_name(path); + // Either separator: a path saved on another platform counts as a path here too. + let name = path.rsplit(['/', '\\']).find(|part| !part.is_empty()).unwrap_or(path).to_string(); if name != path && !readable(path) { hidden.push((id, name)); } else { @@ -402,30 +422,81 @@ fn collect_library_panel_ids(v: &Value, ids: &mut std::collections::BTreeSet`) and in keys. fn hide_library_ids(v: &mut Value, ids: &[(String, String)]) { - if ids.is_empty() { + let pairs: Vec<_> = ids.iter().map(|(id, shown)| (id.as_str(), shown.as_str())).collect(); + swap_library_ids(v, &pairs); +} + +/// Text that a person or a script wrote, by its key: a command palette query, a script's report and a +/// workspace's name. [`swap_library_ids`] leaves it as written. +const TEXT_FIELDS: [&str; 3] = ["palette_query", "scriptReport", "name"]; + +/// Writes each `from` id of `pairs` as its `to` id wherever `v` holds it as a whole id ([`swap_whole_ids`]), +/// in strings and keys, except in the [`TEXT_FIELDS`]. +fn swap_library_ids(v: &mut Value, pairs: &[(&str, &str)]) { + if pairs.is_empty() { return; } - let hide = |s: &mut String| { - for (id, shown) in ids { - if s.contains(id.as_str()) { - *s = s.replace(id.as_str(), shown); + match v { + Value::String(s) => { + if let Some(swapped) = swap_whole_ids(s, pairs) { + *s = swapped; } } - }; - match v { - Value::String(s) => hide(s), - Value::Array(items) => items.iter_mut().for_each(|item| hide_library_ids(item, ids)), + Value::Array(items) => items.iter_mut().for_each(|item| swap_library_ids(item, pairs)), Value::Object(fields) => { - for (mut key, mut item) in std::mem::take(fields) { - hide(&mut key); - hide_library_ids(&mut item, ids); - fields.insert(key, item); + for (key, mut item) in std::mem::take(fields) { + if TEXT_FIELDS.contains(&key.as_str()) { + fields.insert(key, item); + continue; + } + swap_library_ids(&mut item, pairs); + fields.insert(swap_whole_ids(&key, pairs).unwrap_or(key), item); } } _ => {} } } +/// `s` with each `from` id of `pairs` written as its `to` id where it is a whole id: the whole string, +/// or a part that starts the string or follows a `:`, `"` or `` ` `` (a panel id, a quoted id) and ends +/// the string or a quote. An id that only begins or ends a longer one stays. `None` when nothing changed. +fn swap_whole_ids(s: &str, pairs: &[(&str, &str)]) -> Option { + let mut out: Option = None; + for (from, to) in pairs { + let text = out.as_deref().unwrap_or(s); + if !text.contains(from) { + continue; + } + let mut next = String::with_capacity(text.len()); + let mut copied = 0; + for (at, _) in text.match_indices(from) { + let end = at + from.len(); + let starts = at == 0 || text[..at].ends_with([':', '"', '`']); + let ends = end == text.len() || text[end..].starts_with(['"', '`']); + if at >= copied && starts && ends { + next.push_str(&text[copied..at]); + next.push_str(to); + copied = end; + } + } + if copied > 0 { + next.push_str(&text[copied..]); + out = Some(next); + } + } + out +} + +/// Does `v` hold a string or a key that names a loaded library (`loaded/…`)? +fn names_a_loaded_library(v: &Value) -> bool { + match v { + Value::String(s) => s.contains("loaded/"), + Value::Array(items) => items.iter().any(names_a_loaded_library), + Value::Object(fields) => fields.iter().any(|(key, item)| key.contains("loaded/") || names_a_loaded_library(item)), + _ => false, + } +} + /// The preferences that hold a folder, by their keys in `ui.engine_prefs` and the Preferences dialog. const PREF_FOLDERS: [&str; 6] = ["fontsFolder", "pluginsFolder", "scratchPrimary", "scratchSecondary", "recoveryFolder", "templatesFolder"]; @@ -439,9 +510,43 @@ fn readable(path: &str) -> bool { vectorcraft_engine::file_access::check_read(path).is_ok() } +/// Does a recorded step's param `key` hold a file or folder path: `path`, `folder` or a name ending in +/// `Folder` (`destinationFolder`, `searchFolder`)? +fn is_path_param(key: &str) -> bool { + matches!(key, "path" | "folder") || key.ends_with("Folder") +} + +/// The params of each step of each action of action set `set` (`ui.action_sets`). +fn steps_mut(set: &mut Value) -> impl Iterator> { + let actions = set.get_mut("actions").and_then(Value::as_array_mut).into_iter().flatten(); + let steps = actions.filter_map(|action| action.get_mut("steps").and_then(Value::as_array_mut)).flatten(); + steps.filter_map(|step| step.get_mut(1).and_then(Value::as_object_mut)) +} + +/// Takes out of a recorded step's params, at any depth (the commands of a `command.batch` step), the +/// paths the request may not read: a path param ([`is_path_param`]) and each path of a `paths` list. +fn leave_out_unreadable_params(params: &mut serde_json::Map) { + params.retain(|key, v| !(is_path_param(key) && v.as_str().is_some_and(|p| !readable(p)))); + if let Some(Value::Array(paths)) = params.get_mut("paths") { + paths.retain(|p| p.as_str().is_none_or(readable)); + } + for v in params.values_mut() { + leave_out_unreadable_values(v); + } +} + +/// [`leave_out_unreadable_params`] on each object `v` holds. +fn leave_out_unreadable_values(v: &mut Value) { + match v { + Value::Object(fields) => leave_out_unreadable_params(fields), + Value::Array(items) => items.iter_mut().for_each(leave_out_unreadable_values), + _ => {} + } +} + /// Takes out of `ui.inspect`'s copy of the UI state the paths the request may not read: recent files, -/// a library panel's file and folder preferences (saved, and in an open Preferences dialog) outside -/// the read root. The UI state itself is not changed. +/// a library panel's file, folder preferences (saved, and in an open Preferences dialog) and the paths +/// in recorded actions' steps outside the read root. The UI state itself is not changed. fn leave_out_unreadable_paths(ui: &mut serde_json::Map) { if let Some(Value::Array(files)) = ui.get_mut("recent_files") { files.retain(|f| f.as_str().is_some_and(readable)); @@ -453,6 +558,9 @@ fn leave_out_unreadable_paths(ui: &mut serde_json::Map) { } } } + for step in ui.get_mut("action_sets").and_then(Value::as_array_mut).into_iter().flatten().flat_map(steps_mut) { + leave_out_unreadable_params(step); + } if let Some(Value::Object(prefs)) = ui.get_mut("engine_prefs") { leave_out_unreadable_folders(prefs); } @@ -468,8 +576,9 @@ fn leave_out_unreadable_paths(ui: &mut serde_json::Map) { /// empty value (no folder) and a scratch disk choice ([`SCRATCH_CHOICES`]) stay. fn leave_out_unreadable_folders(prefs: &mut serde_json::Map) { for key in PREF_FOLDERS { - let Some(path) = prefs.get(key).and_then(Value::as_str).map(str::trim) else { continue }; - if path.is_empty() || (key.starts_with("scratch") && SCRATCH_CHOICES.contains(&path)) || readable(path) { + let Some(written) = prefs.get(key).and_then(Value::as_str) else { continue }; + let path = written.trim(); + if path.is_empty() || (key.starts_with("scratch") && SCRATCH_CHOICES.contains(&path)) || readable(written) { continue; } prefs.remove(key); @@ -499,7 +608,83 @@ fn modifiers(p: &Value) -> egui::Modifiers { egui::Modifiers { alt: b("alt"), ctrl: b("ctrl"), shift: b("shift"), mac_cmd: b("cmd") && cfg!(target_os = "macos"), command: b("cmd") } } +/// The params that name a library or a library panel, at any depth of a request (`ui.set {panel}`, +/// `engine.execute {params: {panel}}`, `ui.libraryPanel {panel, library}`), with the docking +/// commands' destinations (`window.panel.move {anchor, before}`, `window.panel.float {onto, above, +/// below}`) and a list of panels. +const LIBRARY_PARAMS: [&str; 8] = ["panel", "library", "anchor", "before", "onto", "above", "below", "panels"]; + +/// The param of `window.panel.layout` that holds a serialized docking action: each panel id in it is +/// translated. +const DOCKING_ACTION: &str = "action"; + +/// Runs a control request. A library loaded from outside the read root keeps the id `ui.inspect` +/// shows for it ([`unreadable_library_ids`]) both ways: a request may name it or its panel by that id +/// in a [`LIBRARY_PARAMS`] param, and the reply to such a request shows that id. pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { + let ids = if names_a_loaded_library(&req.params) { unreadable_library_ids(app) } else { Vec::new() }; + let shown: Vec<_> = ids.iter().map(|(id, shown)| (shown.as_str(), id.as_str())).collect(); + let mut params = req.params.clone(); + if shown.is_empty() || !swap_library_params(&mut params, &shown) { + return dispatch(app, ctx, req); + } + let named = ControlRequest { method: req.method.clone(), params, reply: req.reply.clone(), state: req.state.clone(), seq: req.seq }; + match dispatch(app, ctx, &named) { + Outcome::Done(mut reply) => { + hide_library_ids(&mut reply, &ids); + Outcome::Done(reply) + } + outcome => outcome, + } +} + +/// Writes each `from` id of `pairs` in the [`LIBRARY_PARAMS`] params of `params`, at any depth, as its +/// `to` id → whether one was. +fn swap_library_params(params: &mut Value, pairs: &[(&str, &str)]) -> bool { + match params { + Value::Array(items) => items.iter_mut().fold(false, |swapped, item| swap_library_params(item, pairs) | swapped), + Value::Object(fields) => fields.iter_mut().fold(false, |swapped, (key, item)| { + let this = match item { + Value::String(s) if LIBRARY_PARAMS.contains(&key.as_str()) => swap_whole_ids(s, pairs).map(|to| *s = to).is_some(), + Value::Array(items) if LIBRARY_PARAMS.contains(&key.as_str()) => items.iter_mut().fold(false, |swapped, item| { + let this = item.as_str().and_then(|s| swap_whole_ids(s, pairs)).map(|to| *item = Value::String(to)).is_some(); + this | swapped + }), + Value::Object(_) if key == DOCKING_ACTION => { + let before = item.clone(); + swap_library_ids(item, pairs); + *item != before + } + _ => swap_library_params(item, pairs), + }; + this | swapped + }), + _ => false, + } +} + +/// Adds to `ids` the loaded library ids that the [`LIBRARY_PARAMS`] params of `v` name, at any depth: +/// a library's id or its panel's id. +fn collect_library_params(v: &Value, ids: &mut std::collections::BTreeSet) { + match v { + Value::Array(items) => items.iter().for_each(|item| collect_library_params(item, ids)), + Value::Object(fields) => { + for (key, item) in fields { + if !LIBRARY_PARAMS.contains(&key.as_str()) { + collect_library_params(item, ids); + continue; + } + for s in item.as_str().into_iter().chain(item.as_array().into_iter().flatten().filter_map(Value::as_str)) { + add_library_id(s, ids); + collect_library_panel_ids(&json!(s), ids); + } + } + } + _ => {} + } +} + +fn dispatch(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { let p = &req.params; let s = |k: &str| p.get(k).and_then(Value::as_str); match req.method.as_str() { diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index f725a0819c..f3aee6ca73 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -425,6 +425,124 @@ fn ui_inspect_leaves_out_paths_outside_the_read_root() { let _ = std::fs::remove_dir_all(base); } +/// `ui.inspect` checks a folder preference as it is written: the read root's path with a space before +/// it is a relative path, outside the root, and is left out. +#[test] +fn ui_inspect_checks_a_folder_preference_as_written() { + let (mut app, tx, base) = confined("inspect-folder-space"); + let inside = s(base.join("in")); + app.ui.engine_prefs = json!({"fontsFolder": inside, "templatesFolder": format!(" {inside}")}); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let prefs = r["result"]["ui"]["engine_prefs"].as_object().unwrap(); + assert_eq!(prefs.get("fontsFolder").and_then(Value::as_str), Some(inside.as_str()), "{r}"); + assert!(!prefs.contains_key("templatesFolder"), "{r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// `ui.inspect` rewrites a hidden library's id only in the fields that hold library and panel ids: +/// status text that is that id stays as written. +#[test] +fn ui_inspect_keeps_status_text_that_is_a_hidden_library_id() { + let (mut app, tx, base) = confined("inspect-status-id"); + let theirs = format!("loaded/{}", s(base.join("outside/b.svg"))); + app.ui.color_guide_limit = theirs.clone(); + app.ui.status = theirs.clone(); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + assert_eq!(r["result"]["ui"]["color_guide_limit"], "loaded/b.svg", "{r}"); + assert_eq!(r["result"]["ui"]["status"], json!(theirs), "{r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// `ui.inspect` hides a library loaded from outside the read root in the steps of a recorded action +/// (its `library` and `panel` params), also when nothing else names it, and keeps the action's name. +#[test] +fn ui_inspect_hides_a_hidden_library_in_recorded_steps() { + let (mut app, tx, base) = confined("inspect-action-steps"); + let theirs = format!("loaded/{}", s(base.join("outside/b.svg"))); + let set: crate::panels::actions::ActionSet = serde_json::from_value(json!({ + "name": "set", + "actions": [{"name": theirs, "steps": [ + ["swatch.library.add", {"library": theirs}], + ["window.panel", {"panel": format!("library:swatches:{theirs}")}], + ]}], + })) + .unwrap(); + app.ui.action_sets = vec![set]; + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let action = &r["result"]["ui"]["action_sets"][0]["actions"][0]; + assert_eq!(action["name"], json!(theirs), "{r}"); + assert_eq!( + action["steps"], + json!([["swatch.library.add", {"library": "loaded/b.svg"}], ["window.panel", {"panel": "library:swatches:loaded/b.svg"}]]), + "{r}" + ); + let _ = std::fs::remove_dir_all(base); +} + +/// `ui.inspect` leaves out of a recorded action's steps the file paths (`path`, `folder`, a name ending +/// in `Folder`, `paths`) outside the read root, also in a batch step's commands, and keeps the ones +/// inside it. +#[test] +fn ui_inspect_leaves_out_recorded_paths_outside_the_read_root() { + let (mut app, tx, base) = confined("inspect-action-paths"); + let (a, b) = (s(base.join("in/a.svg")), s(base.join("outside/b.svg"))); + let set: crate::panels::actions::ActionSet = serde_json::from_value(json!({ + "name": "set", + "actions": [{"name": "files", "steps": [ + ["file.open", {"path": a}], + ["file.open", {"path": b}], + ["file.place", {"paths": [a, b]}], + ["batch.run", {"folder": s(base.join("outside"))}], + ["action.batch", {"destinationFolder": s(base.join("outside"))}], + ["command.batch", {"commands": [{"command": "file.open", "params": {"path": b}}]}], + ]}], + })) + .unwrap(); + app.ui.action_sets = vec![set]; + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let steps = &r["result"]["ui"]["action_sets"][0]["actions"][0]["steps"]; + let want = json!([ + ["file.open", {"path": a}], + ["file.open", {}], + ["file.place", {"paths": [a]}], + ["batch.run", {}], + ["action.batch", {}], + ["command.batch", {"commands": [{"command": "file.open", "params": {}}]}], + ]); + assert_eq!(steps, &want, "{r}"); + assert_eq!(app.ui.action_sets[0].actions[0].steps.len(), 6, "the UI state is not changed"); + let _ = std::fs::remove_dir_all(base); +} + +/// `ui.inspect` hides a library loaded from outside the read root whose id holds a path written with +/// either separator, also one saved on another platform. +#[test] +fn ui_inspect_hides_a_library_path_with_either_separator() { + let (mut app, tx, base) = confined("inspect-separators"); + app.ui.color_guide_limit = r"loaded/C:\Users\someone\b.svg".to_string(); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + assert_eq!(r["result"]["ui"]["color_guide_limit"], "loaded/b.svg", "{r}"); + app.ui.color_guide_limit = "loaded//home/someone/c.svg".to_string(); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + assert_eq!(r["result"]["ui"]["color_guide_limit"], "loaded/c.svg", "{r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// A hidden library does not take the id of a library loaded without a path under the same file +/// name: it shows as `loaded/ (2)`, and a request that names the other library reaches it. +#[test] +fn ui_inspect_keeps_a_hidden_library_off_a_loaded_librarys_id() { + let (mut app, tx, base) = confined("inspect-loaded-id"); + let svg = std::fs::read_to_string(base.join("in/a.svg")).unwrap(); + let loaded = app.session.execute("swatch.library.load", &json!({"data": svg, "name": "b.svg"})).unwrap(); + assert_eq!(loaded["library"], "loaded/b.svg"); + let theirs = format!("loaded/{}", s(base.join("outside/b.svg"))); + app.ui.color_guide_limit = theirs; + let r = request(&mut app, &tx, "ui.inspect", json!({})); + assert_eq!(r["result"]["ui"]["color_guide_limit"], "loaded/b.svg (2)", "{r}"); + let _ = std::fs::remove_dir_all(base); +} + /// A closed panel's former place, as `ui.docking_hidden` keeps it. fn hidden_place() -> craft_ui::docking::Location { serde_json::from_value(json!({"floating": null, "anchor": null, "placement": {"Tab": {"before": null}}, "size": null, "stack": null})).unwrap() @@ -519,6 +637,71 @@ fn ui_inspect_keeps_text_that_only_looks_like_a_library_id() { let _ = std::fs::remove_dir_all(base); } +/// `ui.inspect` rewrites a hidden library's id only where a string is that whole id: a readable +/// library's id that begins with it stays, and a script's report line and a command palette query +/// that hold it stay as written. +#[test] +fn ui_inspect_rewrites_only_whole_hidden_library_ids() { + let (mut app, tx, base) = confined("inspect-whole-id"); + let hidden = format!("loaded/{}", s(base.join("i"))); + let readable = format!("loaded/{}", s(base.join("in/a.svg"))); + assert!(readable.starts_with(&hidden)); + let panel = |id: &str| -> crate::panels::library_panel::LibraryPanel { serde_json::from_value(json!({"kind": "swatches", "id": id})).unwrap() }; + app.ui.library_panels = vec![panel(&hidden), panel(&readable)]; + let query = format!("library:swatches:{hidden}"); + app.ui.palette_query = query.clone(); + let text = format!("found {hidden}"); + let source = format!("vc.log({})", json!(text)); + app.session.execute("script.run", &json!({"source": source, "name": "t.js"})).unwrap(); + for _ in 0..10_000 { + if app.session.script_pump().is_some() { + break; + } + } + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let ids: Vec<_> = r["result"]["ui"]["library_panels"].as_array().unwrap().iter().map(|p| p["id"].clone()).collect(); + assert_eq!(ids, [json!("loaded/i"), json!(readable)], "{r}"); + assert_eq!(r["result"]["scriptReport"]["log"], json!([text]), "{r}"); + assert_eq!(r["result"]["ui"]["palette_query"], json!(query), "{r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// A client can name a hidden library by the id `ui.inspect` shows: `ui.set {panel}` with that panel id +/// reopens the closed panel of the library, and the reply does not show its path. +#[test] +fn ui_set_reopens_a_hidden_librarys_panel_by_its_shown_id() { + let (mut app, tx, base) = confined("set-hidden-panel"); + let theirs = format!("loaded/{}", s(base.join("outside/b.svg"))); + let real = format!("library:swatches:{theirs}"); + app.ui.docking_hidden.insert(real.clone(), hidden_place()); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let shown = "library:swatches:loaded/b.svg"; + assert!(r["result"]["ui"]["docking_hidden"].as_object().unwrap().contains_key(shown), "{r}"); + let r = request(&mut app, &tx, "ui.set", json!({"panel": shown})); + assert_eq!(r["ok"], true, "{r}"); + assert!(!app.ui.docking_hidden.contains_key(&real), "the closed panel is reopened: {r}"); + let layout = crate::panel_docking::layout_of(&app.ui); + assert!(layout.contains(&real) && !layout.contains(&shown.to_string()), "no second panel: {layout:?}"); + let hidden = json!(s(base.join("outside"))).to_string(); + assert!(!r.to_string().contains(hidden.trim_matches('"')), "no path outside the root: {r}"); + // A docking command takes the shown id as its destination too. + let r = request( + &mut app, + &tx, + "engine.execute", + json!({"command": "window.panel.move", "params": {"panel": "layers", "anchor": shown, "zone": "tab"}}), + ); + assert_eq!(r["ok"], true, "{r}"); + assert!(!r.to_string().contains(hidden.trim_matches('"')), "no path outside the root: {r}"); + // An error that quotes the panel shows the id the client gave, and other text is not rewritten. + let r = request(&mut app, &tx, "ui.set", json!({"panel": "library:bogus:loaded/b.svg"})); + assert_eq!(r["ok"], false, "{r}"); + assert!(!r.to_string().contains(hidden.trim_matches('"')), "no path outside the root: {r}"); + let r = request(&mut app, &tx, "ui.set", json!({"status": "loaded/b.svg"})); + assert_eq!((r["ok"].as_bool(), app.ui.status.as_str()), (Some(true), "loaded/b.svg"), "{r}"); + let _ = std::fs::remove_dir_all(base); +} + /// A client without automation roots sees every loaded library's full id in `ui.inspect`. #[test] fn ui_inspect_shows_full_library_ids_without_a_read_root() { diff --git a/docs/mcp.md b/docs/mcp.md index 33a7e2a68c..b8265bdfd2 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -129,14 +129,24 @@ the suite) keep the agent to the folders you give it: `file.openRecentN` for a file outside the roots ends with `no such recent file`, as an empty slot does. - `ui_inspect` (the control channel's `ui.inspect`) leaves out the paths outside the read roots: in `opening`, the documents still being read; in `ui.recent_files`, the recent files; a library panel's `path` in `ui.library_panels`; - and the folder preferences in `ui.engine_prefs` and in an open Preferences dialog's `ui.dialog.fields` + in a recorded action's steps in `ui.action_sets`, also in a batch step's commands, the `path` and `folder` params, + a param whose name ends in `Folder` (`destinationFolder`) and the entries of a `paths` param; and the folder + preferences in `ui.engine_prefs` and in an open Preferences dialog's `ui.dialog.fields` (`fontsFolder`, `pluginsFolder`, `scratchPrimary`, `scratchSecondary`, `recoveryFolder`, `templatesFolder`; an empty folder and the scratch disk choices `Startup` and `None` stay). A library loaded from a file outside the roots shows as `loaded/` wherever `ui_inspect` and `ui.docking` name it: its id, its panel's id, the - Color Guide's `ui.color_guide_limit` and a closed panel's key in `ui.docking_hidden`, also when its panel is - closed. When another hidden library has the same file name, the libraries take `loaded/`, + Color Guide's `ui.color_guide_limit`, a closed panel's key in `ui.docking_hidden` and the `library` and `panel` + params of a recorded action's steps in `ui.action_sets`, also when its panel is closed. A path written with + either separator counts as a path, also one saved on another platform. When another hidden library has the same + file name, the libraries take `loaded/`, `loaded/ (2)`, `(3)` and so on in the order of their full ids, and each one keeps its id in both - replies. The other entries keep their order, and the app's own state is not changed. + replies. A hidden library skips an id that a library loaded without a path already has. The other entries keep + their order, and the app's own state is not changed. A request may name such a library or its panel by the id + shown in a `panel`, `library` or `panels` param, in a docking destination (`anchor`, `before`, `onto`, `above`, + `below`) and in `window.panel.layout`'s `action` (`ui.set {panel: "library:swatches:loaded/"}` + reopens its panel), and the reply to such a request, an error included, shows that id too. `ui_inspect` rewrites only whole ids, and only in the fields that hold library and panel ids: + a longer id that begins with a hidden one, the status text (`ui.status`), the command palette query + (`ui.palette_query`) and a script's report (`scriptReport`) keep their text. - Each flag may be given more than once (#3546): a path inside any of the read roots may be read, one inside any of the write roots written, so one session can read a project folder and a shared assets folder, or write to an output folder and a scratch folder, without widening a root to their common parent. A folder given twice From 6ae7051e4e6906f6fc9f58b226f43aaffae98212 Mon Sep 17 00:00:00 2001 From: Sven Lafebre Date: Wed, 11 Nov 2026 21:43:23 +0100 Subject: [PATCH 002/100] Fix #5111: kerning at the caret before a line's first character moves it At the Type tool's caret, kerning goes on the character before the caret. At the start of the text there is none, so Alt+arrows, the Kerning field and Reset Tracking and Kerning did nothing; at a later paragraph's start the kerning landed on the invisible line break. CharStyle::kerning_before (kerningBefore) is the space before a character that starts a line by itself: the first character of the text, of a paragraph, or after a forced line break. Shaping gives its cluster the space and draws it that much later along the line (right to left the space is at its right; in vertical type it moves down the column). The caret at such a line's start sets it (edit::caret_kerning_target), in type.step, type.resetTracking and the Character panel; text.setRangeStyle takes it. SVG export starts the line where the character is drawn. The Eyedropper and character styles leave it alone. --- crates/doc/src/text/char_style.rs | 7 + crates/doc/src/text/tabs_lists.rs | 1 + crates/engine/src/cmd/appearance.rs | 3 + crates/engine/src/cmd/textedit.rs | 85 ++++++---- crates/engine/src/cmd/textstyles.rs | 10 +- crates/engine/src/cmd/textstyles/tests.rs | 4 +- crates/engine/src/tests.rs | 1 + crates/engine/src/tests/caret_kerning.rs | 183 ++++++++++++++++++++++ crates/svg/src/export/text.rs | 11 ++ crates/svg/tests/it/text_layout.rs | 24 +++ crates/text/src/edit.rs | 22 +++ crates/text/src/harakat.rs | 2 + crates/text/src/layout.rs | 41 +++-- crates/text/src/shape.rs | 38 ++++- crates/text/src/tests.rs | 1 + crates/text/src/tests/kerning_before.rs | 121 ++++++++++++++ crates/ui-egui/src/panels/character.rs | 29 ++-- docs/mcp.md | 3 +- docs/target-app-parity.md | 6 +- docs/type-parity.md | 3 +- 20 files changed, 523 insertions(+), 72 deletions(-) create mode 100644 crates/engine/src/tests/caret_kerning.rs create mode 100644 crates/text/src/tests/kerning_before.rs diff --git a/crates/doc/src/text/char_style.rs b/crates/doc/src/text/char_style.rs index fd828c79af..37c76c5e3a 100644 --- a/crates/doc/src/text/char_style.rs +++ b/crates/doc/src/text/char_style.rs @@ -28,6 +28,12 @@ pub struct CharStyle { /// Manual pair spacing in 1/1000 em, as [`Self::tracking`]; None uses the automatic mode. #[serde(default, skip_serializing_if = "Option::is_none")] pub kerning: Option, + /// Space before the character in 1/1000 em, as [`Self::kerning`], when it starts a line by + /// itself: the first character of the text, of a paragraph, or after a forced line break. The + /// Type tool's caret before such a character kerns with it (#5111), since there is no character + /// before it whose [`Self::kerning`] could space it. Elsewhere it has no effect. + #[serde(default, rename = "kerningBefore", skip_serializing_if = "crate::skip::is_default")] + pub kerning_before: f64, /// Use glyph shapes for automatic pair spacing; false uses font metrics. #[serde(default, rename = "opticalKerning", skip_serializing_if = "crate::skip::is_default")] pub optical_kerning: bool, @@ -411,6 +417,7 @@ impl Default for CharStyle { leading: None, tracking: 0.0, kerning: None, + kerning_before: 0.0, optical_kerning: false, baseline_shift: 0.0, h_scale: 100.0, diff --git a/crates/doc/src/text/tabs_lists.rs b/crates/doc/src/text/tabs_lists.rs index c4ec92b70b..d00005c779 100644 --- a/crates/doc/src/text/tabs_lists.rs +++ b/crates/doc/src/text/tabs_lists.rs @@ -356,6 +356,7 @@ impl ParaList { position: CharPosition::Normal, tracking: 0.0, kerning: None, + kerning_before: 0.0, optical_kerning: first.optical_kerning, tate_chu_yoko: false, underline: false, diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 2dfeb497d0..98eec73a3d 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -1430,6 +1430,9 @@ impl Picked { stroke_width: st.stroke_width, overprint_fill: st.overprint_fill, overprint_stroke: st.overprint_stroke, + // The space before a line's first character belongs to where the character is, + // not to how it looks: a sample from one doesn't move the target's (#5111). + kerning_before: st.kerning_before, ..cs.clone() }; } diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 21ed6d8023..773f2002b6 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -40,7 +40,7 @@ pub fn specs() -> Vec { "Character", [], None, - "{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), story?: bool = false (all the text of every frame of the threaded story `id` is a frame of, in one undo step; no `start` or `end`), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), fill?: colour|\"none\", stroke?: colour|\"none\" (#rrggbbaa and #rgba are refused in both), 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\", …], featureChanges?: [\"tnum\", \"-onum\", …] (OpenType features turned on, or with - off, on top of each character's own; its other features are kept), 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), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings: in vertical type a block of tate-chu-yoko characters moves up by a positive Up/Down and down by a negative one, right by a positive Left/Right and left by a negative one, as one piece with the values of its first character; on a vertical path a positive Up/Down moves it toward the path's start and a positive Left/Right to the path's left; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, with Show East Asian Options: in vertical type those half-width characters, such as Latin letters and digits, stand upright one below the other instead of being rotated; spaces and tate-chu-yoko blocks are not changed; kept on the characters and ignored in horizontal type; off by default), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), 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\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference: the height of the font that size measures, the em box by default; with a font that has no such height, or one outside 0.1 to 2 em, the em box), language?: name|tag (Character panel › Language: one of Document Setup's languages by name or BCP 47 tag, case-insensitive, such as \"Turkish\" or \"tr\"; the characters are shaped in it, so the font uses that language's forms (OpenType `locl`); saved as the tag, English: USA, the default, as none; new type takes the document's language), 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)}", + "{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), story?: bool = false (all the text of every frame of the threaded story `id` is a frame of, in one undo step; no `start` or `end`), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\" (the space after each character), kerningBefore?: 1/1000 em -1000..10000 (the space before a character that starts a line by itself: the first character of the text, of a paragraph or after a forced line break; no effect elsewhere; 0 by default), baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), fill?: colour|\"none\", stroke?: colour|\"none\" (#rrggbbaa and #rgba are refused in both), 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\", …], featureChanges?: [\"tnum\", \"-onum\", …] (OpenType features turned on, or with - off, on top of each character's own; its other features are kept), 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), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings: in vertical type a block of tate-chu-yoko characters moves up by a positive Up/Down and down by a negative one, right by a positive Left/Right and left by a negative one, as one piece with the values of its first character; on a vertical path a positive Up/Down moves it toward the path's start and a positive Left/Right to the path's left; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, with Show East Asian Options: in vertical type those half-width characters, such as Latin letters and digits, stand upright one below the other instead of being rotated; spaces and tate-chu-yoko blocks are not changed; kept on the characters and ignored in horizontal type; off by default), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), 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\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference: the height of the font that size measures, the em box by default; with a font that has no such height, or one outside 0.1 to 2 em, the em box), language?: name|tag (Character panel › Language: one of Document Setup's languages by name or BCP 47 tag, case-insensitive, such as \"Turkish\" or \"tr\"; the characters are shaped in it, so the font uses that language's forms (OpenType `locl`); saved as the tag, English: USA, the default, as none; new type takes the document's language), 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 ), @@ -103,7 +103,7 @@ pub fn specs() -> Vec { "Step Type", [], None, - "{attribute: \"size\"|\"leading\"|\"tracking\"|\"kerning\"|\"baselineShift\", by?: steps (1; negative steps down), id?, start?: byte, end?: byte, ids?: [id…] (instead of id)} step type by the Preferences › Type increments (size and leading: typeSizeIncrement, pt; tracking and kerning: trackingIncrement, 1/1000 em; baseline shift: baselineShiftIncrement, pt), what the Type tool's Alt+arrows do (Cmd/Ctrl too: five steps; horizontal: ←/→ kerning at the caret or tracking of the selection, ↑/↓ leading, Shift+↑/↓ baseline shift; vertical: ↓/↑ with or without Shift kerning or tracking, ←/→ leading, Shift+→/← baseline shift; mixed selected text objects use the horizontal mapping). The range of `id`, else the type objects in `ids`, else the text the Type tool has selected (at a caret: kerning steps the character before it, leading its paragraph), else every selected type object → {ids}", + "{attribute: \"size\"|\"leading\"|\"tracking\"|\"kerning\"|\"baselineShift\", by?: steps (1; negative steps down), id?, start?: byte, end?: byte, ids?: [id…] (instead of id)} step type by the Preferences › Type increments (size and leading: typeSizeIncrement, pt; tracking and kerning: trackingIncrement, 1/1000 em; baseline shift: baselineShiftIncrement, pt), what the Type tool's Alt+arrows do (Cmd/Ctrl too: five steps; horizontal: ←/→ kerning at the caret or tracking of the selection, ↑/↓ leading, Shift+↑/↓ baseline shift; vertical: ↓/↑ with or without Shift kerning or tracking, ←/→ leading, Shift+→/← baseline shift; mixed selected text objects use the horizontal mapping). The range of `id`, else the type objects in `ids`, else the text the Type tool has selected (at a caret: kerning steps the space after the character before it, or at the start of a line (of the text, a paragraph, or after a forced line break) the space before the character after it (kerningBefore, as text.setRangeStyle), and nothing at an empty line's start; leading its paragraph), else every selected type object → {ids}", has_doc, type_step ), @@ -148,7 +148,7 @@ pub fn specs() -> Vec { "Reset Tracking and Kerning", [], Some("Cmd+Alt+Q"), - "{ids?: [id…]} set tracking to 0 and clear manual kerning without changing the automatic method in all the text of the type objects in `ids`, else in the text the Type tool has selected (at a caret: only the kerning of the character before it), else in every selected type object, as one undo step → {ids}", + "{ids?: [id…]} set tracking to 0 and clear manual kerning (kerning and kerningBefore) without changing the automatic method in all the text of the type objects in `ids`, else in the text the Type tool has selected (at a caret: only the kerning there, as type.step steps it), else in every selected type object, as one undo step → {ids}", has_selection, reset_tracking ), @@ -430,6 +430,8 @@ pub(crate) struct CharChange { leading: Option>, tracking: Option, kerning: Option>, + /// The space before a character that starts a line by itself (`kerningBefore`). + kerning_before: Option, optical_kerning: Option, baseline_shift: Option, h_scale: Option, @@ -691,7 +693,7 @@ fn auto_or_num(p: &Value, k: &str, cmd: &str) -> Result>> { impl CharChange { /// `setup` gives the superscript, subscript and small caps proportions. pub(crate) fn parse(p: &Value, cmd: &str, setup: &vectorcraft_doc::DocSetup) -> Result { - check_numbers(p, &["size", "tracking", "baselineShift", "hScale", "vScale", "rotation", "strokeWidth"], cmd)?; + check_numbers(p, &["size", "tracking", "kerningBefore", "baselineShift", "hScale", "vScale", "rotation", "strokeWidth"], cmd)?; check_switches( p, &["underline", "strikethrough", "allCaps", "proportionalMetrics", "tateChuYoko", "standardVerticalRoman", "noBreak", "kashidas"], @@ -720,6 +722,7 @@ impl CharChange { Some(Value::String(v)) if v.eq_ignore_ascii_case("optical") || v.eq_ignore_ascii_case("auto") => Some(None), Some(v) => Some(Some(v.as_f64().ok_or_else(|| bad(cmd, "kerning must be a number, \"auto\", or \"optical\""))?)), }, + kerning_before: num("kerningBefore"), optical_kerning: match p.get("kerning") { Some(Value::String(v)) if v.eq_ignore_ascii_case("optical") => Some(true), Some(Value::String(v)) if v.eq_ignore_ascii_case("auto") => Some(false), @@ -777,6 +780,7 @@ impl CharChange { && self.leading.is_none() && self.tracking.is_none() && self.kerning.is_none() + && self.kerning_before.is_none() && self.optical_kerning.is_none() && self.baseline_shift.is_none() && self.h_scale.is_none() @@ -883,6 +887,9 @@ impl CharChange { if let Some(v) = self.kerning { st.kerning = v.map(|k| k.clamp(-1000.0, 10000.0)); } + if let Some(v) = self.kerning_before { + st.kerning_before = v.clamp(-1000.0, 10000.0); + } if let Some(v) = self.optical_kerning { st.optical_kerning = v; } @@ -1694,17 +1701,16 @@ pub(crate) fn editing_story(s: &Session) -> Vec { s.active().map(|st| super::threads::story_of(&st.doc, id)).unwrap_or_default() } +/// A text range a type shortcut changes: its type object, its bytes, and the caret's byte when it +/// came from a caret ([`type_ranges`]). +pub(crate) type TypeRange = (NodeId, usize, usize, Option); + /// The text ranges a type shortcut changes: the range of `id`, else all the text of the type /// objects in `ids`, else the text the Type tool has selected (all the text of every frame of the /// story after Select All in threaded text, [`editing_story`]), else all the text of every selected /// type object. At a caret, `at_caret` gives the range from the plain text and the caret's byte -/// (None: nothing changes there). Each range comes with whether it came from a caret. -pub(crate) fn type_ranges( - s: &mut Session, - p: &Value, - c: &str, - at_caret: impl Fn(&str, usize) -> Option<(usize, usize)>, -) -> Result> { +/// (None: nothing changes there). Each range comes with the caret's byte when it came from a caret. +pub(crate) fn type_ranges(s: &mut Session, p: &Value, c: &str, at_caret: impl Fn(&str, usize) -> Option<(usize, usize)>) -> Result> { // (text, its range; None: all of it). let targets: Vec<(NodeId, Option<(usize, usize)>)> = match super::object_param(p, "id", c)? { // An object that isn't type is refused as `ids` of it is. @@ -1728,10 +1734,10 @@ pub(crate) fn type_ranges( let text = text_ref(s, id)?.plain_text(); let (a, b) = range.map_or((0, text.len()), |(a, b)| (a.min(text.len()), b.min(text.len()))); let (a, b, caret) = if a < b { - (a, b, false) + (a, b, None) } else { match at_caret(&text, a) { - Some((a, b)) => (a, b, true), + Some((x, y)) => (x, y, Some(a)), None => continue, } }; @@ -1763,24 +1769,31 @@ fn type_step(s: &mut Session, p: &Value) -> Result { Step::Tracking | Step::Kerning => prefs.tracking_increment, Step::BaselineShift => prefs.baseline_shift_increment, }; - // The ranges stepped. At a caret: kerning is the space after the character before it, leading - // the paragraph's; the rest wait for a selection. + // The ranges stepped. At a caret: kerning is the space after the character before it, or at + // the start of a line the space before the character after it (#5111), leading the + // paragraph's; the rest wait for a selection. let ranges = type_ranges(s, p, C, |text, a| match step { - Step::Kerning => Some((edit::prev_char(text, a), a)), + Step::Kerning => edit::caret_kerning_target(text, a).map(|(r, _)| (r.start, r.end)), Step::Leading => { let para = edit::paragraph_at(text, a); Some((para.start, para.end)) } _ => None, })?; + // At a caret, the kerning stepped: (whether it is the space before the character, its value). let mut caret_kerning = Vec::with_capacity(ranges.len()); for &(id, a, b, caret) in &ranges { - let value = if matches!(step, Step::Kerning) && caret { - let t = text_ref(s, id)?; - let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); - Some(effective_kerning(vectorcraft_text::FontDb::global(), &t, &lay, a, b)) - } else { - None + let value = match caret { + Some(c) if matches!(step, Step::Kerning) => { + let t = text_ref(s, id)?; + if edit::caret_kerning_target(&t.plain_text(), c).is_some_and(|(_, before)| before) { + Some((true, edit::style_at(&t.runs, a + 1).kerning_before)) + } else { + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); + Some((false, effective_kerning(vectorcraft_text::FontDb::global(), &t, &lay, a, b))) + } + } + _ => None, }; caret_kerning.push(value); } @@ -1829,10 +1842,13 @@ fn type_step(s: &mut Session, p: &Value) -> Result { st.leading = Some(super::typecmd::local_type_value(t, leading * points + inc, (0.1, 5000.0), false, C)?); } Step::Tracking => st.tracking = (st.tracking + inc).clamp(-1000.0, 10000.0), - Step::Kerning => { - let current = caret_kerning.get(range_index).copied().flatten().unwrap_or_else(|| st.kerning.unwrap_or(0.0)); - st.kerning = Some((current + inc).clamp(-1000.0, 10000.0)); - } + Step::Kerning => match caret_kerning.get(range_index).copied().flatten() { + Some((true, current)) => st.kerning_before = (current + inc).clamp(-1000.0, 10000.0), + caret => { + let current = caret.map_or_else(|| st.kerning.unwrap_or(0.0), |(_, v)| v); + st.kerning = Some((current + inc).clamp(-1000.0, 10000.0)); + } + }, Step::BaselineShift => { st.baseline_shift = super::typecmd::local_type_value(t, st.baseline_shift * points + inc, (-1296.0, 1296.0), false, C)?; } @@ -1849,17 +1865,22 @@ fn type_step(s: &mut Session, p: &Value) -> Result { /// Cmd/Ctrl+Alt+Q: tracking 0 and manual kerning cleared in the type objects in /// `ids`, else in the Type tool's selected text, else in every selected type object. At a caret, -/// only the kerning of the character before it changes, as Alt+←/→ in horizontal type or Alt+↓/↑ in vertical type kerns there. -/// The automatic method remains selected. +/// only the kerning there changes, as Alt+←/→ in horizontal type or Alt+↓/↑ in vertical type kerns +/// there: of the character before it, or at the start of a line the space before the character +/// after it ([`edit::caret_kerning_target`]). The automatic method remains selected. fn reset_tracking(s: &mut Session, p: &Value) -> Result { - let ranges = type_ranges(s, p, "type.resetTracking", |text, a| Some((edit::prev_char(text, a), a)))?; + let ranges = type_ranges(s, p, "type.resetTracking", |text, a| edit::caret_kerning_target(text, a).map(|(r, _)| (r.start, r.end)))?; if !ranges.is_empty() { s.edit("Reset Tracking and Kerning", |d, _| { for &(id, a, b, caret) in &ranges { let t = text_mut(d, id).ok_or(EngineError::NoNode(id))?; - edit::style_range(&mut t.runs, a, b, |st| { - st.kerning = None; - if !caret { + let before = caret.and_then(|c| edit::caret_kerning_target(&t.plain_text(), c)).map(|(_, before)| before); + edit::style_range(&mut t.runs, a, b, |st| match before { + Some(true) => st.kerning_before = 0.0, + Some(false) => st.kerning = None, + None => { + st.kerning = None; + st.kerning_before = 0.0; st.tracking = 0.0; } }); diff --git a/crates/engine/src/cmd/textstyles.rs b/crates/engine/src/cmd/textstyles.rs index 89ec47b276..f63f4fe9fa 100644 --- a/crates/engine/src/cmd/textstyles.rs +++ b/crates/engine/src/cmd/textstyles.rs @@ -174,8 +174,10 @@ fn to_attrs(v: &T) -> Map { /// from a style instead leaves the text's own value alone. pub fn char_attrs(v: &CharStyle) -> Map { let mut m = to_attrs(v); - // A chosen glyph belongs to one character, not to a reusable character style. + // A chosen glyph belongs to one character, and the space before a line's first character to + // where it stands (#5111), not to a reusable character style. m.remove("glyphAlternate"); + m.remove("kerningBefore"); for (k, x) in [ ("font_version", json!(v.font_version)), ("fontAxes", json!(v.font_axes)), @@ -516,6 +518,12 @@ fn attrs_param(d: &Document, p: &Value, kind: Kind, verb: &str, kept: Option<&Ma if m.contains_key("glyphAlternate") { return Err(bad(&c, "`glyphAlternate` belongs to one character, not to a character style (text.setGlyphAlternate sets it)")); } + if m.contains_key("kerningBefore") { + return Err(bad( + &c, + "`kerningBefore` belongs to a line's first character, not to a character style (text.setRangeStyle sets it)", + )); + } let mut st = with_attrs(&CharStyle::default(), m, &c)?; let changed = |key: &str| m.contains_key(key) && kept.and_then(|k| k.get(key)) != m.get(key); for (key, value, lo, hi) in diff --git a/crates/engine/src/cmd/textstyles/tests.rs b/crates/engine/src/cmd/textstyles/tests.rs index ab0c7d342e..53720c1ad9 100644 --- a/crates/engine/src/cmd/textstyles/tests.rs +++ b/crates/engine/src/cmd/textstyles/tests.rs @@ -1336,7 +1336,8 @@ fn para_style_redefined_to_the_defaults_updates_its_paragraphs() { /// The same for character styles: Auto kerning and the Roman baseline alignment, which the /// run's JSON leaves out, are captured and reach the style's users. A glyph choice belongs to -/// one character, so it is not a reusable style attribute. +/// one character, and the space before a line's first character to where it stands (#5111), so +/// neither is a reusable style attribute. #[test] fn char_style_redefined_to_the_defaults_updates_its_text() { use vectorcraft_doc::CharAlign; @@ -1356,6 +1357,7 @@ fn char_style_redefined_to_the_defaults_updates_its_text() { assert_eq!((kerning(&s), aligns(&s)), ([None, None, Some(60.0)], [CharAlign::RomanBaseline; 3])); let mut reusable = settable::(); reusable.remove("glyphAlternate"); + reusable.remove("kerningBefore"); assert_eq!(defined(&s, 0), reusable, "Redefine captures every reusable attribute"); s.execute("charStyle.new", &json!({"name": "Copy", "id": local})).unwrap(); assert_eq!(defined(&s, 1), reusable, "New captures every reusable attribute"); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ca0501c8f7..4fa733a6b2 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -20,6 +20,7 @@ mod brushlib; mod brushsym; mod build; mod cancel; +mod caret_kerning; mod charstroke; pub(super) mod clip; mod clipboard; diff --git a/crates/engine/src/tests/caret_kerning.rs b/crates/engine/src/tests/caret_kerning.rs new file mode 100644 index 0000000000..dca3519cb1 --- /dev/null +++ b/crates/engine/src/tests/caret_kerning.rs @@ -0,0 +1,183 @@ +//! Kerning at the Type tool's caret at the start of a line (#5111): with no character on the line +//! before the caret, `type.step`, `type.resetTracking` and the Kerning field set the space before +//! the character after it (`kerningBefore`), as one undo step, and the character moves. + +use serde_json::json; +use vectorcraft_doc::{CharStyle, NodeId, NodeKind, TextObject}; +use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; + +use crate::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + s +} + +fn text(s: &mut Session, t: &str) -> NodeId { + NodeId(s.execute("text.create", &json!({"x": 100, "y": 100, "text": t, "size": 20})).unwrap()["id"].as_u64().unwrap()) +} + +fn obj(s: &Session, id: NodeId) -> TextObject { + let Some(NodeKind::Text(t)) = s.doc().unwrap().doc.node(id).map(|n| &n.kind) else { panic!("not text") }; + (**t).clone() +} + +fn style_at(s: &Session, id: NodeId, byte: usize) -> CharStyle { + vectorcraft_text::edit::style_at(&obj(s, id).runs, byte + 1) +} + +/// Where glyph `byte` is drawn along its line, in text space. +fn drawn_x(s: &Session, id: NodeId, byte: usize) -> f64 { + let l = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &obj(s, id)); + l.glyphs.iter().find(|g| g.byte == byte).unwrap().xf.as_coeffs()[4] +} + +/// The Type tool editing `id` (made by [`text`]) with its caret at byte `caret`. +fn caret_in(s: &mut Session, id: NodeId, caret: usize) -> ViewInfo { + let v = ViewInfo::default(); + s.select_tool("type", v).unwrap(); + for k in [PointerKind::Down, PointerKind::Up] { + s.pointer(&PointerEvent::new(k, 102.0, 95.0), v).unwrap(); + } + assert_eq!(s.tool_options()["editing"], json!(id.0), "editing the text"); + s.set_tool_option("select", &json!({"start": caret, "end": caret})); + v +} + +fn step_at(s: &mut Session, id: NodeId, caret: usize, by: i32) -> serde_json::Value { + s.execute("type.step", &json!({"id": id.0, "start": caret, "end": caret, "attribute": "kerning", "by": by})).unwrap() +} + +#[test] +fn a_step_at_the_start_of_the_text_moves_the_first_character() { + let mut s = session(); + let id = text(&mut s, "AV"); + let inc = s.prefs.tracking_increment; + let (a, v) = (drawn_x(&s, id, 0), drawn_x(&s, id, 1)); + assert_eq!(step_at(&mut s, id, 0, 1)["ids"], json!([id.0])); + let st = style_at(&s, id, 0); + assert_eq!((st.kerning_before, st.kerning), (inc, None), "the space before A, not after it"); + // A step of `inc` thousandths of the 20 pt em moves both letters along the line. + let moved = inc / 1000.0 * 20.0; + assert!((drawn_x(&s, id, 0) - a - moved).abs() < 1e-6 && (drawn_x(&s, id, 1) - v - moved).abs() < 1e-6); + // A negative step goes back towards the line's start, past it. + step_at(&mut s, id, 0, -3); + assert_eq!(style_at(&s, id, 0).kerning_before, -2.0 * inc); + assert!((drawn_x(&s, id, 0) - a + 2.0 * moved).abs() < 1e-6); + // One undo step each. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(style_at(&s, id, 0).kerning_before, inc); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(style_at(&s, id, 0).kerning_before, 0.0); + assert!((drawn_x(&s, id, 0) - a).abs() < 1e-6); +} + +#[test] +fn inside_a_line_a_step_still_kerns_the_character_before_the_caret() { + let mut s = session(); + let id = text(&mut s, "AV"); + step_at(&mut s, id, 1, 1); + let (a, v) = (style_at(&s, id, 0), style_at(&s, id, 1)); + assert_eq!((a.kerning_before, v.kerning_before, v.kerning), (0.0, 0.0, None)); + assert!(a.kerning.is_some(), "the pair after A"); +} + +#[test] +fn the_start_of_a_paragraph_or_after_a_forced_line_break_kerns_the_character_after_it() { + let mut s = session(); + let inc = s.prefs.tracking_increment; + let id = text(&mut s, "One\nTwo"); + let t = drawn_x(&s, id, 4); + step_at(&mut s, id, 4, 1); + assert_eq!((style_at(&s, id, 3).kerning, style_at(&s, id, 4).kerning_before), (None, inc), "not the line break's kerning"); + assert!(drawn_x(&s, id, 4) > t, "T moves"); + let forced = text(&mut s, "One\u{2028}Two"); + let after = "One\u{2028}".len(); + step_at(&mut s, forced, after, 1); + assert_eq!((style_at(&s, forced, 3).kerning, style_at(&s, forced, after).kerning_before), (None, inc)); +} + +#[test] +fn an_empty_line_has_nothing_to_kern() { + let mut s = session(); + let id = text(&mut s, "One\n\nTwo"); + let before = obj(&s, id).runs; + assert_eq!(step_at(&mut s, id, 4, 1)["ids"], json!([])); + assert_eq!(obj(&s, id).runs, before); + // Nor does the end of text after a line break. + let end = text(&mut s, "One\n"); + assert_eq!(step_at(&mut s, end, 4, 1)["ids"], json!([])); +} + +#[test] +fn reset_at_the_start_of_a_line_clears_the_space_before_and_a_full_reset_clears_it_too() { + let mut s = session(); + let id = text(&mut s, "AV"); + s.execute("text.setRangeStyle", &json!({"id": id.0, "kerning": -40, "tracking": 100})).unwrap(); + step_at(&mut s, id, 0, 2); + assert_ne!(style_at(&s, id, 0).kerning_before, 0.0); + // At the caret before A only the space before A goes; A keeps its kerning after it. + caret_in(&mut s, id, 0); + s.execute("type.resetTracking", &json!({})).unwrap(); + let st = style_at(&s, id, 0); + assert_eq!((st.kerning_before, st.kerning, st.tracking), (0.0, Some(-40.0), 100.0)); + step_at(&mut s, id, 0, 2); + s.execute("type.resetTracking", &json!({"ids": [id.0]})).unwrap(); + let st = style_at(&s, id, 0); + assert_eq!((st.kerning_before, st.kerning, st.tracking), (0.0, None, 0.0), "all manual kerning"); +} + +#[test] +fn set_range_style_sets_the_space_before() { + let mut s = session(); + let id = text(&mut s, "AV"); + s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 0, "end": 1, "kerningBefore": 50})).unwrap(); + assert_eq!(style_at(&s, id, 0).kerning_before, 50.0); + s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 0, "end": 1, "kerningBefore": 20000})).unwrap(); + assert_eq!(style_at(&s, id, 0).kerning_before, 10000.0, "kerning's limits"); + assert!(s.execute("text.setRangeStyle", &json!({"id": id.0, "kerningBefore": "auto"})).is_err()); +} + +/// The Type tool: Alt+← at the caret before the first character kerns it, and Alt+→ back. +#[test] +fn alt_arrows_at_the_caret_before_the_first_character_kern_it() { + let mut s = session(); + let inc = s.prefs.tracking_increment; + let id = text(&mut s, "Hello"); + let v = caret_in(&mut s, id, 0); + let alt = Mods { alt: true, ..Mods::default() }; + s.tool_key(ToolKey::Left, alt, v).unwrap(); + assert_eq!((style_at(&s, id, 0).kerning_before, style_at(&s, id, 0).kerning), (-inc, None)); + s.tool_key(ToolKey::Right, alt, v).unwrap(); + s.tool_key(ToolKey::Right, alt, v).unwrap(); + assert_eq!(style_at(&s, id, 0).kerning_before, inc); +} + +/// The Eyedropper copies how type looks, not where a line starts: a sample from a first character +/// with a space before it leaves the target's first character where it was. +#[test] +fn the_eyedropper_keeps_the_targets_space_before() { + let mut s = session(); + let src = text(&mut s, "Source"); + s.execute("text.setRangeStyle", &json!({"id": src.0, "start": 0, "end": 1, "kerningBefore": 300, "tracking": 50})).unwrap(); + let dst = text(&mut s, "Target"); + s.execute("appearance.copyFrom", &json!({"source": src.0})).unwrap(); + let st = style_at(&s, dst, 0); + assert_eq!((st.tracking, st.kerning_before), (50.0, 0.0), "the tracking goes, the space before doesn't"); +} + +/// A character style captured from a line's first character leaves its space before out, and a +/// style can't be given one: it would space the first character of every paragraph set in it. +#[test] +fn a_character_style_leaves_the_space_before_out() { + let mut s = session(); + let id = text(&mut s, "Source"); + s.execute("text.setRangeStyle", &json!({"id": id.0, "kerningBefore": 300, "tracking": 50})).unwrap(); + s.execute("charStyle.new", &json!({"name": "Body", "id": id.0})).unwrap(); + let attrs = s.doc().unwrap().doc.char_styles[0].attrs.clone(); + assert_eq!(attrs.get("tracking"), Some(&json!(50.0))); + assert!(!attrs.contains_key("kerningBefore"), "{attrs:?}"); + let err = s.execute("charStyle.new", &json!({"name": "Spaced", "attrs": {"kerningBefore": 100}})).unwrap_err(); + assert!(err.to_string().contains("kerningBefore"), "{err}"); +} diff --git a/crates/svg/src/export/text.rs b/crates/svg/src/export/text.rs index b12811338b..5ff58a97bf 100644 --- a/crates/svg/src/export/text.rs +++ b/crates/svg/src/export/text.rs @@ -751,6 +751,17 @@ fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) - let x = g.xf.translation().x - offset; (advance, if (x - g.origin.x).abs() > PLACE_TOLERANCE { x } else { g.origin.x }) } + // The space before a line's first character (`kerningBefore`) is in its advance and + // moves it along the line: a reader sets it from where it is drawn, without it. + None if !lay.vertical + && !g.rtl + && g.len > 0 + && run.style.kerning_before != 0.0 + && vectorcraft_text::edit::starts_line(&plain, g.byte) => + { + let before = Some(g.xf.translation().x - g.origin.x).filter(|b| b.is_finite()).unwrap_or(0.0); + (g.advance - before, g.origin.x + before) + } None => (g.advance, g.origin.x), }; let src: String = source(line.glyph_start + k, g).chars().filter(|c| *c != '\u{ad}').map(|c| run.style.digits.map(c)).collect(); diff --git a/crates/svg/tests/it/text_layout.rs b/crates/svg/tests/it/text_layout.rs index a031de7412..e8129a6922 100644 --- a/crates/svg/tests/it/text_layout.rs +++ b/crates/svg/tests/it/text_layout.rs @@ -109,6 +109,30 @@ fn rich_text_writes_spacing_shift_and_scale() { assert_eq!(attr(tags[1], "y"), attr(tags[0], "y")); } +/// The space before a line's first character (`kerningBefore`, #5111) moves the line's first +/// `` to where the character is drawn, left aligned or anchored at a centred line's centre. +#[test] +fn the_space_before_a_lines_first_character_moves_its_tspan() { + let before = CharStyle { kerning_before: 250.0, ..style(12.0) }; + // 250/1000 em of 12 pt: 3 pt. The second paragraph's first character takes it too; the "O" after + // the first "H" is inside its line, so its value does nothing. + let runs = vec![run("HO", before.clone()), run("H\n", style(12.0)), run("H", before.clone()), run("O", style(12.0))]; + let svg = export(&area_doc(runs, 200.0, Justify::Left), &ExportOptions::default()); + // The first on each baseline. + let mut starts: Vec<(f64, f64)> = vec![]; + for t in tspans(&svg) { + let (x, y) = (attr(t, "x").unwrap(), attr(t, "y").unwrap()); + if starts.last().is_none_or(|&(_, last)| last != y) { + starts.push((x, y)); + } + } + assert_eq!(starts.iter().map(|s| s.0).collect::>(), vec![3.0, 3.0], "{svg}"); + // Centred, the line is 3 pt longer and the anchor sits at the middle of what a reader draws. + let svg = export(&area_doc(vec![run("H", before.clone()), run("OH", style(12.0))], 200.0, Justify::Center), &ExportOptions::default()); + let x = tspans(&svg).iter().find_map(|t| attr(t, "x")).unwrap(); + assert!((x - 101.5).abs() < 0.01, "{svg}"); +} + /// Proportional Metrics (#966) is written as the `palt` feature, beside the character's own. #[test] fn proportional_metrics_write_palt() { diff --git a/crates/text/src/edit.rs b/crates/text/src/edit.rs index a4505f9596..c2838fbbe8 100644 --- a/crates/text/src/edit.rs +++ b/crates/text/src/edit.rs @@ -214,6 +214,28 @@ pub fn next_char(s: &str, i: usize) -> usize { s[i..].chars().next().map(|c| i + c.len_utf8()).unwrap_or(s.len()) } +/// Does the character at byte `i` of `s` start a line by itself: the first character of the text, +/// of a paragraph, or after a forced line break (U+2028)? +pub fn starts_line(s: &str, i: usize) -> bool { + i == 0 || s.get(..i).is_some_and(|before| before.ends_with(['\n', '\u{2028}'])) +} + +/// The character whose kerning the Type tool's caret at byte `i` of `s` sets (the Kerning field, +/// Alt+arrows, Reset Tracking and Kerning), and whether that kerning is the space before it +/// ([`vectorcraft_doc::CharStyle::kerning_before`]) or after it +/// ([`vectorcraft_doc::CharStyle::kerning`]). Inside a line it is the space after the character +/// before the caret. At the start of a line ([`starts_line`]) no character on the line comes before +/// the caret, so it is the space before the character after it (#5111). None at the start of an +/// empty line, where no character follows either. +pub fn caret_kerning_target(s: &str, i: usize) -> Option<(Range, bool)> { + let i = floor_char(s, i); + if !starts_line(s, i) { + return Some((prev_char(s, i)..i, false)); + } + let c = s.get(i..)?.chars().next()?; + (c != '\n' && c != '\u{2028}').then(|| (i..i + c.len_utf8(), true)) +} + /// The start of the grapheme cluster before `i` (Left arrow): a Thai consonant with its vowel and /// tone marks, an accented letter or an emoji sequence is passed over whole. pub fn prev_cluster(s: &str, i: usize) -> usize { diff --git a/crates/text/src/harakat.rs b/crates/text/src/harakat.rs index b7c4f1060e..b781bd8b63 100644 --- a/crates/text/src/harakat.rs +++ b/crates/text/src/harakat.rs @@ -51,6 +51,7 @@ pub(crate) fn paint_only(a: &CharStyle, b: &CharStyle) -> bool { leading, tracking, kerning, + kerning_before, optical_kerning, baseline_shift, h_scale, @@ -100,6 +101,7 @@ pub(crate) fn paint_only(a: &CharStyle, b: &CharStyle) -> bool { && leading == &b.leading && tracking == &b.tracking && kerning == &b.kerning + && kerning_before == &b.kerning_before && optical_kerning == &b.optical_kerning && baseline_shift == &b.baseline_shift && h_scale == &b.h_scale diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 10c44eb73e..c68a4af43a 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -99,14 +99,17 @@ fn takes_cell(g: &SGlyph) -> bool { /// /// It stands in glyph space as it stands in vertical point type's line space: a quarter turn /// counterclockwise from horizontal path type's pose about the center of its cell, a baseline shift -/// kept across the column and a leading space taken off moving it along the path, then moved across +/// kept across the column and a leading space taken off (or the space before a column's first +/// character) moving it along the path, then moved across /// the path as a glyph lying on its side is. fn upright_path_turn(g: &SGlyph, em: f64, pre: Affine, local: Affine, unturned: Affine, angle: f64, advance: f64) -> (Affine, f64) { let cell = if takes_cell(g) { upright_cell(g) } else { advance }; let (shift, unshift) = (Affine::translate((0.0, -g.bshift)), Affine::translate((0.0, g.bshift))); let about = Point::new(cell * 0.5, -upright_centre(g, cell, em)); - let mut turn = - Affine::translate((-g.lead, g.face.ideographic_centre() * em)) * shift * Affine::rotate_about(-std::f64::consts::FRAC_PI_2, about) * unshift; + let mut turn = Affine::translate((g.kern_shift - g.lead, g.face.ideographic_centre() * em)) + * shift + * Affine::rotate_about(-std::f64::consts::FRAC_PI_2, about) + * unshift; // The effects that keep the glyph's axes fixed (Stair Step) leave its top toward the path's end on // a path that runs to the left: it turns half a turn about its cell's center. let tangent = Vec2::new(angle.cos(), angle.sin()); @@ -359,19 +362,20 @@ impl Ctx<'_> { } else { self.db.outline(&g.face, g.gid) }; - // A glyph whose leading space was taken off (mojikumi) is drawn that much earlier: an - // upright one in vertical type by moving it up the column once it stands upright. + // A glyph whose leading space was taken off (mojikumi) is drawn that much earlier, and one + // moved along the line by manual kerning ([`SGlyph::kern_shift`]) that much later: an + // upright one in vertical type by moving it along the column once it stands upright. let upright = self.vertical && g.tcy.is_none() && stands_upright(g) && !(self.on_path && g.inline.is_some()); - let lead = if upright { 0.0 } else { g.lead }; + let (lead, kern_shift) = if upright { (0.0, 0.0) } else { (g.lead, g.kern_shift) }; let local = match &g.inline { // Inline graphics: art space to glyph space (no font outline), moved along the line by // right-to-left manual kerning as a glyph is. - Some(ib) => Affine::translate((g.kern_shift - lead, -g.bshift)) * ib.xf, + Some(ib) => Affine::translate((kern_shift - lead, -g.bshift)) * ib.xf, // Baseline shift moves the glyph across its line, and Character Rotation then turns it // about its shifted pen position, so the shift is across the line at any rotation. // Manual kerning and composition trim move that pen position along the line. None => { - Affine::translate((g.kern_shift - lead, -g.bshift)) + Affine::translate((kern_shift - lead, -g.bshift)) * Affine::rotate(-g.rotation.to_radians()) * Affine::translate((g.dx, g.dy)) * Affine::scale_non_uniform(g.sx, g.sy) @@ -379,7 +383,7 @@ impl Ctx<'_> { }; // The turning point in font units, which later moves of the glyph's transform keep. let pivot = match local.determinant() { - det if det != 0.0 && det.is_finite() => local.inverse() * Point::new(g.kern_shift - lead, -g.bshift), + det if det != 0.0 && det.is_finite() => local.inverse() * Point::new(kern_shift - lead, -g.bshift), _ => Point::ZERO, }; let unturned = if g.inline.is_none() && g.rotation != 0.0 && g.sx != 0.0 && g.sy != 0.0 { @@ -490,9 +494,10 @@ impl Ctx<'_> { // space after the cell, and must not push the glyph off the centre line). let em = self.style_at(g.byte).size; let cell = if takes_cell(g) { upright_cell(g) } else { advance }; - // A leading space taken off moves it up the column after the turn (moving the - // turning point instead would move it across the column too). - let turn = Affine::translate((-g.lead, 0.0)) + // A leading space taken off moves it up the column after the turn, and the space + // before a column's first character down it (moving the turning point instead + // would move it across the column too). + let turn = Affine::translate((g.kern_shift - g.lead, 0.0)) * Affine::rotate_about(-std::f64::consts::FRAC_PI_2, Point::new(origin.x + cell * 0.5, origin.y - upright_centre(g, cell, em))); m = shift * turn * unshift * m; // Its own advance across, from where the cell centres it: where the shaper set the @@ -527,10 +532,16 @@ impl Ctx<'_> { }; // Where its own width starts along its advance: the shaper's placement, a leading space // taken off and right-to-left manual kerning move the glyph along the line. An upright or - // tate-chu-yoko glyph in vertical type starts its cell at the start of its advance. - let width_start = if g.inline.is_none() && !upright && !(self.vertical && g.tcy.is_some()) { + // tate-chu-yoko glyph in vertical type starts its cell at the start of its advance, an + // upright one after the space before a column's first character. + let along = { let [a, b, ..] = pre.as_coeffs(); - Some((g.kern_shift + g.dx - g.lead) * a.hypot(b)).filter(|s| s.is_finite()).unwrap_or(0.0) + a.hypot(b) + }; + let width_start = if g.inline.is_none() && !upright && !(self.vertical && g.tcy.is_some()) { + Some((g.kern_shift + g.dx - g.lead) * along).filter(|s| s.is_finite()).unwrap_or(0.0) + } else if upright { + Some(g.kern_shift * along).filter(|s| s.is_finite()).unwrap_or(0.0) } else { 0.0 }; diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index 3c56b2c914..e5d85d50fc 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -39,7 +39,9 @@ pub(crate) struct SGlyph { pub dy: f64, /// Right to left: the run's manual kerning, which moves the glyph right along the baseline, /// outside its Character Rotation; with whole-point advances, the difference the kerning makes - /// to the cluster's rounded advance. 0 otherwise. + /// to the cluster's rounded advance. Left to right and in vertical type: the space before a + /// line's first character ([`CharStyle::kerning_before`]), which moves it along the line the + /// same way. 0 otherwise. pub kern_shift: f64, /// Outline scale (font units -> points). pub sx: f64, @@ -522,6 +524,20 @@ pub(crate) fn shape_runs( } } +/// [`CharStyle::kerning_before`] of the character at `byte` of `text` in style `st`, in points of +/// `spacing_em`: the space before it when it starts a line by itself ([`crate::edit::starts_line`]), else 0. Its +/// cluster takes the space in its advance, as manual kerning takes the space after a character, +/// and left to right (and in vertical type) its glyphs are drawn that much later on the line +/// (#5111). Right to left, the line starts at its right: the space the advance gains is already on +/// that side. +fn space_before(text: &str, byte: usize, st: &CharStyle, spacing_em: f64) -> f64 { + if st.kerning_before == 0.0 || !crate::edit::starts_line(text, byte) || text.get(byte..).is_some_and(|rest| rest.starts_with(['\n', '\u{2028}'])) + { + return 0.0; + } + Some(st.kerning_before / 1000.0 * spacing_em).filter(|s| s.is_finite()).unwrap_or(0.0) +} + /// Line segments of a glyph outline in font units, cached for the current shaping pass. type Profile = Vec<(f64, f64, f64, f64)>; type CachedProfile = (Profile, (f64, f64)); @@ -836,7 +852,8 @@ fn drawn_alike(a: &CharStyle, b: &CharStyle, vertical: bool) -> bool { /// They do when they differ only in paint and stroke, underline and strikethrough, overprint, the /// character style's name, leading, baseline shift, rotation, Character Alignment, Character /// Direction (text is shaped apart where its bidi level changes), Kashidas, the kerning (each glyph takes its -/// run's, see [`shape_segment`]) and the tracking, as long as the tracking keeps or drops standard +/// run's, see [`shape_segment`]), the space before a line's first character ([`space_before`]) +/// and the tracking, as long as the tracking keeps or drops standard /// ligatures alike ([`crate::features::ligatures_suppressed_by`]). fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool { // Every field is named, so that a new attribute has to be sorted into one group or the other. @@ -849,6 +866,7 @@ fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool { leading: _, tracking, kerning: _, + kerning_before: _, optical_kerning: _, baseline_shift: _, h_scale, @@ -948,8 +966,9 @@ fn shaping_script(c: char) -> Script { /// /// The art is scaled uniformly to `scale` x the font size tall, its left edge on the pen and its /// vertical centre on the middle of the cap height raised by the inline's shift (see -/// [`InlineArt`]). Its advance is the scaled art width plus tracking and its character's manual -/// kerning, which right to left in horizontal type also moves the art right by the value, as it +/// [`InlineArt`]). Its advance is the scaled art width plus tracking, its character's manual +/// kerning and the space before it at a line's start ([`space_before`]). Right to left in +/// horizontal type the manual kerning also moves the art right by the value, as it /// moves a letter's glyph. The art doesn't take the run's Horizontal Scale; the tracking and the /// kerning do, as after a letter ([`spacing_scale`]). The glyph's ascent and descent grow to the /// art's extent so a tall graphic opens up its line. A missing symbol takes a square of the same @@ -983,7 +1002,8 @@ fn inline_glyph( let spacing_em = size * spacing_scale(st, vertical); let tracking = Some(st.tracking / 1000.0 * spacing_em).filter(|t| t.is_finite()).unwrap_or(0.0); let manual_kern = st.kerning.map(|v| v / 1000.0 * spacing_em).filter(|k| k.is_finite()).unwrap_or(0.0); - let adv = width + tracking + manual_kern; + let before = space_before(text, range.start, st, spacing_em); + let adv = width + tracking + manual_kern + before; SGlyph { face: face.clone(), gid: 0, @@ -994,7 +1014,7 @@ fn inline_glyph( tracking: if adv.is_finite() { tracking } else { 0.0 }, dx: 0.0, dy: 0.0, - kern_shift: if level.is_rtl() && !vertical { manual_kern } else { 0.0 }, + kern_shift: if level.is_rtl() && !vertical { manual_kern } else { before }, // Not used to draw (the art has its own transform), but they make the glyph's em box // its run's em: Character Alignment and Top-to-Top leading treat an inline graphic as a // character of its run's size, whatever its scale (see `layout::glyph_em`). @@ -1407,15 +1427,19 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe adv = 0.0; } else { let manual_kern = gst.kerning.map(|v| v / 1000.0 * spacing_em).unwrap_or(0.0); + let before = space_before(text, cl, gst, spacing_em); // Manual kerning spaces a character from the next one in reading order. Right to left, // that one is drawn to its left: the cluster's glyphs move right along the baseline by // the value, so that the space its last glyph's advance gains is at the cluster's left. + // The space before a line's first character moves them the other way. if level.is_rtl() && !feats.vertical { kern_shift = manual_kern; + } else { + kern_shift = before; } if last_in_cluster { tracked = gst.tracking / 1000.0 * spacing_em; - adv += tracked + manual_kern; + adv += tracked + manual_kern + before; } } out.push(SGlyph { diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 8934090d5a..cdd4248220 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -19,6 +19,7 @@ mod inline; mod joiners; mod justify; mod kept_layouts; +mod kerning_before; mod ligature_carets; mod path_joins; mod ruby; diff --git a/crates/text/src/tests/kerning_before.rs b/crates/text/src/tests/kerning_before.rs new file mode 100644 index 0000000000..c33f3fd97a --- /dev/null +++ b/crates/text/src/tests/kerning_before.rs @@ -0,0 +1,121 @@ +//! The space before a character that starts a line by itself (`CharStyle::kerning_before`, #5111): +//! the first character of the text, of a paragraph or after a forced line break moves along its line +//! by the value, the rest of the line follows, and the value does nothing anywhere else. +use crate::*; +use kurbo::Shape; +use vectorcraft_doc::{CharStyle, Justify}; + +fn db() -> &'static FontDb { + FontDb::global() +} + +fn style(size: f64) -> CharStyle { + CharStyle { size, ..CharStyle::default() } +} + +/// Where glyph `byte` is drawn along a horizontal line. +fn drawn_x(l: &TextLayout, byte: usize) -> f64 { + l.glyphs.iter().find(|g| g.byte == byte).map(|g| g.xf.as_coeffs()[4]).unwrap() +} + +/// `t` with `kerning_before` set on the character at `byte`. +fn spaced(t: &TextObject, byte: usize, value: f64) -> TextObject { + let mut t = t.clone(); + let end = edit::next_char(&t.plain_text(), byte); + edit::style_range(&mut t.runs, byte, end, |st| st.kerning_before = value); + t +} + +/// How far each of `bytes` moved along the line from `a` to `b`. +fn moves(a: &TextObject, b: &TextObject, bytes: &[usize]) -> Vec { + let (la, lb) = (layout(db(), a), layout(db(), b)); + bytes.iter().map(|&i| drawn_x(&lb, i) - drawn_x(&la, i)).collect() +} + +fn near(values: &[f64], expected: &[f64]) -> bool { + values.len() == expected.len() && values.iter().zip(expected).all(|(v, e)| (v - e).abs() < 1e-6) +} + +#[test] +fn the_first_character_moves_along_its_line_and_the_rest_follow() { + let plain = TextObject::point(Point::ZERO, "HOH", style(100.0)); + // 250/1000 em of 100 pt: 25 pt, away from the line's start; a negative value pulls it back. + let away = moves(&plain, &spaced(&plain, 0, 250.0), &[0, 1, 2]); + assert!(near(&away, &[25.0, 25.0, 25.0]), "{away:?}"); + let back = moves(&plain, &spaced(&plain, 0, -100.0), &[0, 1, 2]); + assert!(near(&back, &[-10.0, -10.0, -10.0]), "{back:?}"); + // The line is that much longer, so a centred line moves its first character half as far. + let mut centred = plain.clone(); + centred.para.justify = Justify::Center; + let half = moves(¢red, &spaced(¢red, 0, 250.0), &[0, 2]); + assert!(near(&half, &[12.5, 12.5]), "{half:?}"); +} + +#[test] +fn it_does_nothing_on_a_character_inside_a_line() { + let plain = TextObject::point(Point::ZERO, "HOH", style(100.0)); + let inside = moves(&plain, &spaced(&plain, 1, 250.0), &[0, 1, 2]); + assert!(near(&inside, &[0.0, 0.0, 0.0]), "{inside:?}"); + // Nor on the first character of a line that wrapped in area type: the line break moves. + let area = super::area("HOH HOH", style(50.0), Rect::new(0.0, 0.0, 180.0, 400.0), Justify::Left); + let wrapped = layout(db(), &area); + let second_line = wrapped.glyphs.iter().find(|g| g.byte == 4).unwrap().line; + assert_eq!(second_line, 1, "the second HOH wraps"); + let moved = moves(&area, &spaced(&area, 4, 250.0), &[0, 4, 5]); + assert!(near(&moved, &[0.0, 0.0, 0.0]), "{moved:?}"); +} + +#[test] +fn a_paragraph_and_a_forced_line_break_start_lines_by_themselves() { + let paragraphs = TextObject::point(Point::ZERO, "HO\nHO", style(100.0)); + let moved = moves(¶graphs, &spaced(¶graphs, 3, 250.0), &[0, 1, 3, 4]); + assert!(near(&moved, &[0.0, 0.0, 25.0, 25.0]), "{moved:?}"); + let forced = TextObject::point(Point::ZERO, "HO\u{2028}HO", style(100.0)); + let after = "HO\u{2028}".len(); + let moved = moves(&forced, &spaced(&forced, after, 250.0), &[0, after, after + 1]); + assert!(near(&moved, &[0.0, 25.0, 25.0]), "{moved:?}"); + // On a line break it stays: an empty line has nothing to move. + let empty = TextObject::point(Point::ZERO, "HO\n\nHO", style(100.0)); + let moved = moves(&empty, &spaced(&empty, 3, 250.0), &[0, 4]); + assert!(near(&moved, &[0.0, 0.0]), "{moved:?}"); +} + +#[test] +fn it_takes_the_em_of_the_character_as_manual_kerning_does() { + // Horizontal Scale 50%: half the space, as kerning after a character takes it. + let narrow = TextObject::point(Point::ZERO, "HOH", CharStyle { h_scale: 50.0, ..style(100.0) }); + let moved = moves(&narrow, &spaced(&narrow, 0, 250.0), &[0, 2]); + assert!(near(&moved, &[12.5, 12.5]), "{moved:?}"); +} + +#[test] +fn right_to_left_the_space_is_at_the_right_of_the_first_character() { + // The first character in reading order is drawn rightmost; the space before it is on its right, + // so the two keep their distance, and the line is 25 pt longer. + let plain = TextObject::point(Point::ZERO, "אב", style(100.0)); + let kerned = spaced(&plain, 0, 250.0); + let (a, b) = (layout(db(), &plain), layout(db(), &kerned)); + assert!(drawn_x(&a, 0) > drawn_x(&a, 2), "right to left"); + let gap = |l: &TextLayout| drawn_x(l, 0) - drawn_x(l, 2); + assert!((gap(&b) - gap(&a)).abs() < 1e-6, "{} {}", gap(&a), gap(&b)); + let length = |l: &TextLayout| l.lines[0].x1 - l.lines[0].x0; + assert!((length(&b) - length(&a) - 25.0).abs() < 1e-6, "{} {}", length(&a), length(&b)); +} + +#[test] +fn in_vertical_type_the_first_character_moves_down_its_column() { + // An upright glyph (§) and letters lying on their side both move 5 pt (250/1000 em of 20 pt) + // down the column, and stay in it. + for text in ["§§", "ab"] { + let mut plain = TextObject::point(Point::ZERO, text, style(20.0)); + plain.xf = Affine::IDENTITY; + plain.vertical = true; + let kerned = spaced(&plain, 0, 250.0); + let (a, b) = (layout(db(), &plain), layout(db(), &kerned)); + for i in 0..2 { + let (before, after) = (a.glyphs[i].outline.bounding_box(), b.glyphs[i].outline.bounding_box()); + assert!((after.center().y - before.center().y - 5.0).abs() < 1e-6, "{text} {i}: {before:?} {after:?}"); + assert!((after.center().x - before.center().x).abs() < 1e-6, "{text} {i}: {before:?} {after:?}"); + } + } +} diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs index 5f2f96a8cc..2f834a272c 100644 --- a/crates/ui-egui/src/panels/character.rs +++ b/crates/ui-egui/src/panels/character.rs @@ -266,8 +266,9 @@ fn char_cmd(app: &mut VectorcraftApp, cmd: &str, p: Value) { /// Set the Kerning field's `value` (a number, "auto", or "optical"). At a caret in text the Type tool edits /// it is the pair kerning after the character before the caret, as Alt+←/→ steps it -/// (`type.step`); nowhere to set at the start of the text. Else it is a character attribute like -/// the others: the selected range, or all the text. +/// (`type.step`), or at the start of a line the space before the character after it, where Auto +/// and Optical set no space (#5111); nowhere to set at an empty line's start. Else it is a +/// character attribute like the others: the selected range, or all the text. fn set_kerning(app: &mut VectorcraftApp, value: Value) { let Some((id, a, b)) = text_editing(app) else { format(app, json!({"kerning": value})); @@ -278,15 +279,18 @@ fn set_kerning(app: &mut VectorcraftApp, value: Value) { return; } let Some(NodeKind::Text(t)) = app.session.active().and_then(|st| st.doc.node(id)).map(|n| &n.kind) else { return }; - let start = vectorcraft_text::edit::prev_char(&t.plain_text(), a); - if start >= a { - return; - } + let Some((target, before)) = vectorcraft_text::edit::caret_kerning_target(&t.plain_text(), a) else { return }; end_typing(app); - app.run("text.setRangeStyle", json!({"id": id.0, "start": start, "end": a, "kerning": value})).ok(); + let params = if before { + json!({"id": id.0, "start": target.start, "end": target.end, "kerningBefore": value.as_f64().unwrap_or(0.0)}) + } else { + json!({"id": id.0, "start": target.start, "end": target.end, "kerning": value}) + }; + app.run("text.setRangeStyle", params).ok(); } -/// The pair before the caret, in 1/1000 em, using the layout the canvas draws. +/// The kerning at the caret, in 1/1000 em, using the layout the canvas draws: the pair before it, +/// or at the start of a line the space before the character after it. fn caret_kerning(app: &VectorcraftApp, ui: &Ui) -> Option<(f64, bool)> { let (id, a, b) = text_editing(app)?; if a != b { @@ -294,11 +298,12 @@ fn caret_kerning(app: &VectorcraftApp, ui: &Ui) -> Option<(f64, bool)> { } let NodeKind::Text(t) = &app.session.active()?.doc.node(id)?.kind else { return None }; let text = t.plain_text(); - let start = vectorcraft_text::edit::prev_char(&text, a); - if start >= a { - return None; - } + let (target, before) = vectorcraft_text::edit::caret_kerning_target(&text, a)?; + let start = target.start; let style = vectorcraft_text::edit::style_at(&t.runs, start + 1); + if before { + return Some((style.kerning_before, false)); + } if let Some(v) = style.kerning { return Some((v, false)); } diff --git a/docs/mcp.md b/docs/mcp.md index 5826c5c8c4..862a05e92b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -4773,7 +4773,8 @@ Pen's second anchor at (150, 431), in line with the square's centre. `type.resetTracking` (Cmd+Alt+Q, `press_key {key: "Q", mods: {cmd: true, alt: true}}`) sets tracking to 0 and clears manual kerning in the type objects in `ids`, the Type tool's selected text or the selected type objects, keeping Optical; at a caret - it resets only the kerning of the character before it. `type.resetHorizontalScale` (Cmd+Shift+X, + it resets only the kerning there: of the character before it, or at the start of a line (of the text, a paragraph, + or after a forced line break) the space before the character after it (`kerningBefore`). `type.resetHorizontalScale` (Cmd+Shift+X, `press_key {key: "X", mods: {cmd: true, shift: true}}`) sets the horizontal scale to 100%, as the Character panel shows it, in the same text; at a caret it leaves the text as it is. In the app, `type.highlightKerning` (Cmd+Alt+K), `type.highlightTracking` (no default key) and `type.highlightFontName` (Cmd+Alt+Shift+F) show the Character panel and diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b4c949f742..7b92606edb 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (Type tool: kerning at a caret at the start of a line moves the character after it, #5111; scores and estimates unchanged; previously minor: G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > > Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369). > @@ -873,7 +873,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - With the Artboard tool, Cmd/Ctrl+A after a click on a row of the Artboards panel selects every artboard, as the macOS menu bar's Select All does then; the active artboard stays active, and the art's selection does not change. After a press outside the panel's list, Cmd/Ctrl+A selects every object again. It used to select every object after a click in the panel too. Public reference: [Select artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/select-artboards.html). - Cmd/Ctrl+/ (`appearance.addFill`) and Cmd/Ctrl+Alt+/ (`appearance.addStroke`) add a new fill and a new stroke to the selected objects, as the Appearance panel's Add New Fill and Add New Stroke buttons do, on the keys the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists for adding a new fill and a new stroke. Keyboard Shortcuts lists both under Window › Appearance. - Cmd/Ctrl+Alt+Shift+. and , (`type.size.increaseByFive`, `type.size.decreaseByFive`) step the font size of the selected type, or of the text the Type tool has selected, by five times the Size/Leading increment (Preferences › Type); Cmd/Ctrl+Shift+. and , still step it by one increment. - - Cmd/Ctrl+Alt+Q (`type.resetTracking`) sets the tracking of the selected type, or of the text the Type tool has selected, to 0 and clears manual kerning while keeping the automatic method, in one undo step; at a caret it resets only the kerning of the character before it. + - Cmd/Ctrl+Alt+Q (`type.resetTracking`) sets the tracking of the selected type, or of the text the Type tool has selected, to 0 and clears manual kerning while keeping the automatic method, in one undo step; at a caret it resets only the kerning there: of the character before it, or at the start of a line the space before the character after it (#5111). - Cmd/Ctrl+Alt+K (`type.highlightKerning`) and Cmd/Ctrl+Alt+Shift+F (`type.highlightFontName`) show the Character panel and give its Kerning field the keyboard focus with the value selected, or open its font family menu with the search field focused, on the keys the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists for highlighting kerning and the font name field. The page lists the same key for selecting the tracking value; `type.highlightTracking` focuses the Tracking field the same way and has no default key in Keyboard Shortcuts. - Cmd/Ctrl+Alt+L (`ui.newLayer`) opens Layer Options for a new layer, as an Alt-click on the Layers panel's New Layer button does, on the key the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists for adding a layer through the New Layer dialog box. OK adds the layer above the current layer, and Cancel adds none. Cmd/Ctrl+L still adds a layer without the dialog. - F8 (`ui.symbolOptions`) opens Symbol Options for a new symbol made from the selection, as the Symbols panel's New Symbol button does, on the key the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page lists for making a new symbol. OK makes the symbol and puts an instance of it in place of the art, and Cancel makes none. With nothing selected, F8 opens no dialog, and the status bar says that nothing is selected. Shift+F8 and Cmd/Ctrl+F8 still show the Transform and Info panels. @@ -1122,6 +1122,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Vertical type spacing shortcuts:** The [Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page gives Alt+Down/Up for kerning at a caret or tracking of selected text, Alt+Left/Right for leading, and Alt+Shift+Right/Left for baseline shift. Alt+Shift+Down/Up also steps kerning or tracking. Down increases kerning or tracking, Left increases leading, and Shift+Right increases baseline shift; the opposite arrows decrease them. Cmd/Ctrl makes five steps. Horizontal type keeps its existing mapping; mixed horizontal and vertical text-object selections take the horizontal mapping for both objects in one undo step. Selected vertical point, area and path type use the same directions. Tests: `spacing_arrows_follow_each_text_objects_orientation`, `alt_arrows_rotate_for_selected_vertical_type`, `mixed_type_objects_take_horizontal_spacing_chords_in_both_orders`, and `selected_vertical_area_and_path_type_use_vertical_spacing_chords`. - **Vertical punctuation:** fullwidth `!` (U+FF01) and `?` (U+FF1F) use their nominal glyph upright when no vertical alternate is present, following the Tu fallback in [Unicode UAX #50 §3.1](https://www.unicode.org/reports/tr50/#vo). Code-built test fonts cover point and area type with and without `vert` alternates, retain the rotated fallback for a bracket and long vowel mark, and leave horizontal type unchanged. Tests: `crates/text/src/tests/vertical.rs`. - Free Distort's dialog (Effect › Distort & Transform › Free Distort…) shows a box in the proportions of the first selected object's bounds with a handle on each corner. Dragging a handle moves that corner. A handle focused by a click or Tab moves 1% of the box with each arrow key, and 10% with Shift. Reset puts the four corners back on the box. The preview and OK run `effect.apply`, or `effect.setParams` when the dialog edits an applied effect, and the Appearance panel's inline editor opens the dialog with Edit…. Behavior reference: Illustrator CC Help (2014, [PDF](https://help.adobe.com/archive/en/illustrator/cc/2014/illustrator_reference.pdf)), page 285 (PDF page 291). +- **Kerning at the start of a line:** at the Type tool's caret before the first character of the text, of a paragraph, or after a forced line break, the Kerning field, Alt+←/→ (Alt+↓/↑ in vertical type) and Reset Tracking and Kerning set the space before the character after the caret (`kerningBefore`), as the reporter of #5111 describes the reference; the character moves along its line and the rest of the line follows, as one undo step (#5111). It used to do nothing there, or kern the invisible line break. Inside a line the caret still kerns the character before it. A line that wraps in area type takes no space before its first character, and Touch Type still moves a line's first character only up and down. SVG export starts the line where the character is drawn; vertical type moves the character down its column. Tests: `tests::caret_kerning` (engine), `tests::kerning_before` (text), `the_space_before_a_lines_first_character_moves_its_tspan` (SVG). - **Optical kerning:** Character and Properties offer Optical beside Auto for automatic pair spacing. The side-profile spacing method is VectorCraft's own: each pair moves part of the way toward the font's own stem spacing (the gap of `nn`, else `HH` or `oo`), keeps at least part of that gap where the outlines come closest, and changes by at most 0.12 em tighter or 0.06 em looser, so ordinary text keeps the font's spacing (#5336). Ideographs, kana, hangul, fullwidth and CJK punctuation forms and dashes keep the font's spacing. Optical keeps its mode across cosmetic style runs. Choosing Optical clears manual pair values, as Auto does, and leaves additive tracking intact; a manual value typed afterwards replaces that pair's correction ([Adjust kerning and tracking](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/adjust-kerning-and-tracking.html)). Native documents retain the mode and pair overrides; old documents use Auto. Behavior reference: [Line and character spacing](https://web.archive.org/web/20260916050333/https://helpx.adobe.com/illustrator/using/line-character-spacing.html) (public documentation). Tests: `crates/text/src/tests/typo.rs`, `crates/engine/src/tests/textedit.rs`. - **Kerning across style runs:** characters that differ only in fill, stroke, underline, strikethrough, overprint, character style name, leading, baseline shift, rotation, Character Alignment, kerning, or tracking that keeps or drops ligatures alike are shaped together, so restyling one character keeps the font's kerning pairs with its neighbors. A character with manual kerning that follows one with automatic kerning keeps the font's kerning with it (unless All Caps or Small Caps sets it as two letters, as ß becomes SS), and its value replaces the font's kerning after it. A ligature does not form across a change of style, and a change of font, size or OpenType features still shapes the characters apart. Behavior reference: [Line and character spacing](https://web.archive.org/web/20260916050333/https://helpx.adobe.com/illustrator/using/line-character-spacing.html) (public documentation). Tests: `crates/text/src/tests/typo.rs`, `crates/text/src/tests/bidi.rs`. - **Manual kerning in right-to-left text:** a manual kerning value on a Hebrew or Arabic character changes the space between it and the next character in reading order, the one drawn to its left, under Auto and Optical kerning. The character before it keeps its place, and the caret between the pair stays beside the next character. The side the space goes on is VectorCraft's own rule. Behavior reference: [Line and character spacing](https://web.archive.org/web/20260916050333/https://helpx.adobe.com/illustrator/using/line-character-spacing.html). Test: `rtl_manual_kerning_spaces_the_character_from_the_next_in_reading_order`. @@ -1981,6 +1982,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| +| 2026-11-11 | minor | Type: kerning at the Type tool's caret at the start of a line (the text's, a paragraph's, or after a forced line break) sets the space before the character after it (`kerningBefore`), which moves it and the rest of its line; it did nothing at the text's start and kerned the invisible line break at a paragraph's (#5111). Scores and estimates unchanged. | | 2026-11-11 | minor | Bundled content: built-in Energy Beams brush library (Laser Bolt, Plasma Arc, Charge Wave, Spark Trail); brush libraries evidence updated; ~44% → ~46%; more built-in brush libraries remain. | | 2026-11-11 | minor | Bundled content: built-in Skill Icons symbol library (Attack, Defend, Heal, Buff, Ult); symbol libraries evidence updated; ~42% → ~44%; more built-in symbol libraries remain. | | 2026-11-10 | minor | Automation: MCP tool count 33; headless `command_list` lists `UI_COMMANDS` (`ui: true`); eight prompts; effect-id vs command-id clarified (#6460); scores and hours unchanged. | diff --git a/docs/type-parity.md b/docs/type-parity.md index 702ecd5048..7f0ac969f2 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -1,6 +1,6 @@ # Type parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (a centred or right-aligned line of tracked type is aligned without the tracking after its last character, as the page of users' `.ai` files aligns it, #6610; previously minor: tracking and manual kerning in horizontally scaled type are thousandths of the em as the Horizontal Scale draws it, as the page of users' `.ai` files spaces such type; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: Direct Selection shows and drags the direction handles of type on a path's path, #3565; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas base-letter paint; scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: right-aligned area type sets the text after a line's last tab against the right edge; a soft return in justified type no longer keeps its line from being justified; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (kerning at the Type tool's caret at the start of a line sets the space before the character after it, #5111; previously minor: a centred or right-aligned line of tracked type is aligned without the tracking after its last character, as the page of users' `.ai` files aligns it, #6610; previously minor: tracking and manual kerning in horizontally scaled type are thousandths of the em as the Horizontal Scale draws it, as the page of users' `.ai` files spaces such type; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: Direct Selection shows and drags the direction handles of type on a path's path, #3565; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas base-letter paint; scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: right-aligned area type sets the text after a line's last tab against the right edge; a soft return in justified type no longer keeps its line from being justified; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen) · **Target:** Adobe Illustrator 2026 (30.x) Type is Illustrator's largest single area by use (weight 13 of 105 across "Type core" and "Type advanced" in [target-app-parity.md](target-app-parity.md)). This checklist lists the features one by one. Status is from the @@ -81,6 +81,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha | Date | Change | Summary | |---|---|---| +| 2026-11-11 | minor | Kerning at the start of a line (#5111): at the Type tool's caret before the first character of the text, of a paragraph or after a forced line break, the Kerning field, Alt+arrows and Reset Tracking and Kerning set the space before the character after the caret (`CharStyle::kerning_before`, `kerningBefore` in `text.setRangeStyle`), which moves it and the rest of its line, in horizontal, right-to-left and vertical type and in SVG export. They did nothing at the text's start and kerned the invisible line break at a paragraph's. Scores and estimates unchanged. | | 2026-11-11 | minor | Aligned lines of tracked type: a centred or right-aligned line is as wide as its characters without the tracking after its last one (`LineInfo::trailing`), so a centred line sits half that tracking right of where it did and a right-aligned line's last glyph ends on its anchor or frame edge, the tracking after it reaching past. The page of users' `.ai` files (used locally, never committed) aligns tracked lines so: matched glyph by glyph with the page, 176 centred lines of point type in 51 files sat half their last character's tracking left of it (median 0.500 of it; 143 within 0.05 pt), 58 right-aligned lines in 5 files the whole tracking (median 1.001), and 182 left-aligned lines where the page has them; in a line whose last character is tracked 150 and the others −34, the offset is half the last character's tracking (0.498 of it). All those lines end a paragraph. The last character is the last the line draws before a paragraph's end, a forced line break or the spaces a wrap leaves (they hang past the line). Spaces or a tab typed before a paragraph's end or a forced line break are the line's last characters, and the letter before them keeps its tracking: in the 2 such lines in the files, the page counts the space and the letter's tracking and leaves out only the space's own (VectorCraft still leaves trailing spaces out of the width). Forced line breaks and wrapped lines of tracked type aren't in the files: they take the rule unverified, so that Convert To Point Type and Artwork Cleanup's Break Text, which turn wraps into paragraphs and forced line breaks, keep the lines where they were. A hyphenated line, one whose last character hangs, justified, vertical and right-to-left lines and type on a path keep the tracking, and so do right, centre and decimal tab stops, which still align the text after them with its last character's tracking. Manual kerning on the last character, line breaking and justification are unchanged (none in the files). The tracking stays in the last glyph's advance (the caret after it and a selection of it take it in), and Fit Headline still counts it, as line breaking does. Right-aligned area type sets the text after its last tab the same way. Convert To Area Type makes the frame end where the lines are aligned, with room for that tracking, so tracked type converted either way stays where it is (it moved by the tracking on each round trip). Affinity import aligns artistic text by the same rule, so centred or right-aligned tracked artistic text moves by half or all of its last character's tracking (unverified against Affinity). Centred and right-aligned tracked type lays out differently than before in every document. Tests `tests::an_aligned_line_leaves_its_last_characters_tracking_out_of_its_width`, `tests::which_lines_leave_the_tracking_after_their_last_character_out`, `tests::an_aligned_line_leaves_out_its_last_tracking_as_glyph_scaling_sets_it`, `tests::the_text_after_the_last_tab_of_right_aligned_type_leaves_its_last_tracking_out`, and the engine's `converting_tracked_type_between_point_and_area_keeps_it_in_place` and `break_tracked_area_type_keeps_its_aligned_lines_in_place`. Scores and estimates unchanged. (#6610) | | 2026-11-11 | minor | Tracking and manual kerning in horizontally scaled type: they are thousandths of the em as the Horizontal Scale draws it (`vectorcraft_text::spacing_scale`), so at 50% a tracking of 100 adds 0.05 em after each character, half as much as at 100%. They were added at the full em whatever the scale, so narrow tracked type drifted further right with each letter. Illustrator's own layout in users' `.ai` files (used locally, not committed) spaces such type so, and PDF scales its character spacing by its horizontal scaling alike (ISO 32000-1 §9.4.4). Inline graphics take it after them too; vertical type keeps the full em, its advances down the column not all taking the Horizontal Scale. The automatic kerning the Character panel reads, Touch Type's limits and Artwork Cleanup's Combine Point Text measure in the same em, and Scale Text Attributes now keeps tracked type where a horizontal stretch of its transform drew it. Type that has both a Horizontal Scale other than 100% and tracking or manual kerning lays out differently than before in every document. Tests `tests::tracking_and_manual_kerning_take_the_horizontal_scale`, `tests::typo::optical_pairs_in_horizontally_scaled_type_measure_without_the_scaled_tracking`, `tests::inline::shift_tracking_and_missing_symbols`, `tests::vertical::vertical_tracking_keeps_the_em_at_any_horizontal_scale` and the engine's `horizontally_scaled_pair_reads_as_the_kerning_that_keeps_it`, `scale_text_attributes_keeps_tracked_type_where_it_was` and `combine_puts_horizontally_scaled_pieces_back_where_they_were`. Scores and estimates unchanged. | | 2026-11-10 | minor | Edit Custom Dictionary (#4936): Edit › Edit Custom Dictionary… adds, deletes and changes the custom dictionary's words, applied on Done; the Check Spelling dialog remains. Scores and estimates unchanged. | From 5c2e182388212a4fab1e070ee17a3c285b307dfe Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 12:47:49 -0800 Subject: [PATCH 003/100] MCP: a malformed object id is refused as such, not reported as missing Commands that need an object id read it with `id_param`, which returns None for a value that is not a non-negative integer, so `{"id": "5"}` or `{"id": -1}` was refused with "missing id". They now read it with `object_param`, which refuses such a value with "`id` must be a whole number", naming the key. This covers `document.node`, the `path.*` anchor and handle commands, the `text.*` range and path commands, the mesh, envelope and slice point commands, `layer.target`, `layer.move`, `node.move`, `select.magicWand`, and the `source`, `target`, `group` and `path` keys of `appearance.transfer`, `appearance.copyFrom`, the `livePaint.*` commands and `text.createInPath`. The optional `group` of `livePaint.fill` and `parent` of `node.move` are refused the same way when given. Before, a malformed `parent` moved the object to the top level and a malformed `group` fell back to the selection. --- crates/engine/src/cmd/appearance.rs | 10 +-- crates/engine/src/cmd/buildcmds.rs | 4 +- crates/engine/src/cmd/buildcmds/live_paint.rs | 13 +-- crates/engine/src/cmd/distortcmds.rs | 10 +-- crates/engine/src/cmd/draw2.rs | 4 +- crates/engine/src/cmd/draw2/anchors.rs | 10 +-- crates/engine/src/cmd/draw2/cut.rs | 4 +- crates/engine/src/cmd/draw2/freehand.rs | 9 +- crates/engine/src/cmd/edit.rs | 6 +- crates/engine/src/cmd/layer.rs | 19 +++-- crates/engine/src/cmd/live.rs | 2 +- crates/engine/src/cmd/live/envelope.rs | 4 +- crates/engine/src/cmd/live/mesh.rs | 6 +- crates/engine/src/cmd/path.rs | 16 ++-- crates/engine/src/cmd/slices.rs | 4 +- crates/engine/src/cmd/textedit.rs | 20 ++--- crates/engine/src/cmd/typecmd.rs | 10 +-- crates/engine/src/cmd/wand.rs | 4 +- crates/engine/src/tests/params.rs | 84 +++++++++++++++++++ docs/mcp.md | 8 +- 20 files changed, 172 insertions(+), 75 deletions(-) diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 2dfeb497d0..7f6f8cca12 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -32,8 +32,8 @@ use super::opacitymask::percent; use super::paint::paint_from; use super::{AppearanceAttrs, EyedropperAttrs, FillAttrs, StrokeAttrs}; use super::{ - CommandSpec, NodeId, Result, Session, bad, check_numbers, check_switches, cmd, has_doc, has_selection, id_param, ids_param, index, leaf_targets, - ok, paint_targets, str_param, switch_or, targets, + CommandSpec, NodeId, Result, Session, bad, check_numbers, check_switches, cmd, has_doc, has_selection, ids_param, index, leaf_targets, + object_param, ok, paint_targets, str_param, switch_or, targets, }; use crate::EngineError; @@ -726,8 +726,8 @@ fn clear(n: &mut Node) { fn transfer(s: &mut Session, p: &Value) -> Result { const C: &str = "appearance.transfer"; - let source = id_param(p, "source").ok_or_else(|| bad(C, "missing `source` id"))?; - let target = id_param(p, "target").ok_or_else(|| bad(C, "missing `target` id"))?; + let source = object_param(p, "source", C)?.ok_or_else(|| bad(C, "missing `source` id"))?; + let target = object_param(p, "target", C)?.ok_or_else(|| bad(C, "missing `target` id"))?; if source == target { return Err(bad(C, "`source` and `target` are the same object")); } @@ -1201,7 +1201,7 @@ fn target_contents(s: &mut Session, p: &Value) -> Result { /// Sample enabled attributes, refusing object transparency that a text range cannot store. fn copy_from(s: &mut Session, p: &Value) -> Result { const C: &str = "appearance.copyFrom"; - let clicked = id_param(p, "source").ok_or_else(|| bad(C, "missing `source` id"))?; + let clicked = object_param(p, "source", C)?.ok_or_else(|| bad(C, "missing `source` id"))?; let (reverse, append) = (switch_or(p, "reverse", false, C)?, switch_or(p, "append", false, C)?); let defaults_only = switch_or(p, "defaultsOnly", false, C)?; if defaults_only && (reverse || append || p.get("ids").is_some()) { diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index d392449247..055bd748af 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -29,8 +29,8 @@ use super::edit::{outermost, selected_roots}; use super::paint::paint_from; use super::pathops::{Beside, shape_node}; use super::{ - CommandSpec, EngineError, Result, Session, always, bad, check_numbers, cmd, color_value, f64_or, finite_numbers, has_doc, id_param, named_ids, - number_or, point_param, str_param, switch_or, + CommandSpec, EngineError, Result, Session, always, bad, check_numbers, cmd, color_value, f64_or, finite_numbers, has_doc, named_ids, number_or, + object_param, point_param, str_param, switch_or, }; mod live_paint; diff --git a/crates/engine/src/cmd/buildcmds/live_paint.rs b/crates/engine/src/cmd/buildcmds/live_paint.rs index 207182d40a..31cd4eaf40 100644 --- a/crates/engine/src/cmd/buildcmds/live_paint.rs +++ b/crates/engine/src/cmd/buildcmds/live_paint.rs @@ -1,7 +1,7 @@ use super::{ Appearance, Arc, Beside, Document, EDGE_NAME, EngineError, FACE_NAME, GapOptions, GapSize, LIVE_PAINT_NAME, Node, NodeId, Paint, PathData, Point, - Rect, Result, SOURCES_NAME, Selection, Session, SubPath, Value, b, bad, build_children, edge_near, face_at, finite, id_param, ids_json, - is_live_paint, json, number_or, paint_from, po, point_param, repaint_from, replace_roots, root_ids, source_nodes, source_shapes, switch_or, + Rect, Result, SOURCES_NAME, Selection, Session, SubPath, Value, b, bad, build_children, edge_near, face_at, finite, ids_json, is_live_paint, + json, number_or, object_param, paint_from, po, point_param, repaint_from, replace_roots, root_ids, source_nodes, source_shapes, switch_or, }; /// `options` as stored on a Live Paint group: none for the defaults. @@ -274,7 +274,7 @@ pub(super) fn lp_fill(s: &mut Session, p: &Value) -> Result { let pt = point_param(p, "point").filter(|q| finite(*q)).ok_or_else(|| bad(C, "missing point [x,y]"))?; crate::cmd::paint::check_keep_model(p, C)?; let paint = paint_from(s, p)?.unwrap_or_else(|| s.paint.fill.clone()); - let group = id_param(p, "group"); + let group = object_param(p, "group", C)?; let roots = if group.is_none() { root_ids(s, p)? } else { vec![] }; let (g, face) = s.edit("Live Paint Bucket", |d, sel| { let gid = match group { @@ -312,7 +312,7 @@ pub(super) fn lp_fill(s: &mut Session, p: &Value) -> Result { pub(super) fn lp_stroke_edge(s: &mut Session, p: &Value) -> Result { const C: &str = "livePaint.strokeEdge"; let pt = point_param(p, "point").filter(|q| finite(*q)).ok_or_else(|| bad(C, "missing point [x,y]"))?; - let g = id_param(p, "group").ok_or_else(|| bad(C, "missing group"))?; + let g = object_param(p, "group", C)?.ok_or_else(|| bad(C, "missing group"))?; crate::cmd::paint::check_keep_model(p, C)?; let paint = paint_from(s, p)?.unwrap_or_else(|| s.paint.stroke.clone()); let width = number_or(p, "width", s.paint.stroke_width.max(0.0), C)?; @@ -335,7 +335,10 @@ pub(super) fn lp_stroke_edge(s: &mut Session, p: &Value) -> Result { } pub(super) fn lp_info(s: &mut Session, p: &Value) -> Result { - let g = id_param(p, "group").or_else(|| id_param(p, "id")).ok_or_else(|| bad("livePaint.info", "missing group"))?; + let g = match object_param(p, "group", "livePaint.info")? { + Some(g) => g, + None => object_param(p, "id", "livePaint.info")?.ok_or_else(|| bad("livePaint.info", "missing group"))?, + }; let d = &s.doc()?.doc; let n = d.node(g).filter(|n| is_live_paint(n)).ok_or_else(|| bad("livePaint.info", format!("{g} is not a Live Paint group")))?; let faces: Vec = b::faces(n) diff --git a/crates/engine/src/cmd/distortcmds.rs b/crates/engine/src/cmd/distortcmds.rs index 17638477a9..2dbb7a9a18 100644 --- a/crates/engine/src/cmd/distortcmds.rs +++ b/crates/engine/src/cmd/distortcmds.rs @@ -15,8 +15,8 @@ use vectorcraft_tools::distort::{PinSet, arap, collect_points, mesh_for, project use super::edit::{duplicate_in, outermost, refuse_symbol_part, selected_roots}; use super::{ - CommandSpec, Result, Session, bad, check_numbers, check_switches, cmd, f64_or, f64_req, finite_numbers, has_doc, has_selection, id_param, - matrix_param, named_id, named_ids, number_or, ok, point_indices, point_param, str_param, switch_or, targets, + CommandSpec, Result, Session, bad, check_numbers, check_switches, cmd, f64_or, f64_req, finite_numbers, has_doc, has_selection, matrix_param, + named_id, named_ids, number_or, object_param, ok, point_indices, point_param, str_param, switch_or, targets, }; use crate::EngineError; @@ -305,7 +305,7 @@ fn same_points(a: Option<&WidthProfile>, b: Option<&WidthProfile>) -> bool { fn width_point_set(s: &mut Session, p: &Value) -> Result { const C: &str = "stroke.widthPoint.set"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let t = f64_req(p, "t", C)?.clamp(0.0, 1.0); let (left, right) = (f64_req(p, "left", C)?, f64_req(p, "right", C)?); if !(left.is_finite() && right.is_finite()) || left < 0.0 || right < 0.0 || left.max(right) > 1.0e5 { @@ -362,7 +362,7 @@ fn width_point_set(s: &mut Session, p: &Value) -> Result { fn width_point_copy(s: &mut Session, p: &Value) -> Result { const C: &str = "stroke.widthPoint.copy"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let t = f64_req(p, "t", C)?.clamp(0.0, 1.0); // When the profile comes out as it was (the copy replaces a side of a discontinuous point that has // its widths already), the document and its undo history stay as they are. @@ -382,7 +382,7 @@ fn width_point_copy(s: &mut Session, p: &Value) -> Result { fn width_point_remove(s: &mut Session, p: &Value) -> Result { const C: &str = "stroke.widthPoint.remove"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let indices = match (p.get("indices"), p.get("index")) { (Some(a), _) => point_indices(a), (None, Some(i)) => point_indices(&json!([i])), diff --git a/crates/engine/src/cmd/draw2.rs b/crates/engine/src/cmd/draw2.rs index 7d1c1208d8..d469c7a613 100644 --- a/crates/engine/src/cmd/draw2.rs +++ b/crates/engine/src/cmd/draw2.rs @@ -18,8 +18,8 @@ use super::create::{add_look, insert_look}; use super::newart::NewArt; use super::pathops::{Beside, remove_each}; use super::{ - CommandSpec, Result, Session, bad, check_numbers, cmd, f64_or, has_anchors, has_doc, has_selection, id_param, index, number_or, ok, str_param, - switch_or, + CommandSpec, Result, Session, bad, check_numbers, cmd, f64_or, has_anchors, has_doc, has_selection, index, number_or, object_param, ok, + str_param, switch_or, }; use crate::EngineError; diff --git a/crates/engine/src/cmd/draw2/anchors.rs b/crates/engine/src/cmd/draw2/anchors.rs index dbca59b00d..10254f3cd3 100644 --- a/crates/engine/src/cmd/draw2/anchors.rs +++ b/crates/engine/src/cmd/draw2/anchors.rs @@ -1,7 +1,7 @@ use super::common::{MAX_INPUT, path_kind, path_mut, point_entry, usize_opt, usize_req, within_input_bound}; use super::{ Anchor, BTreeSet, Beside, Color, Document, EngineError, Node, NodeId, NodeKind, Paint, PathData, Point, Result, Selection, Session, Value, Vec2, - add_look, bad, check_numbers, f64_or, id_param, json, number_or, ok, po, str_param, switch_or, + add_look, bad, check_numbers, f64_or, json, number_or, object_param, ok, po, str_param, switch_or, }; // ---------- Curvature ---------- @@ -41,7 +41,7 @@ enum CurvatureOp { pub(super) fn curvature_edit(s: &mut Session, p: &Value) -> Result { use vectorcraft_tools::draw2::{EndCurve, curvature_close, curvature_extend, curvature_insert, curvature_move}; const C: &str = "path.curvatureEdit"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let si = usize_opt(p, "subpath", C)?.unwrap_or(0); check_numbers(p, &["x", "y", "t"], C)?; let at = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) { @@ -106,7 +106,7 @@ pub(super) fn curvature_edit(s: &mut Session, p: &Value) -> Result { pub(super) fn remove_anchor(s: &mut Session, p: &Value) -> Result { const C: &str = "path.removeAnchor"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let si = usize_opt(p, "subpath", C)?.unwrap_or(0); let ai = usize_req(p, "anchor", C)?; let curvature = switch_or(p, "curvature", false, C)?; @@ -220,7 +220,7 @@ fn remove_anchors_of( pub(super) fn convert_anchor(s: &mut Session, p: &Value) -> Result { const C: &str = "path.convertAnchor"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let si = usize_opt(p, "subpath", C)?.unwrap_or(0); let ai = usize_req(p, "anchor", C)?; let smooth = str_param(p, "to") == Some("smooth"); @@ -257,7 +257,7 @@ pub(super) fn convert_anchor(s: &mut Session, p: &Value) -> Result { pub(super) fn reshape_segment(s: &mut Session, p: &Value) -> Result { const C: &str = "path.reshapeSegment"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let si = usize_opt(p, "subpath", C)?.unwrap_or(0); let seg = usize_req(p, "segment", C)?; let t = number_or(p, "t", 0.5, C)?.clamp(0.05, 0.95); diff --git a/crates/engine/src/cmd/draw2/cut.rs b/crates/engine/src/cmd/draw2/cut.rs index 126fa57a1b..645c184b14 100644 --- a/crates/engine/src/cmd/draw2/cut.rs +++ b/crates/engine/src/cmd/draw2/cut.rs @@ -4,7 +4,7 @@ use super::common::{ }; use super::{ Anchor, AnchorKind, Beside, BezPath, BoolOp, Cap, EngineError, FillRule, Join, NodeId, ParamCurve, PathData, Point, Result, Session, SubPath, - Value, bad, eraser_area, eraser_tip, f64_or, fill_contains, id_param, json, number_or, po, + Value, bad, eraser_area, eraser_tip, f64_or, fill_contains, json, number_or, object_param, po, }; /// Split a filled path into connected pieces (each outer contour with its holes). @@ -170,7 +170,7 @@ pub(in super::super) fn erase_outline(pd: &PathData, step: f64, remove: &dyn Fn( pub(super) fn split(s: &mut Session, p: &Value) -> Result { const C: &str = "path.split"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let si = usize_opt(p, "subpath", C)?.unwrap_or(0); let anchor = usize_opt(p, "anchor", C)?; let seg = usize_opt(p, "segment", C)?; diff --git a/crates/engine/src/cmd/draw2/freehand.rs b/crates/engine/src/cmd/draw2/freehand.rs index 660f52121e..f13b0a0ba4 100644 --- a/crates/engine/src/cmd/draw2/freehand.rs +++ b/crates/engine/src/cmd/draw2/freehand.rs @@ -4,7 +4,8 @@ use super::common::{ }; use super::{ Anchor, Appearance, BTreeSet, BoolOp, Cap, Color, Document, EngineError, FillRule, Join, NewArt, Node, NodeId, NodeKind, Paint, PathData, Point, - Result, Selection, Session, SimplifyOptions, SubPath, Value, bad, id_param, insert_look, json, number_or, po, remove_each, str_param, switch_or, + Result, Selection, Session, SimplifyOptions, SubPath, Value, bad, insert_look, json, number_or, object_param, po, remove_each, str_param, + switch_or, }; // ---------- Pencil / Paintbrush ---------- @@ -158,7 +159,7 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result { let keep_selected = switch_or(p, "keepSelected", true, C)?; let filled = switch_or(p, "fill", false, C)?; if let Some(ext) = p.get("extend").filter(|v| !v.is_null()) { - let id = id_param(ext, "id").ok_or_else(|| bad(C, "extend needs id"))?; + let id = object_param(ext, "id", C)?.ok_or_else(|| bad(C, "extend needs id"))?; let at_start = str_param(ext, "end") == Some("start"); let (pd, _) = path_of(&s.doc()?.doc, id)?; let si = pd.subpaths.iter().rposition(|s| !s.closed && !s.anchors.is_empty()).ok_or_else(|| bad(C, "path has no open end"))?; @@ -179,7 +180,7 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result { // open subpath, that subpath starting at the end the stroke ends on, it ends at its end). let connect = match p.get("connect").filter(|v| !v.is_null()) { Some(cn) => { - let other = id_param(cn, "id").ok_or_else(|| bad(C, "connect needs id"))?; + let other = object_param(cn, "id", C)?.ok_or_else(|| bad(C, "connect needs id"))?; if other == id { return Err(bad(C, "connect needs a path other than the one extended")); } @@ -299,7 +300,7 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result { return Ok(json!({ "id": id.0 })); } if let Some(rd) = p.get("redraw").filter(|v| !v.is_null()) { - let id = id_param(rd, "id").ok_or_else(|| bad(C, "redraw needs id"))?; + let id = object_param(rd, "id", C)?.ok_or_else(|| bad(C, "redraw needs id"))?; let within = number_or(rd, "within", 12.0, C)?; if !within.is_finite() || within < 0.0 { return Err(bad(C, "redraw within must be a finite distance of 0 or more")); diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 7a8a91f0af..fe683bfa9b 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -10,8 +10,8 @@ use vectorcraft_geom::{Affine, Point, Vec2}; use super::clipboard::SwatchChoices; use super::{ CommandSpec, Result, Session, always, bad, bool_or, can_clear_history, can_redo, can_undo, checked_ids_param, clock_date, cmd, date_param, - f64_or, f64_req, finite_numbers, has_clipboard, has_doc, has_selection, has_selection_guides_or_artboard_tool, has_selection_or_guides, id_param, - index, ok, point_param, str_param, switch_or, + f64_or, f64_req, finite_numbers, has_clipboard, has_doc, has_selection, has_selection_guides_or_artboard_tool, has_selection_or_guides, index, + object_param, ok, point_param, str_param, switch_or, }; use crate::{Clipboard, EngineError, inspect}; @@ -267,7 +267,7 @@ fn doc_move(s: &mut Session, p: &Value) -> Result { } fn doc_node(s: &mut Session, p: &Value) -> Result { - let id = id_param(p, "id").ok_or_else(|| bad("document.node", "missing id"))?; + let id = object_param(p, "id", "document.node")?.ok_or_else(|| bad("document.node", "missing id"))?; let mut opts = slice_opts(p, "document.node")?; let summary = bool_or(p, "summary", false); if !summary && (opts.depth.is_some() || opts.child_limit.is_some()) { diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 49b1015bf4..593d325460 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -15,7 +15,7 @@ use vectorcraft_geom::{Affine, Rect}; use super::{ CommandSpec, Result, Session, bad, bool_or, check_numbers, check_switches, checked_id, checked_ids_param, cmd, color_value, f64_or, has_doc, - has_selection, id_param, ids_param, index, ok, str_param, switch_or, + has_selection, ids_param, index, object_param, ok, str_param, switch_or, }; use crate::EngineError; @@ -426,7 +426,7 @@ fn paste_remembers_layers(s: &mut Session, p: &Value) -> Result { /// The Layers panel's target circle. fn target(s: &mut Session, p: &Value) -> Result { const C: &str = "layer.target"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let st = s.doc()?; let n = st.doc.node(id).ok_or(EngineError::NoNode(id))?; if !st.doc.is_editable(id) { @@ -594,7 +594,7 @@ fn duplicate_rows(s: &mut Session, p: &Value) -> Result { fn set_current(s: &mut Session, p: &Value) -> Result { const C: &str = "layer.setCurrent"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let st = s.doc_mut()?; let layer = st.doc.layer_containing(id).ok_or(EngineError::NoNode(id))?; st.active_layer = Some(layer); @@ -700,7 +700,7 @@ fn in_isolation(doc: &Document, isolation: Option, id: NodeId) -> bool { /// The selection column: select a row's art. fn select_art(s: &mut Session, p: &Value) -> Result { const C: &str = "layer.selectAll"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let add = switch_or(p, "add", false, C)?; let st = s.doc()?; let n = st.doc.node(id).ok_or(EngineError::NoNode(id))?; @@ -747,9 +747,12 @@ enum Place { /// Drag rows in the Layers panel. fn move_rows(s: &mut Session, p: &Value) -> Result { const C: &str = "layer.move"; - let mut ids = ids_param(p, "ids", C)?.or_else(|| id_param(p, "id").map(|i| vec![i])).ok_or_else(|| bad(C, "missing ids"))?; + let mut ids = match ids_param(p, "ids", C)? { + Some(ids) => ids, + None => vec![object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing ids"))?], + }; ids.truncate(MAX_ROWS); - let target = id_param(p, "target").ok_or_else(|| bad(C, "missing target"))?; + let target = object_param(p, "target", C)?.ok_or_else(|| bad(C, "missing target"))?; let place = match str_param(p, "place").unwrap_or("inside") { "above" => Place::Above, "below" => Place::Below, @@ -831,8 +834,8 @@ fn move_rows(s: &mut Session, p: &Value) -> Result { fn move_node(s: &mut Session, p: &Value) -> Result { const C: &str = "node.move"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; - let parent = id_param(p, "parent"); + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; + let parent = object_param(p, "parent", C)?; let index = p.get("index").and_then(Value::as_u64).map_or(usize::MAX, index); let d = &s.doc()?.doc; let n = d.node(id).ok_or(EngineError::NoNode(id))?; diff --git a/crates/engine/src/cmd/live.rs b/crates/engine/src/cmd/live.rs index 464f241159..618865cede 100644 --- a/crates/engine/src/cmd/live.rs +++ b/crates/engine/src/cmd/live.rs @@ -21,7 +21,7 @@ use super::expand::gradient_sampler; use super::pathops::Beside; use super::{ CommandSpec, EngineError, Result, Session, always, bad, check_numbers, cmd, color_param, f64_or, f64_req, finite_numbers, has_doc, has_multi, - has_selection, id_param, named_ids, number_or, ok, str_param, switch_or, text_param, + has_selection, named_ids, number_or, object_param, ok, str_param, switch_or, text_param, }; mod blend; diff --git a/crates/engine/src/cmd/live/envelope.rs b/crates/engine/src/cmd/live/envelope.rs index 1aceb49b54..3412023280 100644 --- a/crates/engine/src/cmd/live/envelope.rs +++ b/crates/engine/src/cmd/live/envelope.rs @@ -3,7 +3,7 @@ use super::common::{fix_ids, ids_json, is_envelope, roots_param, selected_of, wr use super::mesh::{handle_param, index_param, whole}; use super::{ Affine, Appearance, Arc, Beside, Color, Document, EngineError, EnvelopeKind, EnvelopeMap, EnvelopeOptions, Node, NodeId, NodeKind, Paint, - PathData, Point, PreserveShape, Result, Session, Value, bad, check_numbers, f64_or, f64_req, id_param, json, live, ok, switch_or, text_param, + PathData, Point, PreserveShape, Result, Session, Value, bad, check_numbers, f64_or, f64_req, json, live, object_param, ok, switch_or, text_param, }; // ---------- Envelope ---------- @@ -419,7 +419,7 @@ pub(super) fn env_edit_contents(s: &mut Session, p: &Value) -> Result { pub(super) fn env_set_mesh_point(s: &mut Session, p: &Value) -> Result { const C: &str = "object.envelope.setMeshPoint"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?; let index = index_param(p, "index", C)?; let q = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?); let handle = handle_param(p, C)?; diff --git a/crates/engine/src/cmd/live/mesh.rs b/crates/engine/src/cmd/live/mesh.rs index 70c2d9f348..187d2149f2 100644 --- a/crates/engine/src/cmd/live/mesh.rs +++ b/crates/engine/src/cmd/live/mesh.rs @@ -4,7 +4,7 @@ use super::common::{fix_ids, ids_json, is_mesh, replace_with, roots_param, selec use super::envelope::{outline_of, rows_cols}; use super::{ Color, Document, EngineError, EnvelopeKind, GradientMesh, MeshAppearance, Node, NodeId, NodeKind, Paint, Point, Result, Session, Value, bad, - check_numbers, color_param, f64_or, f64_req, finite_numbers, gradient_sampler, id_param, json, live, ok, str_param, + check_numbers, color_param, f64_or, f64_req, finite_numbers, gradient_sampler, json, live, object_param, ok, str_param, }; // ---------- Gradient mesh ---------- @@ -115,7 +115,7 @@ fn image_mesh(d: &Document, im: &vectorcraft_doc::ImageObject, rows: u32, cols: /// Run `f` on the gradient mesh `p.id` (one undo step `label`); with `envelopes`, a mesh /// envelope's grid too (see [`edit_grid`]). fn with_mesh(s: &mut Session, p: &Value, cmd: &str, label: &str, envelopes: bool, f: impl FnOnce(&mut GradientMesh) -> Result) -> Result { - let id = id_param(p, "id").ok_or_else(|| bad(cmd, "missing `id`"))?; + let id = object_param(p, "id", cmd)?.ok_or_else(|| bad(cmd, "missing `id`"))?; s.edit(label, move |d, _| edit_grid(d, id, cmd, envelopes, f)) } @@ -147,7 +147,7 @@ const MAX_POINTS: usize = 1 << 20; /// points}…]}`. Each mesh comes once, with its points in order and without repeats. fn point_items(p: &Value, cmd: &str) -> Result)>> { let one = |q: &Value| -> Result<(NodeId, BTreeSet)> { - let id = id_param(q, "id").ok_or_else(|| bad(cmd, "missing `id`"))?; + let id = object_param(q, "id", cmd)?.ok_or_else(|| bad(cmd, "missing `id`"))?; let points = match q.get("points") { None | Some(Value::Null) => BTreeSet::from([index_param(q, "index", cmd)?]), Some(Value::Array(v)) if v.len() <= MAX_POINTS => v diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index 712ca8a179..518b2062f8 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -12,7 +12,7 @@ use super::draw2::usize_opt; use super::pathops::Beside; use super::{ CommandSpec, Result, Session, bad, check_numbers, check_switches, cmd, f64_or, f64_req, finite_numbers, has_anchors, has_doc, has_selected_path, - has_selection, id_param, ids_param, index, number_or, ok, str_param, switch_or, targets, + has_selection, ids_param, index, number_or, object_param, ok, str_param, switch_or, targets, }; use crate::EngineError; @@ -168,7 +168,7 @@ pub(super) fn path_mut(d: &mut vectorcraft_doc::Document, id: NodeId) -> Result< } fn append_anchor(s: &mut Session, p: &Value) -> Result { - let id = id_param(p, "id").ok_or_else(|| bad("path.appendAnchor", "missing id"))?; + let id = object_param(p, "id", "path.appendAnchor")?.ok_or_else(|| bad("path.appendAnchor", "missing id"))?; let a = anchor_from_json(p, "path.appendAnchor")?; let subpath = p.get("subpath").and_then(Value::as_u64).map(|u| u as usize); s.edit("Pen", |d, sel| { @@ -204,7 +204,7 @@ fn append_anchor(s: &mut Session, p: &Value) -> Result { /// Close the active open subpath of `id`, optionally setting the incoming handle of the first anchor. fn close(s: &mut Session, p: &Value) -> Result { - let id = id_param(p, "id").ok_or_else(|| bad("path.close", "missing id"))?; + let id = object_param(p, "id", "path.close")?.ok_or_else(|| bad("path.close", "missing id"))?; let subpath = p.get("subpath").and_then(Value::as_u64).map(|u| u as usize); let h_in = match p.get("in").filter(|h| !h.is_null()) { None => None, @@ -297,7 +297,7 @@ pub(crate) fn transform_anchors(s: &mut Session, label: &str, xf: vectorcraft_ge } fn set_handle(s: &mut Session, p: &Value) -> Result { - let id = id_param(p, "id").ok_or_else(|| bad("path.setHandle", "missing id"))?; + let id = object_param(p, "id", "path.setHandle")?.ok_or_else(|| bad("path.setHandle", "missing id"))?; let si = usize_opt(p, "subpath", "path.setHandle")?.unwrap_or(0); let ai = p.get("anchor").and_then(Value::as_u64).map(index).ok_or_else(|| bad("path.setHandle", "missing anchor"))?; let out = str_param(p, "which") != Some("in"); @@ -315,7 +315,7 @@ fn set_handle(s: &mut Session, p: &Value) -> Result { /// `path.moveAnchor`: one anchor to (x, y), its handles keeping their offsets (the Anchor Point /// tool's Shift-drag). fn move_anchor(s: &mut Session, p: &Value) -> Result { - let id = id_param(p, "id").ok_or_else(|| bad("path.moveAnchor", "missing id"))?; + let id = object_param(p, "id", "path.moveAnchor")?.ok_or_else(|| bad("path.moveAnchor", "missing id"))?; let si = usize_opt(p, "subpath", "path.moveAnchor")?.unwrap_or(0); let ai = p.get("anchor").and_then(Value::as_u64).map(index).ok_or_else(|| bad("path.moveAnchor", "missing anchor"))?; let pos = Point::new(f64_req(p, "x", "path.moveAnchor")?, f64_req(p, "y", "path.moveAnchor")?); @@ -335,7 +335,7 @@ fn move_anchor(s: &mut Session, p: &Value) -> Result { } fn set_anchors(s: &mut Session, p: &Value) -> Result { - let id = id_param(p, "id").ok_or_else(|| bad("path.setAnchors", "missing id"))?; + let id = object_param(p, "id", "path.setAnchors")?.ok_or_else(|| bad("path.setAnchors", "missing id"))?; let subs = p.get("subpaths").and_then(Value::as_array).ok_or_else(|| bad("path.setAnchors", "missing subpaths"))?; let data = PathData::new( subs.iter() @@ -1556,7 +1556,7 @@ fn renumber_after_insert(sel: &mut Selection, id: NodeId, si: usize, added: usiz } fn reshape(s: &mut Session, p: &Value) -> Result { - let id = id_param(p, "id").ok_or_else(|| bad("path.reshape", "missing id"))?; + let id = object_param(p, "id", "path.reshape")?.ok_or_else(|| bad("path.reshape", "missing id"))?; let at = Point::new(f64_req(p, "x", "path.reshape")?, f64_req(p, "y", "path.reshape")?); check_numbers(p, &["dx", "dy", "tol", "radius"], "path.reshape")?; let delta = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0)); @@ -1678,7 +1678,7 @@ fn reshape(s: &mut Session, p: &Value) -> Result { } fn insert_anchor(s: &mut Session, p: &Value) -> Result { - let id = id_param(p, "id").ok_or_else(|| bad("path.insertAnchor", "missing id"))?; + let id = object_param(p, "id", "path.insertAnchor")?.ok_or_else(|| bad("path.insertAnchor", "missing id"))?; let si = usize_opt(p, "subpath", "path.insertAnchor")?.unwrap_or(0); let seg = p.get("segment").and_then(Value::as_u64).map(index).ok_or_else(|| bad("path.insertAnchor", "missing segment"))?; let t = number_or(p, "t", 0.5, "path.insertAnchor")?.clamp(0.0, 1.0); diff --git a/crates/engine/src/cmd/slices.rs b/crates/engine/src/cmd/slices.rs index 06ec067934..787d48e441 100644 --- a/crates/engine/src/cmd/slices.rs +++ b/crates/engine/src/cmd/slices.rs @@ -13,7 +13,7 @@ use vectorcraft_doc::{Appearance, CellAlign, CellVAlign, Document, Node, NodeKin use vectorcraft_geom::{Rect, shapes}; use super::{ - CommandSpec, EngineError, NodeId, Result, Session, bad, bool_or, cmd, f64_or, f64_req, has_doc, has_selection, id_param, ids_param, index, + CommandSpec, EngineError, NodeId, Result, Session, bad, bool_or, cmd, f64_or, f64_req, has_doc, has_selection, ids_param, index, object_param, switch_or, targets, }; @@ -679,7 +679,7 @@ fn create(s: &mut Session, p: &Value) -> Result { fn set_rect(s: &mut Session, p: &Value) -> Result { const C: &str = "object.slice.setRect"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?; let r = rect_param(p, C)?; let d = &s.doc()?.doc; if d.slice(id).is_none() { diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 21ed6d8023..4bcbd6c932 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -11,8 +11,8 @@ use super::menucmds::whole_or; use super::stroke::StrokeChange; use super::typecmd::refresh_bounds; use super::{ - CommandSpec, EngineError, Result, Session, bad, bool_or, check_numbers, check_switches, cmd, color_value, has_doc, has_selection, id_param, - index, number_or, point_param, str_param, switch_or, targets, text_param, + CommandSpec, EngineError, Result, Session, bad, bool_or, check_numbers, check_switches, cmd, color_value, has_doc, has_selection, index, + number_or, object_param, point_param, str_param, switch_or, targets, text_param, }; pub fn specs() -> Vec { @@ -272,7 +272,7 @@ fn range_of(p: &Value, len: usize, cmd: &str) -> Result<(usize, usize)> { fn edit_range(s: &mut Session, p: &Value) -> Result { const C: &str = "text.editRange"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?; let t = text_ref(s, id)?; let len = edit::runs_len(&t.runs); let (a, b) = range_of(p, len, C)?; @@ -337,7 +337,7 @@ fn clear_param(s: &Session, p: &Value, id: NodeId, c: &str) -> Result Result { const C: &str = "text.startList"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?; let t = text_ref(s, id)?; let len = edit::runs_len(&t.runs); let at = offset_or(p, "at", len, C)?.min(len); @@ -365,7 +365,7 @@ fn start_list(s: &mut Session, p: &Value) -> Result { } fn get_range(s: &mut Session, p: &Value) -> Result { - let id = id_param(p, "id").ok_or_else(|| bad("text.getRange", "missing `id`"))?; + let id = object_param(p, "id", "text.getRange")?.ok_or_else(|| bad("text.getRange", "missing `id`"))?; if switch_or(p, "story", false, "text.getRange")? { return get_story(s, p, id); } @@ -1254,7 +1254,7 @@ fn drawn_glyph( fn set_glyph_alternate(s: &mut Session, p: &Value) -> Result { const C: &str = "text.setGlyphAlternate"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?; let gid = match p.get("gid") { None => return Err(bad(C, "missing `gid` (a glyph id, or null for the default glyph)")), Some(Value::Null) => None, @@ -1344,7 +1344,7 @@ fn set_glyph_alternate(s: &mut Session, p: &Value) -> Result { fn glyph_alternates(s: &mut Session, p: &Value) -> Result { const C: &str = "text.glyphAlternates"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?; let t = text_ref(s, id)?; let (ch, bytes, face) = glyph_target(&t, p, C)?; let default = face.glyph_for(ch); @@ -1359,7 +1359,7 @@ fn glyph_alternates(s: &mut Session, p: &Value) -> Result { fn touch_type(s: &mut Session, p: &Value) -> Result { const C: &str = "text.touchType"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?; check_numbers(p, &["start"], C)?; let start = byte_param(p, "start").ok_or_else(|| bad(C, "missing `start` (the byte offset of a character)"))?; if !["dx", "dy", "hScale", "vScale", "rotation"].iter().any(|k| p.get(*k).is_some_and(|v| !v.is_null())) { @@ -1585,7 +1585,7 @@ fn touch_type(s: &mut Session, p: &Value) -> Result { fn create_in_path(s: &mut Session, p: &Value) -> Result { const C: &str = "text.createInPath"; - let pid = id_param(p, "path").ok_or_else(|| bad(C, "missing `path` (a path object id)"))?; + let pid = object_param(p, "path", C)?.ok_or_else(|| bad(C, "missing `path` (a path object id)"))?; let on_path = match str_param(p, "mode").unwrap_or("area") { "area" => false, "onPath" | "path" => true, @@ -1975,7 +1975,7 @@ fn fit_headline(s: &mut Session, p: &Value) -> Result { fn discard_empty(s: &mut Session, p: &Value) -> Result { const C: &str = "text.discardEmpty"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing `id`"))?; if edit::runs_len(&text_ref(s, id)?.runs) > 0 { return Ok(json!({"removed": false})); } diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 6d29550fa0..bf5de3a0cb 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -10,8 +10,8 @@ use vectorcraft_geom::{Affine, PathData, Vec2}; use super::edit::selected_roots; use super::pathops::{length_given, number_given, shape_node}; use super::{ - CommandSpec, EngineError, Result, Session, bad, check_numbers, cmd, color_value, f64_req, has_doc, has_selection, id_param, index, number_or, ok, - str_param, switch_or, targets, text_param, + CommandSpec, EngineError, Result, Session, bad, check_numbers, cmd, color_value, f64_req, has_doc, has_selection, index, number_or, object_param, + ok, str_param, switch_or, targets, text_param, }; pub fn specs() -> Vec { @@ -1222,7 +1222,7 @@ fn reshape_area(s: &mut Session, p: &Value) -> Result { }; let mut items = Vec::with_capacity(raw.len()); for it in raw { - let id = id_param(it, "id").ok_or_else(|| bad(C, "missing id"))?; + let id = object_param(it, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let mut refs = super::select::checked_refs(it.get("anchors"), C)?; refs.sort_unstable(); refs.dedup(); @@ -1253,7 +1253,7 @@ fn reshape_area(s: &mut Session, p: &Value) -> Result { fn reshape_path(s: &mut Session, p: &Value) -> Result { const C: &str = "text.reshapePath"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let mut refs = super::select::checked_refs(p.get("anchors"), C)?; refs.sort_unstable(); refs.dedup(); @@ -1284,7 +1284,7 @@ fn reshape_path(s: &mut Session, p: &Value) -> Result { fn set_path_handle(s: &mut Session, p: &Value) -> Result { const C: &str = "text.setPathHandle"; - let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; + let id = object_param(p, "id", C)?.ok_or_else(|| bad(C, "missing id"))?; let si = super::draw2::usize_opt(p, "subpath", C)?.unwrap_or(0); let ai = p.get("anchor").and_then(Value::as_u64).map(index).ok_or_else(|| bad(C, "missing anchor"))?; let out = super::object::choice(p, C, "which", &["in", "out"])? != Some("in"); diff --git a/crates/engine/src/cmd/wand.rs b/crates/engine/src/cmd/wand.rs index c79ec0b86e..218871972f 100644 --- a/crates/engine/src/cmd/wand.rs +++ b/crates/engine/src/cmd/wand.rs @@ -8,7 +8,7 @@ use vectorcraft_color::Paint; use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; use super::select::selectable_ids; -use super::{CommandSpec, EngineError, Result, Session, always, bad, bool_or, cmd, has_doc, id_param, str_param}; +use super::{CommandSpec, EngineError, Result, Session, always, bad, bool_or, cmd, has_doc, object_param, str_param}; /// Magic Wand panel settings. Tolerances: colours as 0–255 RGB distance, stroke weight in /// points, opacity in percent. @@ -152,7 +152,7 @@ fn paintable(doc: &Document) -> Vec { } fn magic_wand(s: &mut Session, p: &Value) -> Result { - let id = id_param(p, "id").ok_or_else(|| bad("select.magicWand", "missing id"))?; + let id = object_param(p, "id", "select.magicWand")?.ok_or_else(|| bad("select.magicWand", "missing id"))?; let st = s.doc()?; let reference = st.doc.node(id).ok_or(EngineError::NoNode(id))?.clone(); let w = s.menu.wand.clone(); diff --git a/crates/engine/src/tests/params.rs b/crates/engine/src/tests/params.rs index b0345db4ff..6850715b0e 100644 --- a/crates/engine/src/tests/params.rs +++ b/crates/engine/src/tests/params.rs @@ -400,6 +400,90 @@ fn stroke_set_refuses_an_unknown_key() { } } +/// An object id given as something other than a whole number (`"5"`, `-1`) is refused as such, +/// naming its key, not as a missing id; an id left out is still a missing one. +#[test] +fn a_malformed_object_id_is_not_reported_as_missing() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap(); + let undo = |s: &Session| s.doc().unwrap().history.undo.len(); + let before = undo(&s); + let points = json!([[0, 0], [10, 10], [20, 0]]); + for bad in [json!("5"), json!(-1)] { + let id = || json!({ "id": bad }); + let with = |extra: serde_json::Value| { + let mut p = id(); + p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + p + }; + for (cmd, p, key) in [ + ("stroke.widthPoint.set", id(), "id"), + ("stroke.widthPoint.copy", id(), "id"), + ("stroke.widthPoint.remove", id(), "id"), + ("path.curvatureEdit", id(), "id"), + ("path.removeAnchor", id(), "id"), + ("path.convertAnchor", id(), "id"), + ("path.reshapeSegment", id(), "id"), + ("path.split", id(), "id"), + ("path.appendAnchor", id(), "id"), + ("path.close", id(), "id"), + ("path.setHandle", id(), "id"), + ("path.moveAnchor", id(), "id"), + ("path.setAnchors", id(), "id"), + ("path.reshape", id(), "id"), + ("path.insertAnchor", id(), "id"), + ("path.freehand", json!({"points": points, "extend": {"id": bad}}), "id"), + ("path.freehand", json!({"points": points, "redraw": {"id": bad}}), "id"), + ("document.node", id(), "id"), + ("layer.target", id(), "id"), + ("layer.setCurrent", id(), "id"), + ("layer.selectAll", id(), "id"), + ("layer.move", with(json!({"target": 1})), "id"), + ("layer.move", json!({"ids": [1], "target": bad}), "target"), + ("node.move", id(), "id"), + ("node.move", json!({"id": 1, "parent": bad}), "parent"), + ("text.reshapeArea", with(json!({"anchors": []})), "id"), + ("text.reshapeArea", json!({"items": [{"id": bad, "anchors": []}]}), "id"), + ("text.reshapePath", id(), "id"), + ("text.setPathHandle", id(), "id"), + ("text.editRange", id(), "id"), + ("text.startList", id(), "id"), + ("text.getRange", id(), "id"), + ("text.setGlyphAlternate", id(), "id"), + ("text.glyphAlternates", id(), "id"), + ("text.touchType", id(), "id"), + ("text.discardEmpty", id(), "id"), + ("text.createInPath", json!({"path": bad}), "path"), + ("object.envelope.setMeshPoint", id(), "id"), + ("object.mesh.setPointColor", with(json!({"index": 0})), "id"), + ("object.mesh.movePoint", with(json!({"index": 0, "x": 0, "y": 0})), "id"), + ("object.mesh.movePoints", json!({"items": [{"id": bad, "index": 0}]}), "id"), + ("object.mesh.addLine", with(json!({"x": 0, "y": 0})), "id"), + ("object.mesh.deletePoint", with(json!({"index": 0})), "id"), + ("object.slice.setRect", id(), "id"), + ("appearance.transfer", json!({"source": bad, "target": 1}), "source"), + ("appearance.transfer", json!({"source": 1, "target": bad}), "target"), + ("appearance.copyFrom", json!({"source": bad}), "source"), + ("livePaint.strokeEdge", json!({"point": [0, 0], "group": bad}), "group"), + ("livePaint.fill", json!({"point": [0, 0], "group": bad}), "group"), + ("livePaint.info", json!({"group": bad}), "group"), + ("livePaint.info", id(), "id"), + ("select.magicWand", id(), "id"), + ] { + let e = s.execute(cmd, &p).unwrap_err().to_string(); + // `layer.move` checks an `id` that stands in for `ids` before it runs. + let named = e.contains(&format!("`{key}` must be a whole number")) || (cmd == "layer.move" && e.contains("invalid object id")); + assert!(named, "{cmd} {p}: {e}"); + } + } + for cmd in ["path.close", "layer.target", "text.reshapePath", "object.mesh.movePoint", "select.magicWand"] { + let e = s.execute(cmd, &json!({})).unwrap_err().to_string(); + assert!(e.contains("missing"), "{cmd}: {e}"); + } + assert_eq!(undo(&s), before); +} + /// A command that takes the keys of one that isn't checked yet isn't checked either. #[test] fn a_reference_to_an_unchecked_command_is_unchecked() { diff --git a/docs/mcp.md b/docs/mcp.md index 5826c5c8c4..892541c6a8 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -430,7 +430,13 @@ or a new object (`css.export`, `object.isolate`, `layer.locate`, `layer.releaseT counterparts, `image.info`, `links.info`, `links.goTo`, `links.unembed`, `shaper.release`, `shaper.expand`, `transparency.editOpacityMask`, `transparency.viewOpacityMask`, `symbol.edit`, the `object.blend.spine.*` commands and `path.curvature`) refuse an `id` that is not a non-negative integer (`"abc"`, `"5"`, `true`) with an error and -change nothing; an omitted or null `id` keeps the default. A command that leaves out an object selected inside a +change nothing; an omitted or null `id` keeps the default. Commands that need an object id (`document.node`, the +`path.*` anchor and handle commands, `text.reshapePath`, `text.editRange`, the `object.mesh.*` point commands, +`layer.target`, `node.move`, `select.magicWand` and the others like them, and the `source`, `target`, `group` and +`path` keys of `appearance.transfer`, `appearance.copyFrom`, `layer.move`, the `livePaint.*` commands and +`text.createInPath`) refuse a value that is not a non-negative integer with "`id` must be a whole number", naming the +key, and report a key left out or null as missing. The optional `group` of `livePaint.fill` and `parent` of +`node.move` are refused the same way when given; left out, they keep their defaults. A command that leaves out an object selected inside a selected group also leaves it out when `ids` list it together with a group that holds it. Every command that takes `ids` (or `slices`, for the slice commands) refuses a list with an entry that is not a non-negative integer, or a value that is not a list, and changes nothing; it does not act on the other entries. The error for an entry names the key, From b426822a9cf33461bc4fcbfac261e39efcea66b7 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 13:06:51 -0800 Subject: [PATCH 004/100] Type: Release Type on a Path keeps the size Dynamic Text drew the type at Release Type on a Path copied the stored runs into point type with the Dynamic Text flag still set, so the text dropped back to its stored size and the file saved the flag on point type. The point type now gets the drawn sizes and the flag is turned off. The legacy .ai writer writes Dynamic Text type at its drawn sizes; it now also warns that the setting itself is not written, as the editing data writer does, whether the type is written as text or as outlines. docs/target-app-parity.md lists reshaping or scaling the path among VectorCraft's own rules, and a task bar comment marks leaving the item out for threaded type as VectorCraft's own rule. --- crates/engine/src/cmd/addons/typetools.rs | 11 ++++++--- crates/engine/src/tests/dynamic_text.rs | 20 +++++++++++++++ crates/eps/src/legacy_ai.rs | 1 + crates/eps/src/legacy_ai/tests.rs | 30 +++++++++++++++++++++++ crates/eps/src/legacy_ai/writer.rs | 8 ++++-- crates/ui-egui/src/canvas/task_bar.rs | 3 ++- docs/mcp.md | 12 +++++---- docs/target-app-parity.md | 2 +- 8 files changed, 74 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/cmd/addons/typetools.rs b/crates/engine/src/cmd/addons/typetools.rs index bc1d4e4f25..537e6bfe75 100644 --- a/crates/engine/src/cmd/addons/typetools.rs +++ b/crates/engine/src/cmd/addons/typetools.rs @@ -21,7 +21,7 @@ use super::super::menucmds::whole_or; use super::super::object::choice; use super::super::textedit::{CharChange, text_mut, text_ref, type_ranges}; use super::super::typecmd::refresh_bounds; -use super::super::typemenu::text_ids; +use super::super::typemenu::{bake_dynamic_text, text_ids}; use super::super::{CommandSpec, bad, cmd, has_selection, number_or, switch_or, targets}; use crate::{EngineError, Result, Session}; @@ -61,7 +61,7 @@ pub fn specs() -> Vec { "Release Type on a Path…", ["Object", "Plug-ins", "Type Tools"], None, - "{mode?: \"both\" (point type and the path) | \"path\" (the path only) | \"text\" (point type only) (\"both\"), keepOriginal?: false (true: the type on a path stays and the released objects go just above it), ids?: [id…], id? (instead of ids; neither: the selection)} split each type on a path among the objects (inside groups too) into point type and the path it was set on. The point type holds the same text with the same character and paragraph attributes, on one straight line whose baseline starts where the first character sat on the path; Type on a Path Options (effect, align to path, spacing, flip, brackets) don't apply to point type and are dropped. The path keeps its exact shape (open or closed, anchors and handles, the object's transform) and comes out with no fill and no stroke, in the type's layer and place in the stacking order. Other objects are left alone, and with no type on a path among them nothing changes and no undo step is made. One undo step; the released objects are selected → {ids (released, bottom to top), paths, texts}", + "{mode?: \"both\" (point type and the path) | \"path\" (the path only) | \"text\" (point type only) (\"both\"), keepOriginal?: false (true: the type on a path stays and the released objects go just above it), ids?: [id…], id? (instead of ids; neither: the selection)} split each type on a path among the objects (inside groups too) into point type and the path it was set on. The point type holds the same text with the same character and paragraph attributes, on one straight line whose baseline starts where the first character sat on the path; Type on a Path Options (effect, align to path, spacing, flip, brackets) don't apply to point type and are dropped; type with Dynamic Text on (type.dynamicText) comes out with it off, at the sizes it was drawn at. The path keeps its exact shape (open or closed, anchors and handles, the object's transform) and comes out with no fill and no stroke, in the type's layer and place in the stacking order. Other objects are left alone, and with no type on a path among them nothing changes and no undo step is made. One undo step; the released objects are selected → {ids (released, bottom to top), paths, texts}", has_selection, release_path ), @@ -230,8 +230,9 @@ fn first_pen(lay: &vectorcraft_text::TextLayout) -> Option { /// `t`, type on a path, as point type on one straight line: the same runs and character and /// paragraph attributes, its baseline starting where its first character sat on the path (in the -/// object's own text space, so its transform stays). Type on a Path Options are dropped. None for -/// other type, or when it can't be placed. +/// object's own text space, so its transform stays). Type on a Path Options are dropped, and +/// Dynamic Text is turned off at the sizes it draws the text at. None for other type, or when it +/// can't be placed. fn released_text(t: &TextObject) -> Option { let TextKind::OnPath { path, .. } = &t.kind else { return None }; let db = vectorcraft_text::FontDb::global(); @@ -244,6 +245,8 @@ fn released_text(t: &TextObject) -> Option { (p.x.is_finite() && p.y.is_finite()).then_some(p) })?; let mut out = t.clone(); + // Point type has no brackets to fit: it keeps the sizes the path drew it at. + bake_dynamic_text(&mut out); out.kind = TextKind::Point; out.path_effect = Default::default(); out.path_align = Default::default(); diff --git a/crates/engine/src/tests/dynamic_text.rs b/crates/engine/src/tests/dynamic_text.rs index 80ad1f1af9..f9d21a6404 100644 --- a/crates/engine/src/tests/dynamic_text.rs +++ b/crates/engine/src/tests/dynamic_text.rs @@ -238,6 +238,26 @@ fn svg_export_writes_the_drawn_size() { assert!(!sizes.iter().any(|v| (v - 12.0).abs() < 1e-3), "the stored size is not written: {sizes:?}"); } +#[test] +fn release_type_on_a_path_keeps_the_drawn_size() { + let mut s = session(); + let id = on_line(&mut s, "Release"); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let size = 12.0 * fit(&s, id); + assert!(size > 24.0, "the text grows on the long line: {size}"); + let r = s.execute("addon.type.releasePath", &json!({"ids": [id.0], "mode": "text"})).unwrap(); + let released = NodeId(r["texts"][0].as_u64().unwrap()); + let t = text(&s, released); + assert!(!t.dynamic_text, "point type does not keep Dynamic Text"); + assert!((t.runs[0].style.size - size).abs() < 1e-9, "{} vs the drawn {size}", t.runs[0].style.size); + assert_eq!(fit(&s, released), 1.0); + // Type on a path without Dynamic Text keeps its stored size. + let plain = on_line(&mut s, "Plain"); + let r = s.execute("addon.type.releasePath", &json!({"ids": [plain.0], "mode": "text"})).unwrap(); + let t = text(&s, NodeId(r["texts"][0].as_u64().unwrap())); + assert!(!t.dynamic_text && t.runs[0].style.size == 12.0); +} + /// The id of the first text object in `s`'s document. fn first_text(s: &Session) -> NodeId { let mut found = None; diff --git a/crates/eps/src/legacy_ai.rs b/crates/eps/src/legacy_ai.rs index 6b553b8d1a..0bf3960e86 100644 --- a/crates/eps/src/legacy_ai.rs +++ b/crates/eps/src/legacy_ai.rs @@ -97,6 +97,7 @@ pub const SUBLAYERS: &str = "sublayers are written as groups in their top-level pub const TYPE: &str = "type a legacy .ai can't hold as text (characters outside Windows Latin-1, vertical type, ruby, inline graphics, gradient or pattern fills) is written as outlines"; pub const ROTATED_TYPE: &str = "type with rotated characters is written as outlines, which keep the rotation, and is no longer editable text (legacy .ai text can't rotate characters)"; pub const TYPE_ATTRIBUTES: &str = "type attributes legacy .ai files don't have (underline, strikethrough, manual kerning, optical kerning, small caps, superscript and subscript, OpenType features, Variable Font settings, optical margin alignment, System Layout, fractional widths off, area type options, type on a path's effect, alignment and spacing, an end bracket short of the path's end, subpaths after the first in an area type frame or a type path, space after paragraphs, tabs, bulleted and numbered lists) are left out"; +pub const DYNAMIC_TEXT: &str = "Dynamic Text is written as the sizes the type is drawn at (legacy .ai files don't have it)"; pub const HIDDEN: &str = "hidden objects are left out (hidden layers are kept, hidden)"; pub const HIDDEN_LAYER: &str = "hidden objects are moved to a hidden layer above their own (legacy .ai files can hide only layers)"; pub const HIDDEN_SHOWN: &str = "hidden objects are written visible (legacy .ai files can hide only layers)"; diff --git a/crates/eps/src/legacy_ai/tests.rs b/crates/eps/src/legacy_ai/tests.rs index 979cd2a436..988cd9a1f0 100644 --- a/crates/eps/src/legacy_ai/tests.rs +++ b/crates/eps/src/legacy_ai/tests.rs @@ -1233,6 +1233,36 @@ fn a_font_height_reference_is_written_as_the_size_drawn() { assert!((size - 20.0).abs() < 1e-6, "{size}"); } +/// Type on a path with Dynamic Text is written at the sizes it is drawn at, with a warning that +/// the setting itself is not written; without Dynamic Text there is no warning. +#[test] +fn dynamic_text_is_written_at_its_drawn_sizes_with_a_warning() { + let mut doc = typed(); + assert!(!write(&doc).warnings.iter().any(|w| w == DYNAMIC_TEXT)); + let id = texts(&doc).into_iter().find(|(_, t)| matches!(t.kind, vectorcraft_doc::TextKind::OnPath { .. })).unwrap().0.id; + let mut drawn = 0.0; + if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) { + t.runs[0].style.size = 4.0; + t.dynamic_text = true; + drawn = 4.0 * vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).fit_scale; + } + assert!(drawn > 4.0, "{drawn}"); + let out = write(&doc); + assert!(out.warnings.iter().any(|w| w == DYNAMIC_TEXT), "{:?}", out.warnings); + let imported = crate::import(&out.bytes).unwrap(); + let (_, back) = texts(&imported.document).into_iter().find(|(_, t)| t.runs.iter().any(|r| r.text.contains("path"))).unwrap(); + assert!(!back.dynamic_text); + assert!((back.runs[0].style.size - drawn).abs() < 1e-3, "{} vs {drawn}", back.runs[0].style.size); + // Type written as outlines loses the setting too, and says so. + if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) { + t.runs[0].text.push('\u{2192}'); + } + let out = write(&doc); + assert!(out.warnings.iter().any(|w| w == TYPE) && out.warnings.iter().any(|w| w == DYNAMIC_TEXT), "{:?}", out.warnings); + let out = write_with(&doc, LegacyAiOptions { outline_text: true, ..LegacyAiOptions::default() }); + assert!(out.warnings.iter().any(|w| w == DYNAMIC_TEXT), "{:?}", out.warnings); +} + /// Characters with Auto leading are written with their paragraph's Auto Leading share of the /// size: 18 pt type in paragraphs at 150% is written with 27 pt leading. #[test] diff --git a/crates/eps/src/legacy_ai/writer.rs b/crates/eps/src/legacy_ai/writer.rs index dc5128565d..f71018996c 100644 --- a/crates/eps/src/legacy_ai/writer.rs +++ b/crates/eps/src/legacy_ai/writer.rs @@ -3,8 +3,8 @@ use super::encoding::{ unit, writable, }; use super::{ - Affine, AppearanceItem, Arc, BezPath, BlendMode, CharStyle, Cms, Color, Document, ELLIPTICAL, FREEFORM, FillLayer, FillRule, Gradient, - GradientKind, GradientPaint, HEX_LINE, HIDDEN, HIDDEN_LAYER, HIDDEN_SHOWN, HiddenObjects, IMAGE_ALPHA, ImageBlob, Justify, LARGE_IMAGE, + Affine, AppearanceItem, Arc, BezPath, BlendMode, CharStyle, Cms, Color, DYNAMIC_TEXT, Document, ELLIPTICAL, FREEFORM, FillLayer, FillRule, + Gradient, GradientKind, GradientPaint, HEX_LINE, HIDDEN, HIDDEN_LAYER, HIDDEN_SHOWN, HiddenObjects, IMAGE_ALPHA, ImageBlob, Justify, LARGE_IMAGE, LAYER_NAME, LIVE, LegacyAiOptions, MAX_IMAGE_PIXELS, MAX_NEST, NAMES, Node, NodeKind, PATTERNS, Paint, ParaDirection, ParaStyle, PathData, ROTATED_TYPE, Rect, STROKES, SUBLAYERS, Shape, StrokeLayer, SubPath, TITLE_AND_COLOR_NAMES, TRANSPARENCY, TYPE, TYPE_ATTRIBUTES, TextKind, TextObject, UNDECODED, Vec2, Write, ai_ops, latin, ps, stroke, @@ -666,6 +666,10 @@ impl<'a> Writer<'a> { } let resolved = vectorcraft_text::FontDb::global().font_heights_resolved(t); let t = &*resolved; + // Written as text or as outlines, the type keeps its drawn sizes but not the setting. + if t.dynamic_text && matches!(t.kind, TextKind::OnPath { .. }) { + self.warn(DYNAMIC_TEXT); + } if !self.outline_text { if writable(self.doc, t) { return self.text_object(t); diff --git a/crates/ui-egui/src/canvas/task_bar.rs b/crates/ui-egui/src/canvas/task_bar.rs index eb038f0790..4eb5e7dc01 100644 --- a/crates/ui-egui/src/canvas/task_bar.rs +++ b/crates/ui-egui/src/canvas/task_bar.rs @@ -153,8 +153,9 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { TextKind::Point => items.push((tl!("Convert To Area Type"), "dc-type-area", "type.convertToAreaType", none())), TextKind::OnPath { .. } => { items.push((tl!("Type on a Path Options"), "dc-type-path", "type.pathOptions", none())); - // Dynamic Text turns on or off, as the Type menu's item does; left out for type in a thread + // Dynamic Text turns on or off, as the Type menu's item does // (). + // Leaving it out for type in a thread is VectorCraft's own rule. if crate::menus::enabled(app, "type.dynamicText") { items.push((tl!("Dynamic Text"), "maximize-2", "type.dynamicText", none())); } diff --git a/docs/mcp.md b/docs/mcp.md index 5826c5c8c4..9a36ac4025 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -7317,12 +7317,12 @@ sizes do not change: turning Dynamic Text off writes the sizes the text was draw its look, in one undo step. Point type and area type in the selection are left as they are. Type on a path in a thread is refused with an error that names it, and the menu item and the task bar item are not offered for it; `text.thread.create` and `text.thread.add` turn Dynamic Text off on the frames they thread, at their drawn sizes, and -list them in `dynamicTextOff`. When every object already has the setting asked for, nothing changes and no undo step +list them in `dynamicTextOff`; `addon.type.releasePath` turns it off at the drawn sizes on the point type it makes. When every object already has the setting asked for, nothing changes and no undo step is recorded. It replies `{on, ids, fitScale}` (the first object's factor, 1 when off), and the menu item is checked while the first selected type on a path has it on. `document.inspect` reports `dynamicText` and `fitScale` for type -on a path. New type and documents from before it have it off, and it is saved only when on. SVG, PDF, EPS and the -editing data for other apps write the type at its drawn sizes; the editing data does not hold the setting and says -so in a warning. +on a path. New type and documents from before it have it off, and it is saved only when on. SVG, PDF, EPS, legacy +`.ai` and the editing data for other apps write the type at its drawn sizes; legacy `.ai` and the editing data do not +hold the setting, and each says so in a warning. ```json {"name":"run_command","arguments":{"command":"type.dynamicText","params":{"on":true}}} @@ -9009,7 +9009,9 @@ VectorCraft addition and changes no parity score. - **The text** keeps its runs with their character attributes and its paragraph attributes, and becomes point type on one straight line along the object's own axis, with the baseline starting where the first character sat on the path (type with no characters starts at its start bracket). Type on a Path Options (effect, align to path, spacing, flip, - brackets) don't apply to point type and are dropped. Without `keepOriginal` the type object itself becomes the point + brackets) don't apply to point type and are dropped. Type with Dynamic Text on (`type.dynamicText`) becomes point + type with it off, at the sizes it was drawn at: each size, leading and baseline shift is multiplied by the fit factor. + Without `keepOriginal` the type object itself becomes the point type, keeping its id, so a frame of threaded text leaves its thread with the text it holds, as `type.convertToPointType` does for area type. - It acts on the type on a path among `ids` (or `id`, else the selection), groups included; other objects are left diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b4c949f742..2da0c4a6a6 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1158,7 +1158,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur New Symbol and Redefine Symbol on a frame of threaded text take it out of the thread with the text it shows, which the symbol's art holds, and the other frames keep the text they hold. When the last frame goes into the symbol, the text it has no room for goes after the text of the last frame that stays. When every frame of a thread goes into the symbol, the copy of the last frame keeps all its text. With Optical Margin Alignment on, or Roman Hanging Punctuation on in the first paragraph, the glyphs at the ends of left-to-right type on a path hang outside its brackets, measured along the path. The type hangs at the start bracket when its line is placed there, hangs at the end bracket when the line is placed there, and hangs at both when it is centered. Glyphs that hang past the end bracket do not make the type overflow, and a hang stops at an open path's end. SVG export writes such type as outlines. Type on a Path Options › Align to Path (option names as in [Create type on a path](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-type-on-a-path.html)) moves each character by its own font, size and vertical scale: Ascender runs the top of the font's em box along the path (the ideographic em box that its `BASE` table, its typographic or `hhea` box or its ideographs give, else an em box centered on the cap height), Descender runs the bottom of the font's bounding box along it, and Center runs a line at half the height of the top of the font's H above the baseline along it. In vertical type, Ascender and Descender move the characters half an em, and Center leaves them on the path. These distances are VectorCraft's own values (`tests::on_path_align_runs_each_glyph_by_its_own_metrics`, `tests::on_path_align_finds_the_em_box_of_each_kind_of_face`, `tests::on_path_align_in_vertical_type_moves_half_an_em`). - Type › Dynamic Text (`type.dynamicText`, also on the Contextual Task Bar for type on a path) sizes type on a path to fill the span between its start and end brackets and fits it again after each edit to the text, the path or a bracket ([Dynamic Text overview](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/dynamic-text-overview.html), [start and end points](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/reposition-start-and-end-points.html)). VectorCraft's own rules: one factor scales every size, leading and baseline shift; the first character's size moves in 0.1 pt steps within 0.1 to 1,296 pt; turning it off or threading the type keeps the drawn sizes; SVG, EPS and the editing data write the drawn sizes (`crates/text/src/tests/fit.rs`, `crates/engine/src/tests/dynamic_text.rs`). + Type › Dynamic Text (`type.dynamicText`, also on the Contextual Task Bar for type on a path) sizes type on a path to fill the span between its start and end brackets and fits it again after each edit to the text or a bracket ([Dynamic Text overview](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/dynamic-text-overview.html), [start and end points](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/reposition-start-and-end-points.html)). VectorCraft's own rules: reshaping or scaling the path fits the text again; one factor scales every size, leading and baseline shift; the first character's size moves in 0.1 pt steps within 0.1 to 1,296 pt; turning it off, threading the type or Release Type on a Path keeps the drawn sizes; SVG, EPS, legacy `.ai` and the editing data write the drawn sizes (`crates/text/src/tests/fit.rs`, `crates/engine/src/tests/dynamic_text.rs`). The frames of threaded text share one Optical Margin Alignment setting: turning it on or off in one frame sets it in every frame, threading frames gives the thread its first frame's setting, and frames taken out of a thread keep it. The frames of threaded text share one writing direction: Type › Type Orientation (`type.orientation.vertical`, `type.orientation.horizontal`) in one frame sets every frame, threading frames gives the thread its first frame's direction, frames taken out of a thread keep theirs, and the story flows through every frame in the thread's direction. Type › Optical Margin Alignment (`type.opticalMarginAlignment`) hangs the punctuation and letter edges at the ends of a type object's lines outside its margins: at the left of left-aligned lines, at the right of right-aligned lines, and at both ends of centered and justified lines, in point and area type of left-to-right paragraphs. Quotation marks, hyphens, full stops and commas hang wholly; asterisks, tildes, ellipses, en and em dashes, colons and semicolons hang by half; and A, V, W, Y, v, w and y hang by a tenth of their width. It is off by default and saved with the type object. Justification's Glyph Scaling leaves the glyphs that hang at their width, so they hang exactly their share, and Standard burasagari counts what hangs at a line's start as room before hanging a comma; `crates/text/src/tests/hang.rs` checks whole paragraphs with both composers, ragged, centred and justified, with Line-end Punctuation Half Width and bidirectional lines. From 7844657096deb4a9f7b1c00f3feb4c112d3ec5ad Mon Sep 17 00:00:00 2001 From: Sven Lafebre Date: Wed, 11 Nov 2026 22:09:20 +0100 Subject: [PATCH 005/100] Fix #5111: keep ligatures whole and place line starts in SVG by value - drawn_alike ignores kerning_before, so a space before the "f" of "fi" no longer splits the ligature. - SVG export takes a line's starting space from kerningBefore and the em shaping sets the character in (vectorcraft_text::line_start_space), not from where the glyph is drawn, which can include a shaping offset; the reader layout for justified lines leaves the space out, so those lines start there too. - Type on a path writes the space as the first character's dx; a space after a line break, which can't write, is outlined. --- crates/svg/src/export/text.rs | 44 ++++++++++++++++++------- crates/svg/tests/it/text_layout.rs | 30 +++++++++++++++++ crates/text/src/lib.rs | 2 +- crates/text/src/shape.rs | 27 ++++++++++++--- crates/text/src/tests/kerning_before.rs | 15 +++++++++ docs/target-app-parity.md | 2 +- 6 files changed, 102 insertions(+), 18 deletions(-) diff --git a/crates/svg/src/export/text.rs b/crates/svg/src/export/text.rs index 5ff58a97bf..1e5fec21bd 100644 --- a/crates/svg/src/export/text.rs +++ b/crates/svg/src/export/text.rs @@ -258,6 +258,19 @@ impl Writer<'_> { self.warn("type on a path with glyphs hanging outside its brackets is outlined to keep its look"); return self.text_outlines(n, t); } + // The space before the first character is written as its `dx`; one before a character + // after a line break, which the `` writes as a space, keeps its look as outlines. + let plain = t.plain_text(); + let mut at = 0; + let spaced_break = t.runs.iter().any(|r| { + let start = at; + at += r.text.len(); + r.style.kerning_before != 0.0 && (start..at).any(|b| b > 0 && vectorcraft_text::line_start_space(&plain, b, &r.style, false) != 0.0) + }); + if spaced_break { + self.warn("type on a path with space before a character after a line break is outlined to keep its look"); + return self.text_outlines(n, t); + } // A `` holds the characters of the text, not the bullets or numbers drawn // before them. if !lay.markers.is_empty() { @@ -559,10 +572,17 @@ impl Writer<'_> { let a = self.attrs(&props); let offset = fmt_num(at * 100.0, 3); let mut s = format!(""); - for run in &drawn.runs { + let plain = drawn.plain_text(); + let first = drawn.runs.iter().position(|r| !r.text.is_empty()); + for (i, run) in drawn.runs.iter().enumerate() { let mut diff = run_diff(&base, self.char_props(&run.style, space)); diff.extend(direction_props(&run.style)); - let a = self.attrs(&diff); + let mut a = self.attrs(&diff); + // The space before the first character (`kerningBefore`) moves it along the path. + let before = if first == Some(i) { vectorcraft_text::line_start_space(&plain, 0, &run.style, false) } else { 0.0 }; + if before != 0.0 && before.is_finite() { + a.push_str(&format!(" dx=\"{}\"", fmt_num(before, self.opts.decimals))); + } s.push_str(&format!("{}", xml_text(&run.text.replace('\n', " ")))); } s.push_str(""); @@ -752,14 +772,11 @@ fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) - (advance, if (x - g.origin.x).abs() > PLACE_TOLERANCE { x } else { g.origin.x }) } // The space before a line's first character (`kerningBefore`) is in its advance and - // moves it along the line: a reader sets it from where it is drawn, without it. - None if !lay.vertical - && !g.rtl - && g.len > 0 - && run.style.kerning_before != 0.0 - && vectorcraft_text::edit::starts_line(&plain, g.byte) => - { - let before = Some(g.xf.translation().x - g.origin.x).filter(|b| b.is_finite()).unwrap_or(0.0); + // moves it along the line: a reader sets it that far along, without it. (Shrink + // Text to Fit scales it with the sizes.) + None if !lay.vertical && !g.rtl && g.len > 0 && run.style.kerning_before != 0.0 => { + let before = vectorcraft_text::line_start_space(&plain, g.byte, &run.style, false) * lay.fit_scale; + let before = if before.is_finite() { before } else { 0.0 }; (g.advance - before, g.origin.x + before) } None => (g.advance, g.origin.x), @@ -849,10 +866,13 @@ fn reader_advances(t: &TextObject, lay: &vectorcraft_text::TextLayout, kerning: // A reader spaces words and letters as the font does. p.justification = vectorcraft_doc::JustificationOptions::default(); } - if !kerning { - for r in &mut set.runs { + for r in &mut set.runs { + if !kerning { r.style.kerning.get_or_insert(0.0); } + // A reader puts no space before a line's first character (`kerningBefore`): the segment + // starts that much further along instead. + r.style.kerning_before = 0.0; } let relaid; let glyphs = if set == *t { diff --git a/crates/svg/tests/it/text_layout.rs b/crates/svg/tests/it/text_layout.rs index e8129a6922..e858f1ba08 100644 --- a/crates/svg/tests/it/text_layout.rs +++ b/crates/svg/tests/it/text_layout.rs @@ -131,6 +131,36 @@ fn the_space_before_a_lines_first_character_moves_its_tspan() { let svg = export(&area_doc(vec![run("H", before.clone()), run("OH", style(12.0))], 200.0, Justify::Center), &ExportOptions::default()); let x = tspans(&svg).iter().find_map(|t| attr(t, "x")).unwrap(); assert!((x - 101.5).abs() < 0.01, "{svg}"); + // A justified line, which a reader's own advances place word by word, starts there too. + let words = vec![run("H", before.clone()), run("OH OH OH OH OH OH OH OH OH OH", style(12.0))]; + let svg = export(&area_doc(words, 90.0, Justify::JustifyLeft), &ExportOptions::default()); + assert_eq!(attr(tspans(&svg)[0], "x"), Some(3.0), "{svg}"); +} + +/// On a path, the space before the first character is its `dx` along the path. One before a +/// character after a line break, which the `` can't write, is kept as outlines. +#[test] +fn the_space_before_the_first_character_on_a_path_is_its_dx() { + let doc = |runs: Vec| { + let mut t = TextObject::point(Point::ZERO, "", style(12.0)); + t.runs = runs; + let mut line = BezPath::new(); + line.move_to((20.0, 100.0)); + line.line_to((280.0, 100.0)); + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None }; + let mut d = Document::new(300.0, 200.0); + let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t))); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + vectorcraft_svg::export_full(&d, &ExportOptions::default(), None) + }; + let before = CharStyle { kerning_before: 250.0, ..style(12.0) }; + let out = doc(vec![run("HO", before.clone()), run("H", style(12.0))]); + let tags = tspans(&out.svg); + assert!(out.svg.contains(" f64 Some(st.kerning_before / 1000.0 * spacing_em).filter(|s| s.is_finite()).unwrap_or(0.0) } +/// The space before the character at `byte` of `text` in style `st`, in points, as the layout sets +/// it ([`CharStyle::kerning_before`]): its thousandths of the em the character is set in, when it +/// starts a line by itself, else 0. For writers that place a line's first character themselves. +pub fn line_start_space(text: &str, byte: usize, st: &CharStyle, vertical: bool) -> f64 { + let small = st.small_caps.is_some() && !st.all_caps && text.get(byte..).and_then(|s| s.chars().next()).is_some_and(char::is_lowercase); + space_before(text, byte, st, glyph_size(st, small) * spacing_scale(st, vertical)) +} + +/// The size characters in style `st` are shaped at: Superscript, Subscript and synthesized Small +/// Caps (`small`: a lowercase letter in them) shrink the glyphs; line metrics keep the full size. +fn glyph_size(st: &CharStyle, small: bool) -> f64 { + let full = st.size.max(0.0); + let (pos_scale, _) = st.position.scale_shift(full); + let small_scale = if small { st.small_caps.unwrap_or(100.0) / 100.0 } else { 1.0 }; + full * pos_scale * small_scale +} + /// Line segments of a glyph outline in font units, cached for the current shaping pass. type Profile = Vec<(f64, f64, f64, f64)>; type CachedProfile = (Profile, (f64, f64)); @@ -833,8 +850,11 @@ fn source_run_at(runs: &[(Range, &CharStyle)], span: &Range, byte: /// Can one glyph draw characters in styles `a` and `b`? Only when the styles are equal, except that /// horizontal type (`vertical` false) does not read Standard Vertical Roman Alignment or the -/// tate-chu-yoko offsets, so styles that differ only in those are drawn alike there. +/// tate-chu-yoko offsets, so styles that differ only in those are drawn alike there. The space +/// before a line's first character ([`space_before`]) is never read inside a cluster, which takes it +/// from its first character, so it doesn't split a ligature either. fn drawn_alike(a: &CharStyle, b: &CharStyle, vertical: bool) -> bool { + let b = &CharStyle { kerning_before: a.kerning_before, ..b.clone() }; if a == b { return true; } @@ -1099,9 +1119,8 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe let cursive = text_seg.chars().map(|c| c.script()).find(|s| !matches!(s, Script::Common | Script::Inherited)).is_some_and(joins); let full = st.size.max(0.0); // Superscript/subscript and small capitals shrink the glyphs; line metrics keep the full size. - let (pos_scale, pos_shift) = st.position.scale_shift(full); - let small_scale = if small { st.small_caps.unwrap_or(100.0) / 100.0 } else { 1.0 }; - let size = full * pos_scale * small_scale; + let (_, pos_shift) = st.position.scale_shift(full); + let size = glyph_size(st, small); let k = size / face.upem; let km = full / face.upem; let hs = st.h_scale / 100.0; diff --git a/crates/text/src/tests/kerning_before.rs b/crates/text/src/tests/kerning_before.rs index c33f3fd97a..f3d9e785a8 100644 --- a/crates/text/src/tests/kerning_before.rs +++ b/crates/text/src/tests/kerning_before.rs @@ -119,3 +119,18 @@ fn in_vertical_type_the_first_character_moves_down_its_column() { } } } + +#[test] +fn a_space_before_the_first_letter_of_a_ligature_keeps_it_whole() { + // Set on the "f" alone, as the caret before it sets it: Source Serif 4's "fi" ligature stays one + // glyph, and moves. + let serif = CharStyle { font_family: "Source Serif 4".into(), ..style(100.0) }; + let plain = TextObject::point(Point::ZERO, "fit", serif); + let glyphs = |t: &TextObject| layout(db(), t).glyphs.len(); + assert_eq!(glyphs(&plain), 2, "the font joins fi"); + let kerned = spaced(&plain, 0, 250.0); + assert_eq!(glyphs(&kerned), 2, "still joined"); + let moved = moves(&plain, &kerned, &[0, 2]); + assert!(near(&moved, &[25.0, 25.0]), "{moved:?}"); + assert!((line_start_space("fit", 0, &kerned.runs[0].style, false) - 25.0).abs() < 1e-9); +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7b92606edb..b98b9bb77e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1122,7 +1122,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Vertical type spacing shortcuts:** The [Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page gives Alt+Down/Up for kerning at a caret or tracking of selected text, Alt+Left/Right for leading, and Alt+Shift+Right/Left for baseline shift. Alt+Shift+Down/Up also steps kerning or tracking. Down increases kerning or tracking, Left increases leading, and Shift+Right increases baseline shift; the opposite arrows decrease them. Cmd/Ctrl makes five steps. Horizontal type keeps its existing mapping; mixed horizontal and vertical text-object selections take the horizontal mapping for both objects in one undo step. Selected vertical point, area and path type use the same directions. Tests: `spacing_arrows_follow_each_text_objects_orientation`, `alt_arrows_rotate_for_selected_vertical_type`, `mixed_type_objects_take_horizontal_spacing_chords_in_both_orders`, and `selected_vertical_area_and_path_type_use_vertical_spacing_chords`. - **Vertical punctuation:** fullwidth `!` (U+FF01) and `?` (U+FF1F) use their nominal glyph upright when no vertical alternate is present, following the Tu fallback in [Unicode UAX #50 §3.1](https://www.unicode.org/reports/tr50/#vo). Code-built test fonts cover point and area type with and without `vert` alternates, retain the rotated fallback for a bracket and long vowel mark, and leave horizontal type unchanged. Tests: `crates/text/src/tests/vertical.rs`. - Free Distort's dialog (Effect › Distort & Transform › Free Distort…) shows a box in the proportions of the first selected object's bounds with a handle on each corner. Dragging a handle moves that corner. A handle focused by a click or Tab moves 1% of the box with each arrow key, and 10% with Shift. Reset puts the four corners back on the box. The preview and OK run `effect.apply`, or `effect.setParams` when the dialog edits an applied effect, and the Appearance panel's inline editor opens the dialog with Edit…. Behavior reference: Illustrator CC Help (2014, [PDF](https://help.adobe.com/archive/en/illustrator/cc/2014/illustrator_reference.pdf)), page 285 (PDF page 291). -- **Kerning at the start of a line:** at the Type tool's caret before the first character of the text, of a paragraph, or after a forced line break, the Kerning field, Alt+←/→ (Alt+↓/↑ in vertical type) and Reset Tracking and Kerning set the space before the character after the caret (`kerningBefore`), as the reporter of #5111 describes the reference; the character moves along its line and the rest of the line follows, as one undo step (#5111). It used to do nothing there, or kern the invisible line break. Inside a line the caret still kerns the character before it. A line that wraps in area type takes no space before its first character, and Touch Type still moves a line's first character only up and down. SVG export starts the line where the character is drawn; vertical type moves the character down its column. Tests: `tests::caret_kerning` (engine), `tests::kerning_before` (text), `the_space_before_a_lines_first_character_moves_its_tspan` (SVG). +- **Kerning at the start of a line:** at the Type tool's caret before the first character of the text, of a paragraph, or after a forced line break, the Kerning field, Alt+←/→ (Alt+↓/↑ in vertical type) and Reset Tracking and Kerning set the space before the character after the caret (`kerningBefore`), as the reporter of #5111 describes the reference; the character moves along its line and the rest of the line follows, as one undo step (#5111). It used to do nothing there, or kern the invisible line break. Inside a line the caret still kerns the character before it. A line that wraps in area type takes no space before its first character, and Touch Type still moves a line's first character only up and down. SVG export starts the line where the character is drawn, and on a path writes the space as the first character's `dx`; vertical type moves the character down its column. A space on the first letter of a ligature keeps the ligature whole. Tests: `tests::caret_kerning` (engine), `tests::kerning_before` (text), `the_space_before_a_lines_first_character_moves_its_tspan` (SVG). - **Optical kerning:** Character and Properties offer Optical beside Auto for automatic pair spacing. The side-profile spacing method is VectorCraft's own: each pair moves part of the way toward the font's own stem spacing (the gap of `nn`, else `HH` or `oo`), keeps at least part of that gap where the outlines come closest, and changes by at most 0.12 em tighter or 0.06 em looser, so ordinary text keeps the font's spacing (#5336). Ideographs, kana, hangul, fullwidth and CJK punctuation forms and dashes keep the font's spacing. Optical keeps its mode across cosmetic style runs. Choosing Optical clears manual pair values, as Auto does, and leaves additive tracking intact; a manual value typed afterwards replaces that pair's correction ([Adjust kerning and tracking](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/adjust-kerning-and-tracking.html)). Native documents retain the mode and pair overrides; old documents use Auto. Behavior reference: [Line and character spacing](https://web.archive.org/web/20260916050333/https://helpx.adobe.com/illustrator/using/line-character-spacing.html) (public documentation). Tests: `crates/text/src/tests/typo.rs`, `crates/engine/src/tests/textedit.rs`. - **Kerning across style runs:** characters that differ only in fill, stroke, underline, strikethrough, overprint, character style name, leading, baseline shift, rotation, Character Alignment, kerning, or tracking that keeps or drops ligatures alike are shaped together, so restyling one character keeps the font's kerning pairs with its neighbors. A character with manual kerning that follows one with automatic kerning keeps the font's kerning with it (unless All Caps or Small Caps sets it as two letters, as ß becomes SS), and its value replaces the font's kerning after it. A ligature does not form across a change of style, and a change of font, size or OpenType features still shapes the characters apart. Behavior reference: [Line and character spacing](https://web.archive.org/web/20260916050333/https://helpx.adobe.com/illustrator/using/line-character-spacing.html) (public documentation). Tests: `crates/text/src/tests/typo.rs`, `crates/text/src/tests/bidi.rs`. - **Manual kerning in right-to-left text:** a manual kerning value on a Hebrew or Arabic character changes the space between it and the next character in reading order, the one drawn to its left, under Auto and Optical kerning. The character before it keeps its place, and the caret between the pair stays beside the next character. The side the space goes on is VectorCraft's own rule. Behavior reference: [Line and character spacing](https://web.archive.org/web/20260916050333/https://helpx.adobe.com/illustrator/using/line-character-spacing.html). Test: `rtl_manual_kerning_spaces_the_character_from_the_next_in_reading_order`. From 133e2c135695b54396aa102acfe70f7a0c8c22ff Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 13:17:08 -0800 Subject: [PATCH 006/100] MCP: livePaint.info reads id when group is left out The docs said every livePaint key left out is reported as missing. livePaint.info reads `id` when `group` is left out or null, and reports a missing group only when both are left out. --- docs/mcp.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/mcp.md b/docs/mcp.md index 892541c6a8..6287f5b3a7 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -436,7 +436,8 @@ change nothing; an omitted or null `id` keeps the default. Commands that need an `path` keys of `appearance.transfer`, `appearance.copyFrom`, `layer.move`, the `livePaint.*` commands and `text.createInPath`) refuse a value that is not a non-negative integer with "`id` must be a whole number", naming the key, and report a key left out or null as missing. The optional `group` of `livePaint.fill` and `parent` of -`node.move` are refused the same way when given; left out, they keep their defaults. A command that leaves out an object selected inside a +`node.move` are refused the same way when given; left out, they keep their defaults. `livePaint.info` reads `id` when +`group` is left out or null, and reports a missing group only when both are left out. A command that leaves out an object selected inside a selected group also leaves it out when `ids` list it together with a group that holds it. Every command that takes `ids` (or `slices`, for the slice commands) refuses a list with an entry that is not a non-negative integer, or a value that is not a list, and changes nothing; it does not act on the other entries. The error for an entry names the key, From bd9fef69b8bd83226a85e07f5ee76124a22bd615 Mon Sep 17 00:00:00 2001 From: Sven Lafebre Date: Wed, 11 Nov 2026 22:24:49 +0100 Subject: [PATCH 007/100] Fix #5111: keep coloured harakat attached when the first letter is spaced paint_only ignores kerning_before, as drawn_alike does: a space before a line's first letter, set on the letter alone, no longer splits it from its harakat coloured apart. --- crates/text/src/harakat.rs | 6 +++--- crates/text/src/tests/harakat.rs | 22 ++++++++++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/crates/text/src/harakat.rs b/crates/text/src/harakat.rs index b781bd8b63..435bbc4ed7 100644 --- a/crates/text/src/harakat.rs +++ b/crates/text/src/harakat.rs @@ -38,7 +38,8 @@ pub(crate) fn is_harakat(c: char) -> bool { /// Do character styles `a` and `b` differ, if at all, only in paint: fill, stroke and how the /// stroke is drawn, overprint, and the character style name? Those are not read by the shaper -/// and change no glyph, advance or position. Stricter than [`crate::shape`]'s `shapes_alike` +/// and change no glyph, advance or position. Nor is the space before a line's first character +/// inside a cluster, which takes it from its first character (#5111), so it is ignored too. Stricter than [`crate::shape`]'s `shapes_alike` /// (which also ignores baseline shift, tracking, underline, …). pub(crate) fn paint_only(a: &CharStyle, b: &CharStyle) -> bool { // Exhaustive: a new attribute must explicitly keep or relax this boundary rule. @@ -51,7 +52,7 @@ pub(crate) fn paint_only(a: &CharStyle, b: &CharStyle) -> bool { leading, tracking, kerning, - kerning_before, + kerning_before: _, optical_kerning, baseline_shift, h_scale, @@ -101,7 +102,6 @@ pub(crate) fn paint_only(a: &CharStyle, b: &CharStyle) -> bool { && leading == &b.leading && tracking == &b.tracking && kerning == &b.kerning - && kerning_before == &b.kerning_before && optical_kerning == &b.optical_kerning && baseline_shift == &b.baseline_shift && h_scale == &b.h_scale diff --git a/crates/text/src/tests/harakat.rs b/crates/text/src/tests/harakat.rs index e590f103c7..0aa8bf82aa 100644 --- a/crates/text/src/tests/harakat.rs +++ b/crates/text/src/tests/harakat.rs @@ -171,6 +171,7 @@ fn paint_only_keeps_every_nonpaint_attribute_and_partial_equality() { b.stroke_dash = a.stroke_dash.clone(); b.stroke_align = a.stroke_align; b.style_name = a.style_name.clone(); + b.kerning_before = a.kerning_before; *a == b } @@ -203,6 +204,7 @@ fn paint_only_keeps_every_nonpaint_attribute_and_partial_equality() { field!(leading, Some(60.0), false); field!(tracking, 20.0, false); field!(kerning, Some(0.0), false); + field!(kerning_before, 250.0, true); field!(optical_kerning, true, false); field!(baseline_shift, 1.0, false); field!(h_scale, 90.0, false); @@ -312,6 +314,26 @@ fn harakat_ranges_normalise_their_range() { /// the same way, and only a character in that glyph's shaping cluster: repeated glyph ids alone /// don't do. A later in-cluster owner is kept only for a GDEF mark glyph on a harakat character; /// everything else stays at the cluster start (conservative). +/// A space before the line's first letter (#5111), set on the letter alone as the Type tool's caret +/// sets it, keeps the letter's harakat coloured apart attached and in their own colour. Right to +/// left the space is past the letter's right side, so every glyph stays where it was. +#[test] +fn a_space_before_the_first_letter_keeps_its_coloured_harakat() { + let Some(face) = arabic_face("a_space_before_the_first_letter_keeps_its_coloured_harakat") else { + return; + }; + const WORD: &str = "بَبَب"; // ب 0, fatha 2, ب 4, fatha 6, ب 8 + let db = FontDb::global(); + let coloured = text_with_harakat(WORD, &face.family, Some(red())); + let mut spaced = coloured.clone(); + crate::edit::style_range(&mut spaced.runs, 0, 2, |st| st.kerning_before = 250.0); + let (a, b) = (layout(db, &coloured), layout(db, &spaced)); + let placed = |l: &TextLayout| l.glyphs.iter().map(|g| (g.font_id, g.gid, g.byte, g.xf.as_coeffs().map(f64::to_bits))).collect::>(); + assert_eq!(placed(&a), placed(&b), "the same glyphs where they were"); + assert_eq!(painted(&spaced, &b, &red()), painted(&coloured, &a, &red()), "the harakat keep their colour"); + assert!(painted(&spaced, &b, &red()) > 0, "harakat painted apart"); +} + #[test] fn own_chars_need_the_same_setting_and_each_character_in_its_cluster() { // بَبَب right to left: final beh (cluster 8), fatha and medial beh (cluster 4), fatha and From d9f961b0c15a3e5955a9de213bfffe5ef7873f2f Mon Sep 17 00:00:00 2001 From: Sven Lafebre Date: Wed, 11 Nov 2026 22:38:30 +0100 Subject: [PATCH 008/100] Fix #5111: check spaced harakat with the in-memory Arabic font The harakat test now runs on the in-memory Arabic test font in every environment, as well as on an installed Arabic face, and checks that the line is the space longer. --- crates/text/src/tests/harakat.rs | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/crates/text/src/tests/harakat.rs b/crates/text/src/tests/harakat.rs index 0aa8bf82aa..9bb7700d66 100644 --- a/crates/text/src/tests/harakat.rs +++ b/crates/text/src/tests/harakat.rs @@ -316,22 +316,32 @@ fn harakat_ranges_normalise_their_range() { /// everything else stays at the cluster start (conservative). /// A space before the line's first letter (#5111), set on the letter alone as the Type tool's caret /// sets it, keeps the letter's harakat coloured apart attached and in their own colour. Right to -/// left the space is past the letter's right side, so every glyph stays where it was. +/// left the space is past the letter's right side, so every glyph stays where it was and the line +/// is the space longer. Checked with the in-memory Arabic test font (its fatha a `GDEF` mark) in +/// every environment, and with an installed Arabic face when there is one. #[test] fn a_space_before_the_first_letter_keeps_its_coloured_harakat() { - let Some(face) = arabic_face("a_space_before_the_first_letter_keeps_its_coloured_harakat") else { - return; - }; + let db = FontDb::with_font_dirs(vec![]); + assert_eq!(db.add_font(crate::test_fonts::lam_alef_font(b"rlig").unwrap()), 1); + check_spaced_harakat(&db, crate::test_fonts::lam_alef_family(b"rlig")); + if let Some(face) = arabic_face("a_space_before_the_first_letter_keeps_its_coloured_harakat") { + check_spaced_harakat(FontDb::global(), &face.family); + } +} + +fn check_spaced_harakat(db: &FontDb, family: &str) { const WORD: &str = "بَبَب"; // ب 0, fatha 2, ب 4, fatha 6, ب 8 - let db = FontDb::global(); - let coloured = text_with_harakat(WORD, &face.family, Some(red())); + let coloured = text_with_harakat(WORD, family, Some(red())); let mut spaced = coloured.clone(); crate::edit::style_range(&mut spaced.runs, 0, 2, |st| st.kerning_before = 250.0); let (a, b) = (layout(db, &coloured), layout(db, &spaced)); let placed = |l: &TextLayout| l.glyphs.iter().map(|g| (g.font_id, g.gid, g.byte, g.xf.as_coeffs().map(f64::to_bits))).collect::>(); - assert_eq!(placed(&a), placed(&b), "the same glyphs where they were"); - assert_eq!(painted(&spaced, &b, &red()), painted(&coloured, &a, &red()), "the harakat keep their colour"); - assert!(painted(&spaced, &b, &red()) > 0, "harakat painted apart"); + assert_eq!(placed(&a), placed(&b), "{family}: the same glyphs where they were"); + assert!(painted(&spaced, &b, &red()) > 0, "{family}: harakat painted apart"); + assert_eq!(painted(&spaced, &b, &red()), painted(&coloured, &a, &red()), "{family}: the harakat keep their colour"); + // 250/1000 of the 48 pt em. + let length = |l: &TextLayout| l.glyphs.iter().map(|g| g.advance).sum::(); + assert!((length(&b) - length(&a) - 12.0).abs() < 1e-6, "{family}: {} → {}", length(&a), length(&b)); } #[test] From 39683ecdd90db1d3286f7a5d617c888ebc64be66 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 13:55:47 -0800 Subject: [PATCH 009/100] PDF/.ai export: fit Dynamic Text before resolving font heights The legacy .ai writer resolved cap-height and other font height references to em sizes before fitting, so Dynamic Text stepped and limited the em size instead of the size the Character panel shows, and the type could be written smaller than it is drawn. It now fits first, and outlines come from the type's own layout. The editing data now also warns that Dynamic Text is not written when the type is written as outlines. --- crates/eps/src/ai_editing/tests.rs | 13 ++++++++++ crates/eps/src/ai_editing/writer.rs | 4 +++ crates/eps/src/legacy_ai/tests.rs | 38 +++++++++++++++++++++++++++++ crates/eps/src/legacy_ai/writer.rs | 16 ++++++------ 4 files changed, 63 insertions(+), 8 deletions(-) diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index 67ca443a7b..2af2ecd394 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -4366,6 +4366,19 @@ fn dynamic_text_is_written_at_its_drawn_sizes() { assert!(!texts[0].dynamic_text); let k = texts[0].xf.as_coeffs()[0]; assert_eq!(r3(texts[0].runs[0].style.size * k), r3(drawn)); + // Type written as outlines (here on an open path whose ends meet) loses the setting too, and + // says so. + let mut doc = Document::new(600.0, 300.0); + let layer = doc.layers[0].id; + let mut t = TextObject::point(vectorcraft_geom::Point::ZERO, "Grow", style(12.0, Color::BLACK)); + let ends_meet = [(50.0, 200.0).into(), (550.0, 200.0).into(), (300.0, 50.0).into(), (50.0, 200.0).into()]; + t.kind = TextKind::OnPath { path: PathData::new(vec![vectorcraft_geom::SubPath::polyline(&ends_meet, false)]), start: 0.0, end: None }; + t.xf = Affine::IDENTITY; + t.dynamic_text = true; + let node = text_node(&mut doc, t); + doc.insert(Some(layer), usize::MAX, node).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::TYPE) && out.warnings.iter().any(|w| w == writer::DYNAMIC_TEXT), "{:?}", out.warnings); } /// See-through type with the knockout shape flag comes back with the flag and writes no warning. diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index afa6412561..dea4ad0c24 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -1257,6 +1257,10 @@ impl<'a> Writer<'a> { return; } self.warn(TYPE); + // The outlines keep the sizes Dynamic Text draws the type at, but not the setting. + if matches!(&n.kind, NodeKind::Text(t) if t.dynamic_text) { + self.warn(DYNAMIC_TEXT); + } let doc = self.doc; let resolved = match &n.kind { NodeKind::Text(t) => Some(doc.inline_resolved(t).into_owned()), diff --git a/crates/eps/src/legacy_ai/tests.rs b/crates/eps/src/legacy_ai/tests.rs index 988cd9a1f0..14059d33b8 100644 --- a/crates/eps/src/legacy_ai/tests.rs +++ b/crates/eps/src/legacy_ai/tests.rs @@ -1263,6 +1263,44 @@ fn dynamic_text_is_written_at_its_drawn_sizes_with_a_warning() { assert!(out.warnings.iter().any(|w| w == DYNAMIC_TEXT), "{:?}", out.warnings); } +/// Dynamic Text fits type whose size measures the cap height at the size the Character panel +/// shows: 1,000 pt measuring a 0.7 em cap height on a long path grows to the 1,296 pt limit and is +/// written at the em size drawn there, as text and as outlines. +#[test] +fn dynamic_text_with_a_font_height_reference_is_written_at_its_drawn_size() { + use vectorcraft_doc::{TextKind, TextObject}; + use vectorcraft_text::test_fonts::{HEIGHT_FAMILY, height_font}; + let db = vectorcraft_text::FontDb::global(); + db.add_font(height_font(true).unwrap()); + let mut t = TextObject::point(vectorcraft_geom::Point::ZERO, "HE", style(1000.0, red())); + let st = &mut t.runs[0].style; + st.font_family = HEIGHT_FAMILY.into(); + st.font_height_reference = vectorcraft_doc::FontHeightReference::CapHeight; + let mut line = PathData::new(vec![]); + line.subpaths.push(vectorcraft_geom::SubPath::polyline(&[(0.0, 2000.0).into(), (100_000.0, 2000.0).into()], false)); + t.kind = TextKind::OnPath { path: line, start: 0.0, end: None }; + t.xf = Affine::IDENTITY; + t.dynamic_text = true; + let lay = vectorcraft_text::layout(db, &t); + assert!((lay.fit_scale - 1.296).abs() < 1e-9, "{}", lay.fit_scale); + let em = 1296.0 / 0.7; + let mut glyphs = kurbo::BezPath::new(); + for g in &lay.glyphs { + glyphs.extend(g.outline.iter()); + } + let want = glyphs.bounding_box(); + let mut doc = Document::new(300.0, 200.0); + let layer = doc.layers[0].id; + let n = Node::new(doc.alloc_id(), NodeKind::Text(Box::new(t))); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + let back = crate::import(&write(&doc).bytes).unwrap().document; + let size = texts(&back)[0].1.runs[0].style.size; + assert!((size - em).abs() < 1e-3, "{size} vs {em}"); + let back = crate::import(&write_with(&doc, LegacyAiOptions { outline_text: true, ..LegacyAiOptions::default() }).bytes).unwrap().document; + let b = children(&children(&back.layers[0])[0])[0].visual_bounds().unwrap(); + assert!((b.height() - want.height()).abs() < 0.05 && (b.width() - want.width()).abs() < 0.05, "{b:?}, laid out at {want:?}"); +} + /// Characters with Auto leading are written with their paragraph's Auto Leading share of the /// size: 18 pt type in paragraphs at 150% is written with 27 pt leading. #[test] diff --git a/crates/eps/src/legacy_ai/writer.rs b/crates/eps/src/legacy_ai/writer.rs index f71018996c..062ea3e9d8 100644 --- a/crates/eps/src/legacy_ai/writer.rs +++ b/crates/eps/src/legacy_ai/writer.rs @@ -657,15 +657,11 @@ impl<'a> Writer<'a> { // ---------- type ---------- /// Type: a text object when the format can hold it as text, else its outlines (type with - /// rotated characters among them, which keeps their rotation). A run whose - /// font size measures a height other than the em box is written at the em size it is drawn - /// at ([`vectorcraft_text::FontDb::font_heights_resolved`]). + /// rotated characters among them, which keeps their rotation). fn text(&mut self, t: &TextObject) { if !invertible(t.xf) { return; } - let resolved = vectorcraft_text::FontDb::global().font_heights_resolved(t); - let t = &*resolved; // Written as text or as outlines, the type keeps its drawn sizes but not the setting. if t.dynamic_text && matches!(t.kind, TextKind::OnPath { .. }) { self.warn(DYNAMIC_TEXT); @@ -687,14 +683,18 @@ impl<'a> Writer<'a> { /// A text object (§15): `type To`, the text path (`a b c d tx ty startPt Tp`, an area's frame /// or the path type runs along, `TP`), then each run's style and characters, `TO`. The matrix /// takes text space (y up, the first baseline's start for point type) to art space; the frame - /// and path are in art space, as the specification's examples have them (§15.5). + /// and path are in art space, as the specification's examples have them (§15.5). Type that + /// Shrink Text to Fit or Dynamic Text scales is written in the styles it draws in, fitted at + /// the sizes the Character panel shows; then a run whose font size measures a height other + /// than the em box is written at the em size it is drawn at + /// ([`vectorcraft_text::FontDb::font_heights_resolved`]). fn text_object(&mut self, t: &TextObject) { if lost_attributes(t) { self.warn(TYPE_ATTRIBUTES); } - // Type that Shrink Text to Fit or Dynamic Text scales is written in the styles it draws in. let fitted = vectorcraft_text::drawn(t); - let t = &*fitted; + let resolved = vectorcraft_text::FontDb::global().font_heights_resolved(&fitted); + let t = &*resolved; let m = self.to_art * t.xf * Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0]); let (kind, path, start) = match &t.kind { TextKind::Point => (0, None, 0.0), From 6a7214df4c9695c6105fb81450454132853b12a6 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 13:39:28 -0800 Subject: [PATCH 010/100] MCP: replies hide a library path outside the read roots wherever it appears Under the read roots, every control reply now shows a hidden library's id as loaded/ and its path as the file name in every string and key: status text, dialogs, the command palette query, a script's report, recorded steps and errors. The id and path are replaced at any boundary, so a path between spaces, brackets or quotes, followed by a period or by more path text (.bak), or quoted with escaped backslashes is hidden too. An occurrence that continues into a longer path stays only when the request may read that longer path, so a readable library's id that begins with a hidden one keeps its text. The libraries hidden after the request ran are hidden, and for a request that names one, those hidden before it too. A reply with no path separator in it is not searched. Encoded file data (dataBase64, pngBase64) is sent unchanged. Recorded steps in ui.inspect leave out a path outside the read roots under path, paths, file, files, folder and any param whose name ends in Path, Paths, File, Files or Folder (diffPath, fontFiles, destinationFolder), and an absolute path or file: URL outside the roots under any other param or in any list. The file: scheme is matched in any case, a URL's %XX escapes are decoded before the check, a file: URL inside the roots stays, and a file: URL that names another host is always left out. file_access::is_path_key holds the list of path params, and testkit's is_path_key uses it. --- crates/engine/src/file_access.rs | 7 + crates/testkit/src/strategies.rs | 12 +- crates/ui-egui/src/control.rs | 279 ++++++++++++++++++++----- crates/ui-egui/src/control/tests.rs | 57 +++++ crates/ui-egui/src/tests/automation.rs | 113 ++++++++-- docs/mcp.md | 16 +- 6 files changed, 411 insertions(+), 73 deletions(-) diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/file_access.rs index 27ebc9c40d..fc071ebb98 100644 --- a/crates/engine/src/file_access.rs +++ b/crates/engine/src/file_access.rs @@ -215,6 +215,13 @@ pub fn check_read(path: &str) -> Result<(), String> { current().map_or(Ok(()), |r| r.check(path, Access::Read)) } +/// Does a command param named `key` hold a file or folder path: `path`, `paths`, `file`, `files`, +/// `folder`, or a name ending in `Path`, `Paths`, `File`, `Files` or `Folder` (`diffPath`, +/// `destinationFolder`)? +pub fn is_path_key(key: &str) -> bool { + matches!(key, "path" | "paths" | "file" | "files" | "folder") || ["Path", "Paths", "File", "Files", "Folder"].iter().any(|end| key.ends_with(end)) +} + /// May `path` be written here? Always, without roots in force. pub fn check_write(path: &str) -> Result<(), String> { current().map_or(Ok(()), |r| r.check(path, Access::Write))?; diff --git a/crates/testkit/src/strategies.rs b/crates/testkit/src/strategies.rs index 10b0608001..372152870d 100644 --- a/crates/testkit/src/strategies.rs +++ b/crates/testkit/src/strategies.rs @@ -502,15 +502,17 @@ pub fn param_keys(doc: &str) -> Vec { out } -/// Parameter-name keys that name a file or folder a command may write: fuzzing these with -/// arbitrary strings could write files anywhere, so callers substitute a temp path. +/// Parameter-name keys that name a file or folder a command may write +/// ([`vectorcraft_engine::file_access::is_path_key`]): fuzzing these with arbitrary strings could +/// write files anywhere, so callers substitute a temp path. pub fn is_path_key(k: &str) -> bool { - matches!(k, "path" | "diffPath") || is_folder_key(k) + vectorcraft_engine::file_access::is_path_key(k) } -/// Path keys that name a folder a command creates or writes files into. +/// Path keys that name a folder a command creates or writes files into: `folder` or a name ending in +/// `Folder` (`destinationFolder`). pub fn is_folder_key(k: &str) -> bool { - matches!(k, "folder" | "destinationFolder") + k == "folder" || k.ends_with("Folder") } /// The location to send under the path key `k`: `safe_path` for a file, a folder next to it for diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 3cbe9f920c..c06951cee5 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -308,23 +308,10 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value { // for the person at the computer: what asked for it runs again when it answers. "fileDialog": app.picks.waiting_kind(), }); - let ids = unreadable_library_ids(app); - for field in PANEL_FIELDS.iter().chain(&LIBRARY_ID_FIELDS) { - if let Some(v) = inspected["ui"].get_mut(*field) { - hide_library_ids(v, &ids); - } - } - let pairs: Vec<_> = ids.iter().map(|(id, shown)| (id.as_str(), shown.as_str())).collect(); - if let Some(sets) = inspected["ui"].get_mut("action_sets") { - swap_library_params(sets, &pairs); - } + hide_library_ids(&mut inspected, &unreadable_library_ids(app)); inspected } -/// The fields of the UI state, by their keys in `ui.inspect`'s `ui`, that hold library ids or panel -/// ids besides [`PANEL_FIELDS`]: the library panels, the Color Guide's limit and the workspaces. -const LIBRARY_ID_FIELDS: [&str; 4] = ["library_panels", "color_guide_limit", "custom_workspaces", "workspace_changes"]; - /// The fields of the UI state, by their keys in `ui.inspect`'s `ui`, that hold panel ids: the panel /// popped out of the collapsed column, the docking layout, closed panels' places and groups, the panels /// shown as icons or collapsed, and the older floating panels' record. @@ -336,9 +323,9 @@ const PANEL_FIELDS: [&str; 7] = /// loaded without a path is named. The UI state refers to a library by its bare id in a library panel /// and the Color Guide's limit, by a panel id (`library::loaded/`) in the fields of /// [`PANEL_FIELDS`] and the workspaces' layouts, and by either in the `library` and `panel` params of -/// recorded actions' steps. Text the person types (a command palette query, a workspace's or an -/// action's name) is not read. When another of those ids, or a library loaded in the session, already -/// has that id, the next free one +/// recorded actions' steps; the session lists the libraries it has loaded. Text the person types (a +/// command palette query, a workspace's or an action's name) is not read. When another of those ids, +/// or a library loaded in the session, already has that id, the next free one /// of `loaded/ (2)`, `(3)` and so on is shown, in the sorted order of the full ids, so that /// two libraries stay two and `ui.inspect` and `ui.docking` show a library under the same id. fn unreadable_library_ids(app: &VectorcraftApp) -> Vec<(String, String)> { @@ -354,15 +341,16 @@ fn unreadable_library_ids(app: &VectorcraftApp) -> Vec<(String, String)> { } } collect_library_panel_ids(&json!(app.ui.transient_panel), &mut ids); - for id in app.ui.library_panels.iter().map(|p| p.id.as_str()).chain([app.ui.color_guide_limit.as_str()]) { + // The ids of the libraries loaded in the session: a hidden one does not take the id of one loaded + // without a path. + let s = &app.session; + let loaded = s.swatch_libraries.infos().chain(s.style_libraries.infos()).chain(s.symbol_libraries.infos()).chain(s.brush_libraries.infos()); + let loaded: Vec<&str> = loaded.map(|info| info.id.as_str()).collect(); + for id in app.ui.library_panels.iter().map(|p| p.id.as_str()).chain([app.ui.color_guide_limit.as_str()]).chain(loaded.iter().copied()) { add_library_id(id, &mut ids); } let mut hidden = Vec::new(); - // The ids of the libraries loaded in the session, so that a hidden one does not take the id of - // one loaded without a path. - let s = &app.session; - let loaded = s.swatch_libraries.infos().chain(s.style_libraries.infos()).chain(s.symbol_libraries.infos()).chain(s.brush_libraries.infos()); - let mut taken: std::collections::BTreeSet = loaded.map(|info| info.id.clone()).collect(); + let mut taken: std::collections::BTreeSet = loaded.iter().map(|id| id.to_string()).collect(); for id in ids { let path = &id["loaded/".len()..]; // Either separator: a path saved on another platform counts as a path here too. @@ -419,11 +407,106 @@ fn collect_library_panel_ids(v: &Value, ids: &mut std::collections::BTreeSet`) and in keys. +/// Writes each id of `ids` as the id the reply shows instead, and the path in it as the file name +/// shown, wherever `v` holds them, in every string and key: a library's id, its panel's id +/// (`library:swatches:loaded/`), status text, a dialog, the command palette query, a script's +/// report and a recorded step. An occurrence followed by a character that continues a path +/// ([`continues_path`]) stays only when the request may read the longer path +/// ([`readable_continuation`]): a readable library's id that begins with a hidden one keeps its path. fn hide_library_ids(v: &mut Value, ids: &[(String, String)]) { - let pairs: Vec<_> = ids.iter().map(|(id, shown)| (id.as_str(), shown.as_str())).collect(); - swap_library_ids(v, &pairs); + replace_in_value(v, &hidden_text(ids)); +} + +/// The text [`hide_library_ids`] replaces, longest first: each hidden id with the id shown, and the +/// path in it with the file name shown, the path written with either separator too, and with each +/// `\` doubled (a path quoted with `{:?}`). +fn hidden_text(ids: &[(String, String)]) -> Vec<(String, String)> { + let mut pairs: Vec<(String, String)> = Vec::new(); + for (id, shown) in ids { + let path = &id["loaded/".len()..]; + let name = shown.strip_prefix("loaded/").unwrap_or(shown); + let back = path.replace('/', "\\"); + for path in [path.to_string(), path.replace('\\', "/"), back.replace('\\', "\\\\"), back] { + for (from, to) in [(format!("loaded/{path}"), shown.clone()), (path, name.to_string())] { + if !pairs.iter().any(|(f, _)| *f == from) { + pairs.push((from, to)); + } + } + } + } + pairs.sort_by_key(|(from, _)| std::cmp::Reverse(from.len())); + pairs +} + +/// Writes each `from` text of `pairs` as its `to` text in every string and key of `v`, at each +/// occurrence not followed by a character that continues a path ([`continues_path`]) into a path the +/// request may read ([`readable_continuation`]). Encoded file data, under a key ending in `Base64` +/// (`dataBase64`, `pngBase64`), stays as it is. +fn replace_in_value(v: &mut Value, pairs: &[(String, String)]) { + if pairs.is_empty() { + return; + } + match v { + Value::String(s) => { + if let Some(replaced) = replace_text(s, pairs) { + *s = replaced; + } + } + Value::Array(items) => items.iter_mut().for_each(|item| replace_in_value(item, pairs)), + Value::Object(fields) => { + for (key, mut item) in std::mem::take(fields) { + if key.ends_with("Base64") { + fields.insert(key, item); + continue; + } + replace_in_value(&mut item, pairs); + fields.insert(replace_text(&key, pairs).unwrap_or(key), item); + } + } + _ => {} + } +} + +/// `s` with the `from` texts of `pairs` written as their `to` texts, as [`replace_in_value`] writes +/// them. `None` when nothing changed. +fn replace_text(s: &str, pairs: &[(String, String)]) -> Option { + let mut out: Option = None; + for (from, to) in pairs { + let text = out.as_deref().unwrap_or(s); + if from.is_empty() || !text.contains(from.as_str()) { + continue; + } + let mut next = String::with_capacity(text.len()); + let mut copied = 0; + for (at, _) in text.match_indices(from.as_str()) { + let end = at + from.len(); + if !continues_path(&text[end..]) || !readable_continuation(&text[at..]) { + next.push_str(&text[copied..at]); + next.push_str(to); + copied = end; + } + } + if copied > 0 { + next.push_str(&text[copied..]); + out = Some(next); + } + } + out +} + +/// Does text that follows a path continue it: a letter, a digit, `_` or a separator, after any `.`, +/// `-` or `~`? A space, punctuation, a quote, a sentence's final period and the end of the text end it. +fn continues_path(rest: &str) -> bool { + rest.trim_start_matches(['.', '-', '~']).starts_with(|c: char| c.is_alphanumeric() || matches!(c, '_' | '/' | '\\')) +} + +/// May the request read the longer path that `text` starts with, up to a space, a quote, a bracket, +/// `,`, `;` or `|`, without a `loaded/` before it? A readable library's id that begins with a hidden +/// one stays; a hidden path followed by more path text (`.bak`) is still hidden. +fn readable_continuation(text: &str) -> bool { + let end = text.find(|c: char| c.is_whitespace() || "\"'`()[]{}<>,;|".contains(c)).unwrap_or(text.len()); + let path = &text[..end]; + readable(path.strip_prefix("loaded/").unwrap_or(path)) } /// Text that a person or a script wrote, by its key: a command palette query, a script's report and a @@ -510,10 +593,69 @@ fn readable(path: &str) -> bool { vectorcraft_engine::file_access::check_read(path).is_ok() } -/// Does a recorded step's param `key` hold a file or folder path: `path`, `folder` or a name ending in -/// `Folder` (`destinationFolder`, `searchFolder`)? -fn is_path_param(key: &str) -> bool { - matches!(key, "path" | "folder") || key.ends_with("Folder") +/// Is `s` a path the request may not read, written as an absolute path (either separator, with or +/// without a drive) or a `file:` URL ([`file_url_path`])? A `file:` URL that names another host is +/// never readable. +fn unreadable_absolute(s: &str) -> bool { + match file_url_path(s) { + Some(path) => !path.is_some_and(|p| readable(&p)), + None => { + let absolute = s.starts_with(['/', '\\']) || matches!(s.as_bytes(), [drive, b':', b'/' | b'\\', ..] if drive.is_ascii_alphabetic()); + absolute && !readable(s) + } + } +} + +/// May the request read the path `s` names, a path or a `file:` URL ([`file_url_path`])? +fn readable_value(s: &str) -> bool { + match file_url_path(s) { + Some(path) => path.is_some_and(|p| readable(&p)), + None => readable(s), + } +} + +/// The local path that `s` names when it is a `file:` URL, with the scheme in any case and its `%XX` +/// escapes decoded: `Some(None)` for a URL that names another host (`file://host/share`). +/// `file:///C:/x` names `C:/x`. +fn file_url_path(s: &str) -> Option> { + let rest = s.get(..5).filter(|scheme| scheme.eq_ignore_ascii_case("file:")).map(|_| &s[5..])?; + let Some(rest) = rest.strip_prefix("//") else { return Some(Some(percent_decoded(rest))) }; + let rest = if rest.get(..9).is_some_and(|host| host.eq_ignore_ascii_case("localhost")) { &rest[9..] } else { rest }; + if !rest.starts_with('/') { + return Some(None); + } + let path = match rest.as_bytes() { + [b'/', drive, b':', ..] if drive.is_ascii_alphabetic() => &rest[1..], + _ => rest, + }; + Some(Some(percent_decoded(path))) +} + +/// `s` with `%XX` escapes decoded, as the SVG importer decodes an href. +fn percent_decoded(s: &str) -> String { + if !s.contains('%') { + return s.to_string(); + } + let mut out = Vec::with_capacity(s.len()); + let mut rest = s.as_bytes(); + while let Some((&b, tail)) = rest.split_first() { + let hex = tail + .get(..2) + .filter(|h| h.iter().all(u8::is_ascii_hexdigit)) + .and_then(|h| std::str::from_utf8(h).ok()) + .and_then(|h| u8::from_str_radix(h, 16).ok()); + match hex { + Some(v) if b == b'%' => { + out.push(v); + rest = tail.get(2..).unwrap_or_default(); + } + _ => { + out.push(b); + rest = tail; + } + } + } + String::from_utf8_lossy(&out).into_owned() } /// The params of each step of each action of action set `set` (`ui.action_sets`). @@ -524,22 +666,35 @@ fn steps_mut(set: &mut Value) -> impl Iterator) { - params.retain(|key, v| !(is_path_param(key) && v.as_str().is_some_and(|p| !readable(p)))); - if let Some(Value::Array(paths)) = params.get_mut("paths") { - paths.retain(|p| p.as_str().is_none_or(readable)); - } - for v in params.values_mut() { + use vectorcraft_engine::file_access::is_path_key; + params.retain(|key, v| match v.as_str() { + Some(p) => !unreadable_absolute(p) && !(is_path_key(key) && !readable_value(p)), + None => true, + }); + for (key, v) in params.iter_mut() { + if let Value::Array(paths) = v + && is_path_key(key) + { + paths.retain(|p| p.as_str().is_none_or(readable_value)); + } leave_out_unreadable_values(v); } } -/// [`leave_out_unreadable_params`] on each object `v` holds. +/// [`leave_out_unreadable_params`] on each object `v` holds, and an absolute path or a `file:` URL +/// the request may not read taken out of each list. fn leave_out_unreadable_values(v: &mut Value) { match v { Value::Object(fields) => leave_out_unreadable_params(fields), - Value::Array(items) => items.iter_mut().for_each(leave_out_unreadable_values), + Value::Array(items) => { + items.retain(|item| !item.as_str().is_some_and(unreadable_absolute)); + items.iter_mut().for_each(leave_out_unreadable_values); + } _ => {} } } @@ -620,21 +775,45 @@ const DOCKING_ACTION: &str = "action"; /// Runs a control request. A library loaded from outside the read root keeps the id `ui.inspect` /// shows for it ([`unreadable_library_ids`]) both ways: a request may name it or its panel by that id -/// in a [`LIBRARY_PARAMS`] param, and the reply to such a request shows that id. +/// in a [`LIBRARY_PARAMS`] param, and the reply shows that id. Under a read root, the reply shows each +/// such library's id and path as `ui.inspect` does ([`hide_library_ids`]) in every string and key, +/// errors included: the libraries hidden after it ran, and before it for a request that names one. +/// A reply with no `/` or `\` in it is not searched, since every hidden path holds a separator. pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { - let ids = if names_a_loaded_library(&req.params) { unreadable_library_ids(app) } else { Vec::new() }; - let shown: Vec<_> = ids.iter().map(|(id, shown)| (shown.as_str(), id.as_str())).collect(); - let mut params = req.params.clone(); - if shown.is_empty() || !swap_library_params(&mut params, &shown) { + if vectorcraft_engine::file_access::current().is_none() { return dispatch(app, ctx, req); } - let named = ControlRequest { method: req.method.clone(), params, reply: req.reply.clone(), state: req.state.clone(), seq: req.seq }; - match dispatch(app, ctx, &named) { - Outcome::Done(mut reply) => { - hide_library_ids(&mut reply, &ids); - Outcome::Done(reply) + let before = if names_a_loaded_library(&req.params) { unreadable_library_ids(app) } else { Vec::new() }; + let shown: Vec<_> = before.iter().map(|(id, shown)| (shown.as_str(), id.as_str())).collect(); + let mut params = req.params.clone(); + let outcome = if !shown.is_empty() && swap_library_params(&mut params, &shown) { + let request = ControlRequest { method: req.method.clone(), params, reply: req.reply.clone(), state: req.state.clone(), seq: req.seq }; + dispatch(app, ctx, &request) + } else { + dispatch(app, ctx, req) + }; + let Outcome::Done(mut reply) = outcome else { return outcome }; + if !holds_a_separator(&reply) { + return Outcome::Done(reply); + } + let mut ids = unreadable_library_ids(app); + for pair in before { + if !ids.iter().any(|(id, _)| *id == pair.0) { + ids.push(pair); } - outcome => outcome, + } + hide_library_ids(&mut reply, &ids); + Outcome::Done(reply) +} + +/// Does a string or a key of `v`, at any depth, hold a `/` or a `\`? +fn holds_a_separator(v: &Value) -> bool { + let has = |s: &str| s.contains(['/', '\\']); + match v { + Value::String(s) => has(s), + Value::Array(items) => items.iter().any(holds_a_separator), + Value::Object(fields) => fields.iter().any(|(key, item)| has(key) || holds_a_separator(item)), + _ => false, } } diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index 0a9c042a1e..34b96b81e0 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -12,6 +12,63 @@ fn input_request(app: &mut VectorcraftApp, method: &str, params: Value) -> Value r } +/// A reply hides a hidden library's id and path at every occurrence, in an error that wraps them in +/// spaces, brackets or quotes, follows them with more path text, or quotes the path with `{:?}`. +#[test] +fn a_reply_hides_a_hidden_library_whatever_surrounds_it() { + let path = std::env::temp_dir().join("vc-named-reply").join("b.svg").to_string_lossy().into_owned(); + let id = format!("loaded/{path}"); + let hidden = [(id.clone(), "loaded/b.svg".to_string())]; + let mut reply = json!({"ok": false, "error": format!("can't add ( {id} ) or '{id}', from {path}; also {id}.bak and {path:?}")}); + let root = std::env::temp_dir().join(format!("vc-named-reply-root-{}", std::process::id())); + std::fs::create_dir_all(&root).unwrap(); + let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap(); + vectorcraft_engine::file_access::confine(roots.as_ref(), || super::hide_library_ids(&mut reply, &hidden)); + let _ = std::fs::remove_dir_all(root); + assert_eq!(reply["error"], "can't add ( loaded/b.svg ) or 'loaded/b.svg', from b.svg; also loaded/b.svg.bak and \"b.svg\"", "{reply}"); +} + +/// Encoded file data in a reply (`dataBase64`) stays as it is when it holds a hidden library's path. +#[test] +fn a_reply_keeps_encoded_data_that_holds_a_hidden_path() { + let hidden = [("loaded/assets/icon".to_string(), "loaded/icon".to_string())]; + let data = "QUJDassets/iconREVG"; + let mut reply = json!({"ok": true, "result": {"files": [{"name": "a.png", "dataBase64": data}], "status": "from assets/icon"}}); + let root = std::env::temp_dir().join(format!("vc-base64-root-{}", std::process::id())); + std::fs::create_dir_all(&root).unwrap(); + let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap(); + vectorcraft_engine::file_access::confine(roots.as_ref(), || super::hide_library_ids(&mut reply, &hidden)); + let _ = std::fs::remove_dir_all(root); + assert_eq!(reply["result"]["files"][0]["dataBase64"], data, "{reply}"); + assert_eq!(reply["result"]["status"], "from icon", "{reply}"); +} + +/// Under a read root, an absolute path or a `file:` URL outside it is unreadable; an empty string, a +/// relative name and a path inside the root are not. +#[test] +fn an_absolute_path_outside_the_read_root_is_unreadable() { + let base = std::env::temp_dir().join(format!("vc-unreadable-absolute-{}", std::process::id())); + std::fs::create_dir_all(base.join("in")).unwrap(); + let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&base.join("in")], &[]).unwrap(); + let inside = base.join("in").join("a.svg").to_string_lossy().into_owned(); + vectorcraft_engine::file_access::confine(roots.as_ref(), || { + assert!(super::unreadable_absolute("/outside/root/x")); + assert!(super::unreadable_absolute("file:///outside/root/x")); + assert!(super::unreadable_absolute("FILE:///outside/root/x")); + assert!(super::unreadable_absolute("file://other-host/share/x")); + let url = if inside.starts_with('/') { format!("File://{inside}") } else { format!("File:///{}", inside.replace('\\', "/")) }; + assert!(!super::unreadable_absolute(&url), "{url}"); + assert!(super::readable_value(&url), "{url}"); + let escaped = url.replacen("/in/a.svg", "/%69n/a.svg", 1); + assert!(!super::unreadable_absolute(&escaped) && super::readable_value(&escaped), "{escaped}"); + assert!(super::unreadable_absolute("file:///outside%2Froot/x")); + assert!(!super::unreadable_absolute("")); + assert!(!super::unreadable_absolute("keep")); + assert!(!super::unreadable_absolute(&inside)); + }); + let _ = std::fs::remove_dir_all(base); +} + #[test] fn deferred_pointer_reply_uses_the_gesture_document_after_file_new() { let mut app = VectorcraftApp::new(Session::new(), Services::default()); diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index f3aee6ca73..2db9feaec5 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -439,22 +439,106 @@ fn ui_inspect_checks_a_folder_preference_as_written() { let _ = std::fs::remove_dir_all(base); } -/// `ui.inspect` rewrites a hidden library's id only in the fields that hold library and panel ids: -/// status text that is that id stays as written. +/// `ui.inspect` hides a hidden library's id and path in every string it holds: status text, the +/// command palette query and a script's report show the id and file name shown, and the path appears +/// nowhere in the reply. #[test] -fn ui_inspect_keeps_status_text_that_is_a_hidden_library_id() { +fn ui_inspect_hides_a_hidden_library_path_in_every_string() { let (mut app, tx, base) = confined("inspect-status-id"); - let theirs = format!("loaded/{}", s(base.join("outside/b.svg"))); + let path = s(base.join("outside/b.svg")); + let theirs = format!("loaded/{path}"); app.ui.color_guide_limit = theirs.clone(); - app.ui.status = theirs.clone(); + app.ui.status = format!("Loaded {path} (3 swatches), kept {path}.bak"); + app.ui.palette_query = format!("\"{theirs}\""); + let source = format!("vc.log({})", json!(format!("read {path}."))); + app.session.execute("script.run", &json!({"source": source, "name": "t.js"})).unwrap(); + for _ in 0..10_000 { + if app.session.script_pump().is_some() { + break; + } + } let r = request(&mut app, &tx, "ui.inspect", json!({})); assert_eq!(r["result"]["ui"]["color_guide_limit"], "loaded/b.svg", "{r}"); - assert_eq!(r["result"]["ui"]["status"], json!(theirs), "{r}"); + assert_eq!(r["result"]["ui"]["status"], "Loaded b.svg (3 swatches), kept b.svg.bak", "{r}"); + assert_eq!(r["result"]["ui"]["palette_query"], "\"loaded/b.svg\"", "{r}"); + assert_eq!(r["result"]["scriptReport"]["log"], json!(["read b.svg."]), "{r}"); + let outside = json!(s(base.join("outside"))).to_string(); + assert!(!r.to_string().contains(outside.trim_matches('"')), "no path outside the root: {r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// The reply to a request that names a hidden library by the id `ui.inspect` shows hides that +/// library's id wherever the reply holds it, also between spaces and brackets and where a character +/// that continues a path follows it. +#[test] +fn a_reply_hides_a_library_the_request_named_by_its_shown_id_everywhere() { + let (mut app, tx, base) = confined("named-hidden-reply"); + let theirs = format!("loaded/{}", s(base.join("outside/b.svg"))); + app.ui.color_guide_limit = theirs.clone(); + app.ui.status = format!("Not added ( {theirs} ); kept {theirs}_old"); + let r = request(&mut app, &tx, "ui.inspect", json!({"library": "loaded/b.svg"})); + assert_eq!(r["result"]["ui"]["status"], "Not added ( loaded/b.svg ); kept loaded/b.svg_old", "{r}"); + let outside = json!(s(base.join("outside"))).to_string(); + assert!(!r.to_string().contains(outside.trim_matches('"')), "no path outside the root: {r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// An error to a request that names a hidden library by the id `ui.inspect` shows names it by that id, +/// and its path appears nowhere in the reply. +#[test] +fn an_error_for_a_hidden_library_named_by_its_shown_id_shows_that_id() { + let (mut app, tx, base) = confined("named-hidden-error"); + app.ui.color_guide_limit = format!("loaded/{}", s(base.join("outside/b.svg"))); + let r = request(&mut app, &tx, "engine.execute", json!({"command": "ui.colorGuideLimit", "params": {"library": "loaded/b.svg"}})); + assert_eq!(r["ok"], false, "{r}"); + assert!(r["error"].as_str().is_some_and(|e| e.contains("loaded/b.svg")), "{r}"); + let outside = json!(s(base.join("outside"))).to_string(); + assert!(!r.to_string().contains(outside.trim_matches('"')), "no path outside the root: {r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// `ui.inspect` leaves out of a recorded action's steps a path outside the read root under any path +/// param (`diffPath`, `file`, a name ending in `Files`), and an absolute path or a `file:` URL outside +/// it (the scheme in any case) under any other param or in any list. A `file:` URL inside it stays. +#[test] +fn ui_inspect_leaves_out_recorded_paths_under_any_key() { + let (mut app, tx, base) = confined("inspect-action-keys"); + let (a, b) = (s(base.join("in/a.svg")), s(base.join("outside/b.svg"))); + let to_url = |p: &str| if p.starts_with('/') { format!("file://{p}") } else { format!("file:///{}", p.replace('\\', "/")) }; + let (url, url_in) = (to_url(&b), to_url(&a)); + let set: crate::panels::actions::ActionSet = serde_json::from_value(json!({ + "name": "set", + "actions": [{"name": "files", "steps": [ + ["document.compare", {"diffPath": b, "target": a}], + ["file.open", {"file": b}], + ["type.fonts", {"fontFiles": [a, b, url_in]}], + ["x.y", {"target": b, "url": url, "upper": url.replacen("file:", "FILE:", 1), "other": "keep"}], + ["x.z", {"list": [b, "keep"]}], + ["x.w", {"share": "file://other-host/share/b.svg", "other": "keep"}], + ["x.v", {"file": url_in}], + ]}], + })) + .unwrap(); + app.ui.action_sets = vec![set]; + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let steps = &r["result"]["ui"]["action_sets"][0]["actions"][0]["steps"]; + let want = json!([ + ["document.compare", {"target": a}], + ["file.open", {}], + ["type.fonts", {"fontFiles": [a, url_in]}], + ["x.y", {"other": "keep"}], + ["x.z", {"list": ["keep"]}], + ["x.w", {"other": "keep"}], + ["x.v", {"file": url_in}], + ]); + assert_eq!(steps, &want, "{r}"); + let outside = json!(s(base.join("outside"))).to_string(); + assert!(!r.to_string().contains(outside.trim_matches('"')), "no path outside the root: {r}"); let _ = std::fs::remove_dir_all(base); } /// `ui.inspect` hides a library loaded from outside the read root in the steps of a recorded action -/// (its `library` and `panel` params), also when nothing else names it, and keeps the action's name. +/// (its `library` and `panel` params) and in the action's name, also when nothing else names it. #[test] fn ui_inspect_hides_a_hidden_library_in_recorded_steps() { let (mut app, tx, base) = confined("inspect-action-steps"); @@ -470,7 +554,7 @@ fn ui_inspect_hides_a_hidden_library_in_recorded_steps() { app.ui.action_sets = vec![set]; let r = request(&mut app, &tx, "ui.inspect", json!({})); let action = &r["result"]["ui"]["action_sets"][0]["actions"][0]; - assert_eq!(action["name"], json!(theirs), "{r}"); + assert_eq!(action["name"], "loaded/b.svg", "{r}"); assert_eq!( action["steps"], json!([["swatch.library.add", {"library": "loaded/b.svg"}], ["window.panel", {"panel": "library:swatches:loaded/b.svg"}]]), @@ -639,7 +723,7 @@ fn ui_inspect_keeps_text_that_only_looks_like_a_library_id() { /// `ui.inspect` rewrites a hidden library's id only where a string is that whole id: a readable /// library's id that begins with it stays, and a script's report line and a command palette query -/// that hold it stay as written. +/// that end with it show the id shown. #[test] fn ui_inspect_rewrites_only_whole_hidden_library_ids() { let (mut app, tx, base) = confined("inspect-whole-id"); @@ -648,8 +732,7 @@ fn ui_inspect_rewrites_only_whole_hidden_library_ids() { assert!(readable.starts_with(&hidden)); let panel = |id: &str| -> crate::panels::library_panel::LibraryPanel { serde_json::from_value(json!({"kind": "swatches", "id": id})).unwrap() }; app.ui.library_panels = vec![panel(&hidden), panel(&readable)]; - let query = format!("library:swatches:{hidden}"); - app.ui.palette_query = query.clone(); + app.ui.palette_query = format!("library:swatches:{hidden}"); let text = format!("found {hidden}"); let source = format!("vc.log({})", json!(text)); app.session.execute("script.run", &json!({"source": source, "name": "t.js"})).unwrap(); @@ -661,8 +744,12 @@ fn ui_inspect_rewrites_only_whole_hidden_library_ids() { let r = request(&mut app, &tx, "ui.inspect", json!({})); let ids: Vec<_> = r["result"]["ui"]["library_panels"].as_array().unwrap().iter().map(|p| p["id"].clone()).collect(); assert_eq!(ids, [json!("loaded/i"), json!(readable)], "{r}"); - assert_eq!(r["result"]["scriptReport"]["log"], json!([text]), "{r}"); - assert_eq!(r["result"]["ui"]["palette_query"], json!(query), "{r}"); + assert_eq!(r["result"]["scriptReport"]["log"], json!(["found loaded/i"]), "{r}"); + assert_eq!(r["result"]["ui"]["palette_query"], "library:swatches:loaded/i", "{r}"); + // A request that names the hidden library by the id shown keeps the readable id too. + let r = request(&mut app, &tx, "ui.inspect", json!({"library": "loaded/i"})); + let ids: Vec<_> = r["result"]["ui"]["library_panels"].as_array().unwrap().iter().map(|p| p["id"].clone()).collect(); + assert_eq!(ids, [json!("loaded/i"), json!(readable)], "{r}"); let _ = std::fs::remove_dir_all(base); } diff --git a/docs/mcp.md b/docs/mcp.md index b8265bdfd2..3deca5409a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -129,8 +129,11 @@ the suite) keep the agent to the folders you give it: `file.openRecentN` for a file outside the roots ends with `no such recent file`, as an empty slot does. - `ui_inspect` (the control channel's `ui.inspect`) leaves out the paths outside the read roots: in `opening`, the documents still being read; in `ui.recent_files`, the recent files; a library panel's `path` in `ui.library_panels`; - in a recorded action's steps in `ui.action_sets`, also in a batch step's commands, the `path` and `folder` params, - a param whose name ends in `Folder` (`destinationFolder`) and the entries of a `paths` param; and the folder + in a recorded action's steps in `ui.action_sets`, also in a batch step's commands, the `path`, `paths`, `file`, + `files` and `folder` params, a param whose name ends in `Path`, `Paths`, `File`, `Files` or `Folder` (`diffPath`, + `destinationFolder`), the entries of a list under such a param, and under any other param or in any list an + absolute path or a `file:` URL outside the roots (any case of `file:`, `%XX` escapes decoded; a URL that names + another host is always left out); and the folder preferences in `ui.engine_prefs` and in an open Preferences dialog's `ui.dialog.fields` (`fontsFolder`, `pluginsFolder`, `scratchPrimary`, `scratchSecondary`, `recoveryFolder`, `templatesFolder`; an empty folder and the scratch disk choices `Startup` and `None` stay). A library loaded from a file outside the @@ -144,9 +147,12 @@ the suite) keep the agent to the folders you give it: their order, and the app's own state is not changed. A request may name such a library or its panel by the id shown in a `panel`, `library` or `panels` param, in a docking destination (`anchor`, `before`, `onto`, `above`, `below`) and in `window.panel.layout`'s `action` (`ui.set {panel: "library:swatches:loaded/"}` - reopens its panel), and the reply to such a request, an error included, shows that id too. `ui_inspect` rewrites only whole ids, and only in the fields that hold library and panel ids: - a longer id that begins with a hidden one, the status text (`ui.status`), the command palette query - (`ui.palette_query`) and a script's report (`scriptReport`) keep their text. + reopens its panel), and the reply to such a request shows that id and file name wherever the library's id or path + appears, an error included. Every reply under the read roots shows a hidden library's id as the id shown and its + path as the file name in every string and key, the status text (`ui.status`), dialogs, the command palette query + (`ui.palette_query`) and a script's report (`scriptReport`) included. A longer path that begins with a hidden one + keeps its text only when it lies inside the read roots, so a readable library's id that begins with a hidden one + stays. Encoded file data (`dataBase64`, `pngBase64`) is sent as it is. - Each flag may be given more than once (#3546): a path inside any of the read roots may be read, one inside any of the write roots written, so one session can read a project folder and a shared assets folder, or write to an output folder and a scratch folder, without widening a root to their common parent. A folder given twice From db8726e94bd5bf32d4a4a99218afaf60809a16c4 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 16:33:37 -0800 Subject: [PATCH 011/100] MCP: replies hide library paths outside the read roots in more forms - Every request under a read root records the hidden libraries before it runs, and the reply hides those and the ones hidden after it. A panic is answered inside the request, so its error is hidden too. - A hidden path followed by more path text stays only when the longer text is another loaded library's id with no `.` or `..` part. - Encoded file data is skipped only for a base64 string under `dataBase64`, `pngBase64` or `thumbnailBase64`; objects and lists under those keys, and other keys ending in `Base64`, are searched. - The error of a failed request hides a path outside the read roots that the request sent by its file name, so it reads the same whether or not a library was loaded from that path. - Paths written with `%XX` escapes are hidden, and on Windows paths in another letter case. - Recorded steps in `ui.inspect` leave out `~`, paths under `~` and paths starting with `./` or `../` outside the read roots under any param. - Hidden libraries are numbered in load order and keep their id from one request to the next. --- crates/ui-egui/src/app.rs | 1 + crates/ui-egui/src/control.rs | 445 ++++++++++++++++++++----- crates/ui-egui/src/control/tests.rs | 126 ++++++- crates/ui-egui/src/lib.rs | 4 + crates/ui-egui/src/tests/automation.rs | 114 ++++++- docs/mcp.md | 27 +- 6 files changed, 609 insertions(+), 108 deletions(-) diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 7b69fbfb8d..02dd92de2a 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -115,6 +115,7 @@ impl VectorcraftApp { synthetic_dropped_before: 0, pending_screenshots: vec![], pointer_replies: vec![], + shown_library_ids: Default::default(), queued_screenshots: vec![], screenshot_token: 0, screenshot_reserved: 0, diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index c06951cee5..4f66142e1c 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -326,30 +326,40 @@ const PANEL_FIELDS: [&str; 7] = /// recorded actions' steps; the session lists the libraries it has loaded. Text the person types (a /// command palette query, a workspace's or an action's name) is not read. When another of those ids, /// or a library loaded in the session, already has that id, the next free one -/// of `loaded/ (2)`, `(3)` and so on is shown, in the sorted order of the full ids, so that -/// two libraries stay two and `ui.inspect` and `ui.docking` show a library under the same id. -fn unreadable_library_ids(app: &VectorcraftApp) -> Vec<(String, String)> { - let mut ids = std::collections::BTreeSet::new(); +/// of `loaded/ (2)`, `(3)` and so on is shown, so that two libraries stay two and +/// `ui.inspect` and `ui.docking` show a library under the same id. A library keeps its id from one +/// request to the next while it is referred to. New ids are numbered in this order: the libraries +/// loaded in the session (the swatch, graphic style, symbol and brush libraries, each kind in the +/// order it was loaded), then the library panels in their order, the Color Guide's limit and the +/// other ids the UI state holds. +fn unreadable_library_ids(app: &VectorcraftApp) -> Hiding { + let mut rest = std::collections::BTreeSet::new(); let ui = serde_json::to_value(&app.ui).unwrap_or_default(); for field in PANEL_FIELDS { - collect_library_panel_ids(&ui[field], &mut ids); + collect_library_panel_ids(&ui[field], &mut rest); } - collect_library_params(&ui["action_sets"], &mut ids); + collect_library_params(&ui["action_sets"], &mut rest); for workspace in ["custom_workspaces", "workspace_changes"].iter().filter_map(|k| ui[k].as_array()).flatten() { for (_, layout) in workspace.as_object().into_iter().flatten().filter(|(k, _)| k.as_str() != "name") { - collect_library_panel_ids(layout, &mut ids); + collect_library_panel_ids(layout, &mut rest); } } - collect_library_panel_ids(&json!(app.ui.transient_panel), &mut ids); + collect_library_panel_ids(&json!(app.ui.transient_panel), &mut rest); // The ids of the libraries loaded in the session: a hidden one does not take the id of one loaded // without a path. let s = &app.session; let loaded = s.swatch_libraries.infos().chain(s.style_libraries.infos()).chain(s.symbol_libraries.infos()).chain(s.brush_libraries.infos()); let loaded: Vec<&str> = loaded.map(|info| info.id.as_str()).collect(); - for id in app.ui.library_panels.iter().map(|p| p.id.as_str()).chain([app.ui.color_guide_limit.as_str()]).chain(loaded.iter().copied()) { - add_library_id(id, &mut ids); + let mut ids: Vec = Vec::new(); + let mut seen = std::collections::BTreeSet::new(); + let first = loaded.iter().copied().chain(app.ui.library_panels.iter().map(|p| p.id.as_str())).chain([app.ui.color_guide_limit.as_str()]); + for id in first.map(str::to_string).chain(rest) { + if id.len() > "loaded/".len() && id.starts_with("loaded/") && seen.insert(id.clone()) { + ids.push(id); + } } let mut hidden = Vec::new(); + let mut readable_ids = std::collections::BTreeSet::new(); let mut taken: std::collections::BTreeSet = loaded.iter().map(|id| id.to_string()).collect(); for id in ids { let path = &id["loaded/".len()..]; @@ -358,25 +368,110 @@ fn unreadable_library_ids(app: &VectorcraftApp) -> Vec<(String, String)> { if name != path && !readable(path) { hidden.push((id, name)); } else { - taken.insert(id); + taken.insert(id.clone()); + readable_ids.insert(id); } } - let mut hidden = hidden - .into_iter() - .map(|(id, name)| { - let mut shown = format!("loaded/{name}"); - let mut n = 2; - while taken.contains(&shown) { - shown = format!("loaded/{name} ({n})"); - n += 1; - } - taken.insert(shown.clone()); - (id, shown) - }) - .collect::>(); + // A library keeps the id it was shown under while it is still referred to and no library loaded + // without a path, or one the request may read, has taken that id since. + let mut remembered = app.shown_library_ids.borrow_mut(); + remembered.retain(|(id, shown)| hidden.iter().any(|(h, _)| h == id) && !taken.contains(shown)); + taken.extend(remembered.iter().map(|(_, shown)| shown.clone())); + let mut shown_ids = Vec::with_capacity(hidden.len()); + for (id, name) in hidden { + if let Some((_, shown)) = remembered.iter().find(|(known, _)| *known == id) { + shown_ids.push((id, shown.clone())); + continue; + } + let mut shown = format!("loaded/{name}"); + let mut n = 2; + while taken.contains(&shown) { + shown = format!("loaded/{name} ({n})"); + n += 1; + } + taken.insert(shown.clone()); + remembered.push((id.clone(), shown.clone())); + shown_ids.push((id, shown)); + } + let mut hidden = shown_ids; // The longest first, so that an id that begins another one does not replace part of it. hidden.sort_by_key(|(id, _)| std::cmp::Reverse(id.len())); - hidden + Hiding { hidden, readable: readable_ids } +} + +/// What a reply hides ([`hide_library_ids`]). +#[derive(Debug, Default)] +struct Hiding { + /// The ids of the loaded libraries the request may not read, longest first, each with the id + /// shown instead ([`unreadable_library_ids`]). + hidden: Vec<(String, String)>, + /// The other loaded library ids the UI state and the session hold: an occurrence of a hidden id or + /// path that continues into one of them stays ([`known_continuation`]). + readable: std::collections::BTreeSet, +} + +impl Hiding { + /// Adds the hidden and readable ids of `other` that this one does not hold. + fn merge(&mut self, other: Hiding) { + self.add_hidden(other.hidden); + self.readable.extend(other.readable); + } + + /// Adds the hidden ids of `pairs` that this one does not hold, each with the id shown. + fn add_hidden(&mut self, pairs: Vec<(String, String)>) { + for pair in pairs { + if !self.hidden.iter().any(|(id, _)| *id == pair.0) { + self.hidden.push(pair); + } + } + self.hidden.sort_by_key(|(id, _)| std::cmp::Reverse(id.len())); + } + + /// Adds the hidden ids of `pairs`, each with the id shown, in place of each id this one holds for + /// the same path: written with either separator, and in any case of ASCII letters with `fold`. + fn set_hidden(&mut self, pairs: Vec<(String, String)>, fold: bool) { + let key = |id: &str| { + let id = id.replace('\\', "/"); + if fold { id.to_ascii_lowercase() } else { id } + }; + let sent: Vec = pairs.iter().map(|(id, _)| key(id)).collect(); + self.hidden.retain(|(id, _)| !sent.contains(&key(id))); + self.add_hidden(pairs); + } +} + +/// The paths outside the read roots that `v`'s strings and keys name at any depth, written as paths +/// ([`unreadable_path_like`]), each as a hidden library id (`loaded/`) with `loaded/` +/// shown. The error of a failed request hides them as it hides a hidden library's path, with that file +/// name also for a hidden library shown as `loaded/ (2)`, so that it reads the same whether +/// or not a library was loaded from one of them. +fn sent_paths(v: &Value, out: &mut Vec<(String, String)>) { + fn add(s: &str, out: &mut Vec<(String, String)>) { + if !unreadable_path_like(s) { + return; + } + let path = match file_url_path(s) { + Some(Some(path)) => path, + Some(None) => return, + None => s.to_string(), + }; + let Some(name) = path.rsplit(['/', '\\']).find(|part| !part.is_empty()).filter(|name| *name != path) else { return }; + let pair = (format!("loaded/{path}"), format!("loaded/{name}")); + if !out.contains(&pair) { + out.push(pair); + } + } + match v { + Value::String(s) => add(s, out), + Value::Array(items) => items.iter().for_each(|item| sent_paths(item, out)), + Value::Object(fields) => { + for (key, item) in fields { + add(key, out); + sent_paths(item, out); + } + } + _ => {} + } } /// Adds `id` to `ids` when it is a loaded library's id (`loaded/`). @@ -411,15 +506,20 @@ fn collect_library_panel_ids(v: &Value, ids: &mut std::collections::BTreeSet`), status text, a dialog, the command palette query, a script's /// report and a recorded step. An occurrence followed by a character that continues a path -/// ([`continues_path`]) stays only when the request may read the longer path -/// ([`readable_continuation`]): a readable library's id that begins with a hidden one keeps its path. -fn hide_library_ids(v: &mut Value, ids: &[(String, String)]) { - replace_in_value(v, &hidden_text(ids)); +/// ([`continues_path`]) stays only when the longer text is another loaded library's id +/// ([`known_continuation`]): a readable library's id that begins with a hidden one keeps its path. On +/// Windows the ids and paths are matched in any case of ASCII letters. +fn hide_library_ids(v: &mut Value, hiding: &Hiding) { + let pairs = hidden_text(&hiding.hidden); + if pairs.is_empty() { + return; + } + replace_in_value(v, &Rewrite { pairs, hiding, fold: cfg!(windows) }); } /// The text [`hide_library_ids`] replaces, longest first: each hidden id with the id shown, and the -/// path in it with the file name shown, the path written with either separator too, and with each -/// `\` doubled (a path quoted with `{:?}`). +/// path in it with the file name shown, the path written with either separator too, and with each `\` +/// doubled (a path quoted with `{:?}`). fn hidden_text(ids: &[(String, String)]) -> Vec<(String, String)> { let mut pairs: Vec<(String, String)> = Vec::new(); for (id, shown) in ids { @@ -438,49 +538,174 @@ fn hidden_text(ids: &[(String, String)]) -> Vec<(String, String)> { pairs } -/// Writes each `from` text of `pairs` as its `to` text in every string and key of `v`, at each -/// occurrence not followed by a character that continues a path ([`continues_path`]) into a path the -/// request may read ([`readable_continuation`]). Encoded file data, under a key ending in `Base64` -/// (`dataBase64`, `pngBase64`), stays as it is. -fn replace_in_value(v: &mut Value, pairs: &[(String, String)]) { - if pairs.is_empty() { - return; - } +/// What [`replace_in_value`] writes: the text to replace, what the reply hides, and whether to match +/// in any case of ASCII letters. +struct Rewrite<'a> { + pairs: Vec<(String, String)>, + hiding: &'a Hiding, + fold: bool, +} + +/// The keys of encoded file data in a reply. +const BASE64_FIELDS: [&str; 3] = ["dataBase64", "pngBase64", "thumbnailBase64"]; + +/// Is `s` written in the base64 alphabet, padded to a multiple of 4? +fn is_base64(s: &str) -> bool { + let body = s.trim_end_matches('='); + s.len().is_multiple_of(4) && s.len() - body.len() <= 2 && body.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'+' || b == b'/') +} + +/// Writes each `from` text of the rewrite's pairs as its `to` text in every string and key of `v`, as +/// [`replace_text`] does. Encoded file data, a base64 string under a key of [`BASE64_FIELDS`], stays +/// as it is. +fn replace_in_value(v: &mut Value, r: &Rewrite) { match v { Value::String(s) => { - if let Some(replaced) = replace_text(s, pairs) { + if let Some(replaced) = replace_text(s, r) { *s = replaced; } } - Value::Array(items) => items.iter_mut().for_each(|item| replace_in_value(item, pairs)), + Value::Array(items) => items.iter_mut().for_each(|item| replace_in_value(item, r)), Value::Object(fields) => { for (key, mut item) in std::mem::take(fields) { - if key.ends_with("Base64") { + if BASE64_FIELDS.contains(&key.as_str()) && item.as_str().is_some_and(is_base64) { fields.insert(key, item); continue; } - replace_in_value(&mut item, pairs); - fields.insert(replace_text(&key, pairs).unwrap_or(key), item); + replace_in_value(&mut item, r); + fields.insert(replace_text(&key, r).unwrap_or(key), item); } } _ => {} } } -/// `s` with the `from` texts of `pairs` written as their `to` texts, as [`replace_in_value`] writes -/// them. `None` when nothing changed. -fn replace_text(s: &str, pairs: &[(String, String)]) -> Option { +/// The byte offsets in `text` at which `needle` starts, not overlapping, in any case of ASCII letters +/// with `fold`. +fn occurrences(text: &str, needle: &str, fold: bool) -> Vec { + if needle.is_empty() { + return Vec::new(); + } + if fold { + let (text, needle) = (text.to_ascii_lowercase(), needle.to_ascii_lowercase()); + text.match_indices(needle.as_str()).map(|(at, _)| at).collect() + } else { + text.match_indices(needle).map(|(at, _)| at).collect() + } +} + +/// `s` with the `from` texts of the rewrite's pairs written as their `to` texts, at each occurrence +/// not followed by a character that continues a path ([`continues_path`]) into another loaded library's id +/// ([`known_continuation`]). A hidden id or path written with `%XX` escapes (in a `file:` URL) is +/// replaced too; the escapes around it stay. `None` when nothing changed. +fn replace_text(s: &str, r: &Rewrite) -> Option { + let replaced = replace_plain_text(s, r); + let text = replaced.as_deref().unwrap_or(s); + if !text.contains('%') { + return replaced; + } + let Some((decoded, spans)) = decoded_with_spans(text) else { return replaced }; + if decoded == text { + return replaced; + } + // The spans of `text` to replace, found in the decoded text, longest `from` first. + let mut cuts: Vec<(usize, usize, &str)> = Vec::new(); + let mut taken: Vec<(usize, usize)> = Vec::new(); + for (from, to) in &r.pairs { + for at in occurrences(&decoded, from, r.fold) { + let end = at + from.len(); + let free = !taken.iter().any(|&(a, b)| at < b && a < end); + if free && (!continues_path(&decoded[end..]) || !known_continuation(&decoded, at, end, r)) { + taken.push((at, end)); + cuts.push((spans[at].0, spans[end - 1].1, to.as_str())); + } + } + } + if cuts.is_empty() { + return replaced; + } + cuts.sort_unstable_by_key(|&(start, _, _)| start); + let mut out = String::with_capacity(text.len()); + let mut copied = 0; + for (start, stop, to) in cuts { + out.push_str(&text[copied..start]); + out.push_str(to); + copied = stop; + } + out.push_str(&text[copied..]); + Some(out) +} + +/// `text` with its `%XX` escapes decoded, and for each byte of the decoded text the range of `text` +/// it was read from. A decoded byte that is not part of UTF-8 text is read as U+FFFD. +fn decoded_with_spans(text: &str) -> Option<(String, Vec<(usize, usize)>)> { + let bytes = text.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut spans = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + let hex = bytes + .get(i + 1..i + 3) + .filter(|h| bytes[i] == b'%' && h.iter().all(u8::is_ascii_hexdigit)) + .and_then(|h| std::str::from_utf8(h).ok()) + .and_then(|h| u8::from_str_radix(h, 16).ok()); + match hex { + Some(v) => { + out.push(v); + spans.push((i, i + 3)); + i += 3; + } + None => { + out.push(bytes[i]); + spans.push((i, i + 1)); + i += 1; + } + } + } + let mut decoded = String::with_capacity(out.len()); + let mut decoded_spans = Vec::with_capacity(spans.len()); + let mut at = 0; + for chunk in out.utf8_chunks() { + decoded.push_str(chunk.valid()); + decoded_spans.extend_from_slice(spans.get(at..at + chunk.valid().len())?); + at += chunk.valid().len(); + for _ in chunk.invalid() { + decoded.push(char::REPLACEMENT_CHARACTER); + let span = *spans.get(at)?; + decoded_spans.extend([span; 3]); + at += 1; + } + } + Some((decoded, decoded_spans)) +} + +/// `s` with the `from` texts of the rewrite's pairs written as their `to` texts, as [`replace_text`] +/// writes them in text without `%XX` escapes. With the rewrite's `fold`, the ids and paths as written +/// are replaced first, so that two libraries whose paths differ only in letter case keep their own +/// ids, and then the ones written in another case. `None` when nothing changed. +fn replace_plain_text(s: &str, r: &Rewrite) -> Option { + let exact = replace_matches(s, r, false); + if !r.fold { + return exact; + } + replace_matches(exact.as_deref().unwrap_or(s), r, true).or(exact) +} + +/// `s` with the `from` texts of the rewrite's pairs written as their `to` texts, matched in any case +/// of ASCII letters with `fold`. `None` when nothing changed. +fn replace_matches(s: &str, r: &Rewrite, fold: bool) -> Option { let mut out: Option = None; - for (from, to) in pairs { + for (from, to) in &r.pairs { let text = out.as_deref().unwrap_or(s); - if from.is_empty() || !text.contains(from.as_str()) { + let found = occurrences(text, from, fold); + if found.is_empty() { continue; } let mut next = String::with_capacity(text.len()); let mut copied = 0; - for (at, _) in text.match_indices(from.as_str()) { + for at in found { let end = at + from.len(); - if !continues_path(&text[end..]) || !readable_continuation(&text[at..]) { + if !continues_path(&text[end..]) || !known_continuation(text, at, end, r) { next.push_str(&text[copied..at]); next.push_str(to); copied = end; @@ -500,13 +725,19 @@ fn continues_path(rest: &str) -> bool { rest.trim_start_matches(['.', '-', '~']).starts_with(|c: char| c.is_alphanumeric() || matches!(c, '_' | '/' | '\\')) } -/// May the request read the longer path that `text` starts with, up to a space, a quote, a bracket, -/// `,`, `;` or `|`, without a `loaded/` before it? A readable library's id that begins with a hidden -/// one stays; a hidden path followed by more path text (`.bak`) is still hidden. -fn readable_continuation(text: &str) -> bool { - let end = text.find(|c: char| c.is_whitespace() || "\"'`()[]{}<>,;|".contains(c)).unwrap_or(text.len()); - let path = &text[..end]; - readable(path.strip_prefix("loaded/").unwrap_or(path)) +/// Is the longer text that the occurrence `text[at..end]` starts, up to the first space, quote, +/// bracket, `,`, `;` or `|` after `end`, another loaded library's id ([`Hiding::readable`]), written +/// with or without its `loaded/`, with no `.` or `..` part? A readable library's id that begins with a +/// hidden one stays; a hidden path followed by more path text (`.bak`) is still hidden. +fn known_continuation(text: &str, at: usize, end: usize, r: &Rewrite) -> bool { + let stop = text[end..].find(|c: char| c.is_whitespace() || "\"'`()[]{}<>,;|".contains(c)).map_or(text.len(), |n| end + n); + let longer = &text[at..stop]; + if longer.split(['/', '\\']).any(|part| part == "." || part == "..") { + return false; + } + let same = |id: &str, want: &str| if r.fold { id.eq_ignore_ascii_case(want) } else { id == want }; + let id = if longer.starts_with("loaded/") { longer.to_string() } else { format!("loaded/{longer}") }; + r.hiding.readable.iter().any(|known| same(known, &id)) } /// Text that a person or a script wrote, by its key: a command palette query, a script's report and a @@ -606,6 +837,19 @@ fn unreadable_absolute(s: &str) -> bool { } } +/// Is `s` written as a path that the request may not read: an absolute path or a `file:` URL +/// ([`unreadable_absolute`]), `~` or a path under it, or a path that starts with `.` or `..` and a +/// separator? It is checked as the file access check reads it, relative to the working folder. Other +/// text with a separator (`a/b`) stays. +fn unreadable_path_like(s: &str) -> bool { + if unreadable_absolute(s) { + return true; + } + let t = s.trim_start(); + let relative = t.trim_end() == "~" || ["~/", "~\\", "./", ".\\", "../", "..\\"].iter().any(|p| t.starts_with(p)); + relative && !readable_value(s) +} + /// May the request read the path `s` names, a path or a `file:` URL ([`file_url_path`])? fn readable_value(s: &str) -> bool { match file_url_path(s) { @@ -668,13 +912,16 @@ fn steps_mut(set: &mut Value) -> impl Iterator) { use vectorcraft_engine::file_access::is_path_key; - params.retain(|key, v| match v.as_str() { - Some(p) => !unreadable_absolute(p) && !(is_path_key(key) && !readable_value(p)), - None => true, + params.retain(|key, v| { + !unreadable_path_like(key) + && match v.as_str() { + Some(p) => !unreadable_path_like(p) && !(is_path_key(key) && !readable_value(p)), + None => true, + } }); for (key, v) in params.iter_mut() { if let Value::Array(paths) = v @@ -686,13 +933,13 @@ fn leave_out_unreadable_params(params: &mut serde_json::Map) { } } -/// [`leave_out_unreadable_params`] on each object `v` holds, and an absolute path or a `file:` URL -/// the request may not read taken out of each list. +/// [`leave_out_unreadable_params`] on each object `v` holds, and a string written as a path that the +/// request may not read ([`unreadable_path_like`]) taken out of each list. fn leave_out_unreadable_values(v: &mut Value) { match v { Value::Object(fields) => leave_out_unreadable_params(fields), Value::Array(items) => { - items.retain(|item| !item.as_str().is_some_and(unreadable_absolute)); + items.retain(|item| !item.as_str().is_some_and(unreadable_path_like)); items.iter_mut().for_each(leave_out_unreadable_values); } _ => {} @@ -777,38 +1024,58 @@ const DOCKING_ACTION: &str = "action"; /// shows for it ([`unreadable_library_ids`]) both ways: a request may name it or its panel by that id /// in a [`LIBRARY_PARAMS`] param, and the reply shows that id. Under a read root, the reply shows each /// such library's id and path as `ui.inspect` does ([`hide_library_ids`]) in every string and key, -/// errors included: the libraries hidden after it ran, and before it for a request that names one. -/// A reply with no `/` or `\` in it is not searched, since every hidden path holds a separator. +/// errors included, an error from a panic too: the libraries hidden before it ran and after it. The +/// error of a failed request also hides the paths outside the read root that the request sent +/// ([`sent_paths`]). A reply with no `/`, `\` or `%` in it is not searched, since every hidden path +/// holds a separator, written or escaped. A deferred reply (a screenshot, a `ui.pointer` reply sent +/// after the tool's edit) holds no library id or path. pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { if vectorcraft_engine::file_access::current().is_none() { return dispatch(app, ctx, req); } - let before = if names_a_loaded_library(&req.params) { unreadable_library_ids(app) } else { Vec::new() }; - let shown: Vec<_> = before.iter().map(|(id, shown)| (shown.as_str(), id.as_str())).collect(); + // Pointer, key and text input does not load, close or rename a library: the libraries hidden after + // it ran are the ones hidden before, so frequent input does not read the UI state twice. + let before = if INPUT_METHODS.contains(&req.method.as_str()) { Hiding::default() } else { unreadable_library_ids(app) }; + let shown: Vec<_> = before.hidden.iter().map(|(id, shown)| (shown.as_str(), id.as_str())).collect(); let mut params = req.params.clone(); - let outcome = if !shown.is_empty() && swap_library_params(&mut params, &shown) { - let request = ControlRequest { method: req.method.clone(), params, reply: req.reply.clone(), state: req.state.clone(), seq: req.seq }; - dispatch(app, ctx, &request) - } else { - dispatch(app, ctx, req) + let swapped = !shown.is_empty() && names_a_loaded_library(&req.params) && swap_library_params(&mut params, &shown); + let ran = vectorcraft_engine::guard::catch_panic(|| { + if swapped { + let request = ControlRequest { method: req.method.clone(), params, reply: req.reply.clone(), state: req.state.clone(), seq: req.seq }; + dispatch(app, ctx, &request) + } else { + dispatch(app, ctx, req) + } + }); + let outcome = match ran { + Ok(outcome) => outcome, + Err(msg) => { + app.picks.recover(); + app.session.set_replaying(false); + err(format!("internal error: {msg} (please report this bug)")) + } }; let Outcome::Done(mut reply) = outcome else { return outcome }; if !holds_a_separator(&reply) { return Outcome::Done(reply); } - let mut ids = unreadable_library_ids(app); - for pair in before { - if !ids.iter().any(|(id, _)| *id == pair.0) { - ids.push(pair); - } + let mut hiding = unreadable_library_ids(app); + hiding.merge(before); + if reply.get("ok").and_then(Value::as_bool) == Some(false) { + let mut sent = Vec::new(); + sent_paths(&req.params, &mut sent); + hiding.set_hidden(sent, cfg!(windows)); } - hide_library_ids(&mut reply, &ids); + hide_library_ids(&mut reply, &hiding); Outcome::Done(reply) } -/// Does a string or a key of `v`, at any depth, hold a `/` or a `\`? +/// The control methods that send synthetic input. +const INPUT_METHODS: [&str; 7] = ["ui.pointer", "ui.click", "ui.drag", "ui.move", "ui.wheel", "ui.key", "ui.text"]; + +/// Does a string or a key of `v`, at any depth, hold a `/`, a `\` or a `%`? fn holds_a_separator(v: &Value) -> bool { - let has = |s: &str| s.contains(['/', '\\']); + let has = |s: &str| s.contains(['/', '\\', '%']); match v { Value::String(s) => has(s), Value::Array(items) => items.iter().any(holds_a_separator), @@ -886,6 +1153,18 @@ fn dispatch(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest) hide_library_ids(&mut docking, &ids); ok(docking) } + // A request that stops with a panic quoting the status text, after clearing the Color Guide's + // limit with `forget`. + #[cfg(test)] + "test.panic" => { + if p.get("forget").and_then(Value::as_bool) == Some(true) { + app.ui.color_guide_limit.clear(); + } + panic!("failed on {}", app.ui.status) + } + // A request that succeeds with the status text. + #[cfg(test)] + "test.status" => ok(json!(app.ui.status)), "ui.menu.list" => ok(serde_json::to_value(crate::menus::menu_entries(app)).unwrap_or_default()), "ui.contextMenu.list" => ok(serde_json::to_value(crate::menus::context_entries(app)).unwrap_or_default()), "ui.tool.select" => wrap(app.run("tool.select", json!({"tool": s("tool").unwrap_or("")}))), diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index 34b96b81e0..8407b04ffc 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -18,29 +18,125 @@ fn input_request(app: &mut VectorcraftApp, method: &str, params: Value) -> Value fn a_reply_hides_a_hidden_library_whatever_surrounds_it() { let path = std::env::temp_dir().join("vc-named-reply").join("b.svg").to_string_lossy().into_owned(); let id = format!("loaded/{path}"); - let hidden = [(id.clone(), "loaded/b.svg".to_string())]; + let hidden = hiding(&[(&id, "loaded/b.svg")], &[]); let mut reply = json!({"ok": false, "error": format!("can't add ( {id} ) or '{id}', from {path}; also {id}.bak and {path:?}")}); - let root = std::env::temp_dir().join(format!("vc-named-reply-root-{}", std::process::id())); - std::fs::create_dir_all(&root).unwrap(); - let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap(); - vectorcraft_engine::file_access::confine(roots.as_ref(), || super::hide_library_ids(&mut reply, &hidden)); - let _ = std::fs::remove_dir_all(root); + hide_under_a_root("named-reply", &mut reply, &hidden); assert_eq!(reply["error"], "can't add ( loaded/b.svg ) or 'loaded/b.svg', from b.svg; also loaded/b.svg.bak and \"b.svg\"", "{reply}"); } -/// Encoded file data in a reply (`dataBase64`) stays as it is when it holds a hidden library's path. -#[test] -fn a_reply_keeps_encoded_data_that_holds_a_hidden_path() { - let hidden = [("loaded/assets/icon".to_string(), "loaded/icon".to_string())]; - let data = "QUJDassets/iconREVG"; - let mut reply = json!({"ok": true, "result": {"files": [{"name": "a.png", "dataBase64": data}], "status": "from assets/icon"}}); - let root = std::env::temp_dir().join(format!("vc-base64-root-{}", std::process::id())); +/// A [`super::Hiding`] with `hidden` ids, each with its shown id, and `readable` ids. +fn hiding(hidden: &[(&str, &str)], readable: &[&str]) -> super::Hiding { + let mut hidden: Vec<(String, String)> = hidden.iter().map(|(id, shown)| (id.to_string(), shown.to_string())).collect(); + hidden.sort_by_key(|(id, _)| std::cmp::Reverse(id.len())); + super::Hiding { hidden, readable: readable.iter().map(|id| id.to_string()).collect() } +} + +/// [`super::hide_library_ids`] on `reply`, under a read root made for `tag`. +fn hide_under_a_root(tag: &str, reply: &mut Value, hidden: &super::Hiding) { + let root = std::env::temp_dir().join(format!("vc-{tag}-root-{}", std::process::id())); std::fs::create_dir_all(&root).unwrap(); let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap(); - vectorcraft_engine::file_access::confine(roots.as_ref(), || super::hide_library_ids(&mut reply, &hidden)); + vectorcraft_engine::file_access::confine(roots.as_ref(), || super::hide_library_ids(reply, hidden)); let _ = std::fs::remove_dir_all(root); - assert_eq!(reply["result"]["files"][0]["dataBase64"], data, "{reply}"); +} + +/// Encoded file data in a reply, a base64 string under `dataBase64`, `pngBase64` or +/// `thumbnailBase64`, stays as it is when it holds a hidden library's path. Text that is not base64 +/// under such a key, an object or a list under it, and any other key ending in `Base64` are searched. +#[test] +fn a_reply_keeps_encoded_data_that_holds_a_hidden_path() { + let hidden = hiding(&[("loaded/assets/icon", "loaded/icon")], &[]); + let data = "QUJDassets/iconREVGxQQ=="; + let mut reply = json!({"ok": true, "result": { + "files": [{"name": "a.png", "dataBase64": data, "pngBase64": "assets/icon.png", "thumbnailBase64": data}], + "status": "from assets/icon", + "dataBase64": {"note": "from assets/icon"}, + "pngBase64": ["from assets/icon"], + "otherBase64": data, + }}); + hide_under_a_root("base64", &mut reply, &hidden); + let files = &reply["result"]["files"][0]; + assert_eq!((&files["dataBase64"], &files["thumbnailBase64"]), (&json!(data), &json!(data)), "{reply}"); + assert_eq!(files["pngBase64"], "icon.png", "{reply}"); assert_eq!(reply["result"]["status"], "from icon", "{reply}"); + assert_eq!(reply["result"]["dataBase64"]["note"], "from icon", "{reply}"); + assert_eq!(reply["result"]["pngBase64"][0], "from icon", "{reply}"); + assert_eq!(reply["result"]["otherBase64"], "QUJDiconREVGxQQ==", "{reply}"); +} + +/// A hidden path followed by more text stays only when the longer text is another loaded library's +/// id: text after the path that the request may read, a `..` part and the part of the path before a +/// space do not keep it. +#[test] +fn a_reply_keeps_a_hidden_path_only_inside_a_known_library_id() { + let base = std::env::temp_dir().join(format!("vc-continuation-{}", std::process::id())); + let root = base.join("in"); + std::fs::create_dir_all(&root).unwrap(); + let hidden_path = base.join("in x").join("b.svg").to_string_lossy().into_owned(); + let other = base.join("outside").join("b.svg").to_string_lossy().into_owned(); + let known = format!("loaded/{other}{}sub.svg", std::path::MAIN_SEPARATOR); + let hidden = hiding(&[(&format!("loaded/{hidden_path}"), "loaded/b.svg"), (&format!("loaded/{other}"), "loaded/b.svg (2)")], &[&known]); + let up = ["", "..", "..", "in", "a.svg"].join(std::path::MAIN_SEPARATOR_STR); + let mut reply = json!({"error": format!("kept {hidden_path}.bak, {other}{up} and {known}")}); + let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap(); + vectorcraft_engine::file_access::confine(roots.as_ref(), || super::hide_library_ids(&mut reply, &hidden)); + let _ = std::fs::remove_dir_all(base); + assert_eq!(reply["error"], json!(format!("kept b.svg.bak, b.svg (2){up} and {known}")), "{reply}"); +} + +/// With matching in any case (as on Windows), a hidden path written in other letter cases is hidden +/// too; a path in a `file:` URL with `%XX` escapes is hidden on every platform. +#[test] +fn a_reply_hides_a_path_in_any_case_and_escaped() { + let hidden = hiding(&[("loaded/C:/Libs/My Swatches/b.svg", "loaded/b.svg")], &[]); + let mut reply = json!({"error": "from c:/LIBS/my swatches/B.SVG and file:///C:/Libs/My%20Swatches/b.svg"}); + let pairs = super::hidden_text(&hidden.hidden); + let rewrite = super::Rewrite { pairs, hiding: &hidden, fold: true }; + super::replace_in_value(&mut reply, &rewrite); + assert_eq!(reply["error"], "from b.svg and file:///b.svg", "{reply}"); + // Two libraries whose paths differ only in letter case keep their own ids. + let two = hiding(&[("loaded//libs/b.svg", "loaded/b.svg"), ("loaded//libs/B.svg", "loaded/B.svg")], &[]); + let mut reply = json!({"error": "x /libs/B.svg y /libs/b.svg z /LIBS/b.svg"}); + super::replace_in_value(&mut reply, &super::Rewrite { pairs: super::hidden_text(&two.hidden), hiding: &two, fold: true }); + assert_eq!(reply["error"], "x B.svg y b.svg z b.svg", "{reply}"); + let mut reply = json!({"error": "file:///C:/Libs/My%20Swatches/b.svg"}); + hide_under_a_root("escaped", &mut reply, &hidden); + assert_eq!(reply["error"], "file:///b.svg", "{reply}"); + let hidden = hiding(&[("loaded//libs/caf\u{e9}/b.svg", "loaded/b.svg")], &[]); + let mut reply = json!({"error": "file:///libs/caf%C3%a9/b.svg and file:///libs%2Fcaf%c3%A9%2Fb.svg?next=%2Fdocs&x=%FF at 100%25"}); + hide_under_a_root("escaped-mixed", &mut reply, &hidden); + assert_eq!(reply["error"], "file://b.svg and file://b.svg?next=%2Fdocs&x=%FF at 100%25", "{reply}"); +} + +/// A path a failed request sent takes the place of a hidden library's id for the same path, written +/// with the other separator, and in another letter case when matching in any case (as on Windows). +#[test] +fn a_sent_path_replaces_a_hidden_id_for_the_same_path() { + let mut hidden = hiding(&[(r"loaded/C:\Libs\b.svg", "loaded/b.svg (2)"), ("loaded//other/b.svg", "loaded/b.svg")], &[]); + hidden.set_hidden(vec![("loaded/c:/libs/B.svg".to_string(), "loaded/B.svg".to_string())], true); + let ids: Vec<&str> = hidden.hidden.iter().map(|(id, _)| id.as_str()).collect(); + assert_eq!(ids, ["loaded/c:/libs/B.svg", "loaded//other/b.svg"]); + hidden.set_hidden(vec![("loaded//OTHER/b.svg".to_string(), "loaded/b.svg".to_string())], false); + assert_eq!(hidden.hidden.len(), 3, "{hidden:?}"); +} + +/// The paths outside the read root that a request sends, as a path, a `file:` URL, under `~` or +/// starting with `../`, are hidden by their file names; a URL that names another host, plain text and +/// a path inside the root are not. +#[test] +fn a_request_sends_paths_that_an_error_hides() { + let base = std::env::temp_dir().join(format!("vc-sent-paths-{}", std::process::id())); + let root = base.join("in"); + std::fs::create_dir_all(&root).unwrap(); + let inside = root.join("a.svg").to_string_lossy().into_owned(); + let keep = json!(["a/b", inside, "file://host/g.svg"]); + let params = json!({"path": "/x/my lib/b.svg", "list": ["file:///y/c%20d.svg", "~/e.svg", "../f.svg"], "keep": keep}); + let mut sent = Vec::new(); + let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap(); + vectorcraft_engine::file_access::confine(roots.as_ref(), || super::sent_paths(¶ms, &mut sent)); + let _ = std::fs::remove_dir_all(base); + let pair = |path: &str, name: &str| (format!("loaded/{path}"), format!("loaded/{name}")); + assert_eq!(sent, [pair("/y/c d.svg", "c d.svg"), pair("~/e.svg", "e.svg"), pair("../f.svg", "f.svg"), pair("/x/my lib/b.svg", "b.svg")]); } /// Under a read root, an absolute path or a `file:` URL outside it is unreadable; an empty string, a diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 644125b545..3b7f338dbc 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -166,6 +166,10 @@ pub struct VectorcraftApp { /// `ui.pointer` replies waiting for their events and the edit the tool computes on another /// thread ([`control::Outcome::AfterToolEdit`]), with the numbers of those events. pointer_replies: Vec, + /// The id each library loaded from outside the automation read roots is shown under, kept while + /// the UI state or the session refers to the library, so that the id a control client was given + /// keeps naming the same library. + shown_library_ids: std::cell::RefCell>, /// Admitted captures whose settled frame has not come yet (issued one at a time). queued_screenshots: Vec, screenshot_token: u64, diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index 2db9feaec5..01929f578c 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -627,6 +627,118 @@ fn ui_inspect_keeps_a_hidden_library_off_a_loaded_librarys_id() { let _ = std::fs::remove_dir_all(base); } +/// Hidden libraries with the same file name are numbered in the order they were loaded, not in the +/// order of their paths. +#[test] +fn hidden_libraries_are_numbered_in_load_order() { + let (mut app, tx, base) = confined("hidden-load-order"); + let gpl = "GIMP Palette\nName: p\n0 0 0 Black\n"; + for dir in ["z", "a"] { + std::fs::create_dir_all(base.join("outside").join(dir)).unwrap(); + let path = base.join("outside").join(dir).join("b.gpl"); + std::fs::write(&path, gpl).unwrap(); + app.session.execute("swatch.library.load", &json!({"path": s(path)})).unwrap(); + } + app.ui.color_guide_limit = format!("loaded/{}", s(base.join("outside").join("a").join("b.gpl"))); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + assert_eq!(r["result"]["ui"]["color_guide_limit"], "loaded/b.gpl (2)", "{r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// A hidden library keeps the id a reply showed for it when another library with the same file name is +/// loaded later. +#[test] +fn a_hidden_library_keeps_its_id_when_another_with_its_name_loads() { + let (mut app, tx, base) = confined("hidden-stable-id"); + let first = format!("loaded/{}", s(base.join("outside").join("a").join("b.gpl"))); + let panel: crate::panels::library_panel::LibraryPanel = serde_json::from_value(json!({"kind": "swatches", "id": first})).unwrap(); + app.ui.library_panels = vec![panel]; + let r = request(&mut app, &tx, "ui.inspect", json!({})); + assert_eq!(r["result"]["ui"]["library_panels"][0]["id"], "loaded/b.gpl", "{r}"); + std::fs::create_dir_all(base.join("outside").join("z")).unwrap(); + let path = base.join("outside").join("z").join("b.gpl"); + std::fs::write(&path, "GIMP Palette\nName: p\n0 0 0 Black\n").unwrap(); + app.session.execute("swatch.library.load", &json!({"path": s(path.clone())})).unwrap(); + app.ui.color_guide_limit = format!("loaded/{}", s(path)); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + assert_eq!(r["result"]["ui"]["library_panels"][0]["id"], "loaded/b.gpl", "{r}"); + assert_eq!(r["result"]["ui"]["color_guide_limit"], "loaded/b.gpl (2)", "{r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// An error from a panic hides a hidden library's path, also when the request stopped referring to +/// the library before it panicked. +#[test] +fn a_panic_reply_hides_a_library_hidden_before_the_request() { + let (mut app, tx, base) = confined("panic-hidden"); + let path = s(base.join("outside/b.svg")); + app.ui.color_guide_limit = format!("loaded/{path}"); + app.ui.status = format!("Loaded {path}"); + let r = request(&mut app, &tx, "test.panic", json!({"forget": true})); + assert_eq!(r["error"], "internal error: failed on Loaded b.svg (please report this bug)", "{r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// An error shows a path outside the read root that the request sent by its file name, whether or not +/// a hidden library was loaded from that path, so that it reads the same either way. A path the +/// request sends does not show the app's own text that holds it, in an error or a result. +#[test] +fn an_error_hides_a_sent_path_whether_or_not_a_library_was_loaded_from_it() { + let (mut app, tx, base) = confined("client-echo"); + let path = s(base.join("outside/b.svg")); + // Another hidden library with the same file name comes first, so this one shows as `b.svg (2)`. + let first = format!("loaded/{}", s(base.join("outside/z/b.svg"))); + let panel: crate::panels::library_panel::LibraryPanel = serde_json::from_value(json!({"kind": "swatches", "id": first})).unwrap(); + app.ui.library_panels = vec![panel]; + app.ui.color_guide_limit = format!("loaded/{path}"); + app.ui.status = format!("Loaded {path}"); + let unloaded = [(s(base.join("outside/y/b.svg")), "b.svg"), (s(base.join("outside/c.svg")), "c.svg")]; + for (sent, name) in [(path.clone(), "b.svg")].into_iter().chain(unloaded) { + let r = request(&mut app, &tx, "app.open", json!({"path": sent})); + assert!(r["error"].as_str().is_some_and(|e| e.ends_with(&format!(": {name}"))), "{r}"); + } + let r = request(&mut app, &tx, "test.status", json!({"guess": path})); + assert_eq!(r["result"], "Loaded b.svg (2)", "{r}"); + let r = request(&mut app, &tx, "test.panic", json!({"guess": path})); + assert_eq!(r["error"], "internal error: failed on Loaded b.svg (please report this bug)", "{r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// A reply whose only trace of a hidden library's path is a `file:` URL with every separator escaped +/// shows the file name. +#[test] +fn a_reply_hides_a_path_with_escaped_separators() { + let (mut app, tx, base) = confined("escaped-separators"); + let path = s(base.join("outside/b.svg")); + app.ui.color_guide_limit = format!("loaded/{path}"); + app.ui.status = format!("from file:{}", path.replace('/', "%2F").replace('\\', "%5C")); + let r = request(&mut app, &tx, "test.status", json!({})); + assert_eq!(r["result"], "from file:b.svg", "{r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// `ui.inspect` leaves out of a recorded action's steps a path that starts with `./` or `../`, `~` and +/// a path under `~` outside the read root under any param, as a key too, and in any list. A URL and +/// other text with a separator stay. +#[test] +fn ui_inspect_leaves_out_relative_paths_in_recorded_steps() { + let (mut app, tx, base) = confined("inspect-relative"); + let set: crate::panels::actions::ActionSet = serde_json::from_value(json!({ + "name": "set", + "actions": [{"name": "files", "steps": [ + ["x.y", {"target": "~/b.svg", "rel": "../outside/b.svg", "home": "~", "text": "a/b", "url": "https://example.com/a"}], + ["x.z", {"list": [".\\b.svg", "a/b"], "../k": 1}], + ]}], + })) + .unwrap(); + app.ui.action_sets = vec![set]; + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let steps = &r["result"]["ui"]["action_sets"][0]["actions"][0]["steps"]; + let want = json!([["x.y", {"text": "a/b", "url": "https://example.com/a"}], ["x.z", {"list": ["a/b"]}]]); + assert_eq!(steps, &want, "{r}"); + let _ = std::fs::remove_dir_all(base); +} + /// A closed panel's former place, as `ui.docking_hidden` keeps it. fn hidden_place() -> craft_ui::docking::Location { serde_json::from_value(json!({"floating": null, "anchor": null, "placement": {"Tab": {"before": null}}, "size": null, "stack": null})).unwrap() @@ -687,7 +799,7 @@ fn ui_inspect_and_ui_docking_give_a_hidden_library_the_same_id() { let ui = &r["result"]["ui"]; assert_eq!( (ui["color_guide_limit"].as_str(), ui["open_panel"].as_str()), - (Some("loaded/b.svg"), Some("library:swatches:loaded/b.svg (2)")), + (Some("loaded/b.svg (2)"), Some("library:swatches:loaded/b.svg")), "{r}" ); let d = request(&mut app, &tx, "ui.docking", json!({})); diff --git a/docs/mcp.md b/docs/mcp.md index 3deca5409a..05e2cab208 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -131,9 +131,10 @@ the suite) keep the agent to the folders you give it: documents still being read; in `ui.recent_files`, the recent files; a library panel's `path` in `ui.library_panels`; in a recorded action's steps in `ui.action_sets`, also in a batch step's commands, the `path`, `paths`, `file`, `files` and `folder` params, a param whose name ends in `Path`, `Paths`, `File`, `Files` or `Folder` (`diffPath`, - `destinationFolder`), the entries of a list under such a param, and under any other param or in any list an - absolute path or a `file:` URL outside the roots (any case of `file:`, `%XX` escapes decoded; a URL that names - another host is always left out); and the folder + `destinationFolder`), the entries of a list under such a param, and under any other param, as a key or in any + list, an absolute path or a `file:` URL outside the roots (any case of `file:`, `%XX` escapes decoded; a URL that + names another host is always left out), and `~`, a path under `~` or a path that starts with `./` or `../` + (either separator) that resolves outside the roots from the app's working folder; and the folder preferences in `ui.engine_prefs` and in an open Preferences dialog's `ui.dialog.fields` (`fontsFolder`, `pluginsFolder`, `scratchPrimary`, `scratchSecondary`, `recoveryFolder`, `templatesFolder`; an empty folder and the scratch disk choices `Startup` and `None` stay). A library loaded from a file outside the @@ -142,17 +143,25 @@ the suite) keep the agent to the folders you give it: params of a recorded action's steps in `ui.action_sets`, also when its panel is closed. A path written with either separator counts as a path, also one saved on another platform. When another hidden library has the same file name, the libraries take `loaded/`, - `loaded/ (2)`, `(3)` and so on in the order of their full ids, and each one keeps its id in both - replies. A hidden library skips an id that a library loaded without a path already has. The other entries keep - their order, and the app's own state is not changed. A request may name such a library or its panel by the id + `loaded/ (2)`, `(3)` and so on: first the libraries loaded in the session (swatch, graphic style, + symbol and brush libraries, each kind in the order it was loaded), then the library panels in their order, the + Color Guide's limit and the other ids. Each one keeps its id in both replies, and from one request to the next + while the app refers to it, also when another library with the same file name is loaded later. A hidden library skips an id that + a library loaded without a path already has. The other entries keep their order, and the app's own state is not + changed. A request may name such a library or its panel by the id shown in a `panel`, `library` or `panels` param, in a docking destination (`anchor`, `before`, `onto`, `above`, `below`) and in `window.panel.layout`'s `action` (`ui.set {panel: "library:swatches:loaded/"}` reopens its panel), and the reply to such a request shows that id and file name wherever the library's id or path appears, an error included. Every reply under the read roots shows a hidden library's id as the id shown and its path as the file name in every string and key, the status text (`ui.status`), dialogs, the command palette query - (`ui.palette_query`) and a script's report (`scriptReport`) included. A longer path that begins with a hidden one - keeps its text only when it lies inside the read roots, so a readable library's id that begins with a hidden one - stays. Encoded file data (`dataBase64`, `pngBase64`) is sent as it is. + (`ui.palette_query`), a script's report (`scriptReport`) and the error from an internal fault included, for the + libraries hidden before the request ran and after it. The path is also matched with `%XX` escapes as a `file:` + URL writes it, and on Windows in any case of its letters. A longer text that begins with a hidden id or path + keeps it only when the longer text is another loaded library's id with no `.` or `..` part, so a readable + library's id that begins with a hidden one stays. The error of a failed request also shows a path outside the + read roots that the request sent by its file name, whether or not a library was loaded from it, so that the + error reads the same either way. Encoded file data, a base64 string under `dataBase64`, + `pngBase64` or `thumbnailBase64`, is sent as it is. - Each flag may be given more than once (#3546): a path inside any of the read roots may be read, one inside any of the write roots written, so one session can read a project folder and a shared assets folder, or write to an output folder and a scratch folder, without widening a root to their common parent. A folder given twice From cce2a685bca1b75625d17cece5fa77f8eb3d15cc Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 16:43:49 -0800 Subject: [PATCH 012/100] MCP: a failed request's error hides a file URL it sent that names another host The error shows the URL's file name, as it does for a local path the request sent outside the read roots. --- crates/ui-egui/src/control.rs | 3 ++- crates/ui-egui/src/control/tests.rs | 17 ++++++++++++----- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 4f66142e1c..64d9e7b061 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -450,9 +450,10 @@ fn sent_paths(v: &Value, out: &mut Vec<(String, String)>) { if !unreadable_path_like(s) { return; } + // A URL that names another host as the path after its scheme (`//host/share/…`). let path = match file_url_path(s) { Some(Some(path)) => path, - Some(None) => return, + Some(None) => percent_decoded(&s[5..]), None => s.to_string(), }; let Some(name) = path.rsplit(['/', '\\']).find(|part| !part.is_empty()).filter(|name| *name != path) else { return }; diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index 8407b04ffc..badf602cc9 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -120,9 +120,9 @@ fn a_sent_path_replaces_a_hidden_id_for_the_same_path() { assert_eq!(hidden.hidden.len(), 3, "{hidden:?}"); } -/// The paths outside the read root that a request sends, as a path, a `file:` URL, under `~` or -/// starting with `../`, are hidden by their file names; a URL that names another host, plain text and -/// a path inside the root are not. +/// The paths outside the read root that a request sends, as a path, a `file:` URL (also one that names +/// another host), under `~` or starting with `../`, are hidden by their file names; plain text and a +/// path inside the root are not. #[test] fn a_request_sends_paths_that_an_error_hides() { let base = std::env::temp_dir().join(format!("vc-sent-paths-{}", std::process::id())); @@ -130,13 +130,20 @@ fn a_request_sends_paths_that_an_error_hides() { std::fs::create_dir_all(&root).unwrap(); let inside = root.join("a.svg").to_string_lossy().into_owned(); let keep = json!(["a/b", inside, "file://host/g.svg"]); - let params = json!({"path": "/x/my lib/b.svg", "list": ["file:///y/c%20d.svg", "~/e.svg", "../f.svg"], "keep": keep}); + let params = json!({"path": "/x/my lib/b.svg", "list": ["file:///y/c%20d.svg", "~/e.svg", "../f.svg"], "other": keep}); let mut sent = Vec::new(); let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap(); vectorcraft_engine::file_access::confine(roots.as_ref(), || super::sent_paths(¶ms, &mut sent)); let _ = std::fs::remove_dir_all(base); let pair = |path: &str, name: &str| (format!("loaded/{path}"), format!("loaded/{name}")); - assert_eq!(sent, [pair("/y/c d.svg", "c d.svg"), pair("~/e.svg", "e.svg"), pair("../f.svg", "f.svg"), pair("/x/my lib/b.svg", "b.svg")]); + let want = [ + pair("/y/c d.svg", "c d.svg"), + pair("~/e.svg", "e.svg"), + pair("../f.svg", "f.svg"), + pair("//host/g.svg", "g.svg"), + pair("/x/my lib/b.svg", "b.svg"), + ]; + assert_eq!(sent, want); } /// Under a read root, an absolute path or a `file:` URL outside it is unreadable; an empty string, a From 027035668b78174384bfeda7689516dcf2b86673 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 17:07:30 -0800 Subject: [PATCH 013/100] MCP: a readable library id keeps its text when a space follows the hidden part A hidden id or path stays only where it begins another loaded library's whole id with no `.` or `..` part, also when that id has a space after the hidden part. --- crates/ui-egui/src/control.rs | 41 ++++++++++++++--------------- crates/ui-egui/src/control/tests.rs | 6 +++++ docs/mcp.md | 5 ++-- 3 files changed, 28 insertions(+), 24 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 64d9e7b061..29c9fbbb5c 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -406,7 +406,7 @@ struct Hiding { /// shown instead ([`unreadable_library_ids`]). hidden: Vec<(String, String)>, /// The other loaded library ids the UI state and the session hold: an occurrence of a hidden id or - /// path that continues into one of them stays ([`known_continuation`]). + /// path that begins one of them stays ([`begins_known_id`]). readable: std::collections::BTreeSet, } @@ -506,9 +506,8 @@ fn collect_library_panel_ids(v: &Value, ids: &mut std::collections::BTreeSet`), status text, a dialog, the command palette query, a script's -/// report and a recorded step. An occurrence followed by a character that continues a path -/// ([`continues_path`]) stays only when the longer text is another loaded library's id -/// ([`known_continuation`]): a readable library's id that begins with a hidden one keeps its path. On +/// report and a recorded step. An occurrence that begins another loaded library's id stays +/// ([`begins_known_id`]): a readable library's id that begins with a hidden one keeps its path. On /// Windows the ids and paths are matched in any case of ASCII letters. fn hide_library_ids(v: &mut Value, hiding: &Hiding) { let pairs = hidden_text(&hiding.hidden); @@ -596,8 +595,8 @@ fn occurrences(text: &str, needle: &str, fold: bool) -> Vec { } /// `s` with the `from` texts of the rewrite's pairs written as their `to` texts, at each occurrence -/// not followed by a character that continues a path ([`continues_path`]) into another loaded library's id -/// ([`known_continuation`]). A hidden id or path written with `%XX` escapes (in a `file:` URL) is +/// that does not begin another loaded library's id ([`begins_known_id`]). A hidden id or path written +/// with `%XX` escapes (in a `file:` URL) is /// replaced too; the escapes around it stay. `None` when nothing changed. fn replace_text(s: &str, r: &Rewrite) -> Option { let replaced = replace_plain_text(s, r); @@ -616,7 +615,7 @@ fn replace_text(s: &str, r: &Rewrite) -> Option { for at in occurrences(&decoded, from, r.fold) { let end = at + from.len(); let free = !taken.iter().any(|&(a, b)| at < b && a < end); - if free && (!continues_path(&decoded[end..]) || !known_continuation(&decoded, at, end, r)) { + if free && !begins_known_id(&decoded, at, end, r) { taken.push((at, end)); cuts.push((spans[at].0, spans[end - 1].1, to.as_str())); } @@ -706,7 +705,7 @@ fn replace_matches(s: &str, r: &Rewrite, fold: bool) -> Option { let mut copied = 0; for at in found { let end = at + from.len(); - if !continues_path(&text[end..]) || !known_continuation(text, at, end, r) { + if !begins_known_id(text, at, end, r) { next.push_str(&text[copied..at]); next.push_str(to); copied = end; @@ -726,19 +725,19 @@ fn continues_path(rest: &str) -> bool { rest.trim_start_matches(['.', '-', '~']).starts_with(|c: char| c.is_alphanumeric() || matches!(c, '_' | '/' | '\\')) } -/// Is the longer text that the occurrence `text[at..end]` starts, up to the first space, quote, -/// bracket, `,`, `;` or `|` after `end`, another loaded library's id ([`Hiding::readable`]), written -/// with or without its `loaded/`, with no `.` or `..` part? A readable library's id that begins with a -/// hidden one stays; a hidden path followed by more path text (`.bak`) is still hidden. -fn known_continuation(text: &str, at: usize, end: usize, r: &Rewrite) -> bool { - let stop = text[end..].find(|c: char| c.is_whitespace() || "\"'`()[]{}<>,;|".contains(c)).map_or(text.len(), |n| end + n); - let longer = &text[at..stop]; - if longer.split(['/', '\\']).any(|part| part == "." || part == "..") { - return false; - } - let same = |id: &str, want: &str| if r.fold { id.eq_ignore_ascii_case(want) } else { id == want }; - let id = if longer.starts_with("loaded/") { longer.to_string() } else { format!("loaded/{longer}") }; - r.hiding.readable.iter().any(|known| same(known, &id)) +/// Does the occurrence `text[at..end]` begin a longer loaded library id ([`Hiding::readable`]), written +/// with or without its `loaded/` and with no `.` or `..` part, that ends where the text stops +/// continuing a path ([`continues_path`])? +/// A readable library's id that begins with a hidden one stays, also with a space after the hidden +/// part; a hidden path followed by other path text (`.bak`, `/../x`) is still hidden. +fn begins_known_id(text: &str, at: usize, end: usize, r: &Rewrite) -> bool { + let rest = &text[at..]; + let same = |a: &str, b: &str| if r.fold { a.eq_ignore_ascii_case(b) } else { a == b }; + let dotted = |id: &str| id.split(['/', '\\']).any(|part| part == "." || part == ".."); + r.hiding.readable.iter().filter(|known| !dotted(known)).any(|known| { + let id = if rest.starts_with("loaded/") { known.as_str() } else { known.strip_prefix("loaded/").unwrap_or(known) }; + id.len() > end - at && rest.get(..id.len()).is_some_and(|head| same(head, id)) && !continues_path(&rest[id.len()..]) + }) } /// Text that a person or a script wrote, by its key: a command palette query, a script's report and a diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index badf602cc9..800a57c795 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -82,6 +82,12 @@ fn a_reply_keeps_a_hidden_path_only_inside_a_known_library_id() { vectorcraft_engine::file_access::confine(roots.as_ref(), || super::hide_library_ids(&mut reply, &hidden)); let _ = std::fs::remove_dir_all(base); assert_eq!(reply["error"], json!(format!("kept b.svg.bak, b.svg (2){up} and {known}")), "{reply}"); + // A readable library's id with a space after the hidden part stays whole. + // A readable id with a `..` part does not keep the hidden part. + let hidden = hiding(&[("loaded//art/in", "loaded/in")], &["loaded//art/in work/a.svg", "loaded//art/in/../ok/c.svg"]); + let mut reply = json!({"error": "x loaded//art/in work/a.svg, /art/in work/a.svg and /art/in work; /art/in/../ok/c.svg"}); + hide_under_a_root("space-continuation", &mut reply, &hidden); + assert_eq!(reply["error"], "x loaded//art/in work/a.svg, /art/in work/a.svg and in work; in/../ok/c.svg", "{reply}"); } /// With matching in any case (as on Windows), a hidden path written in other letter cases is hidden diff --git a/docs/mcp.md b/docs/mcp.md index 05e2cab208..938833d02d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -156,9 +156,8 @@ the suite) keep the agent to the folders you give it: path as the file name in every string and key, the status text (`ui.status`), dialogs, the command palette query (`ui.palette_query`), a script's report (`scriptReport`) and the error from an internal fault included, for the libraries hidden before the request ran and after it. The path is also matched with `%XX` escapes as a `file:` - URL writes it, and on Windows in any case of its letters. A longer text that begins with a hidden id or path - keeps it only when the longer text is another loaded library's id with no `.` or `..` part, so a readable - library's id that begins with a hidden one stays. The error of a failed request also shows a path outside the + URL writes it, and on Windows in any case of its letters. A hidden id or path stays only where it begins + another loaded library's whole id with no `.` or `..` part, so a readable library's id that begins with a hidden one stays. The error of a failed request also shows a path outside the read roots that the request sent by its file name, whether or not a library was loaded from it, so that the error reads the same either way. Encoded file data, a base64 string under `dataBase64`, `pngBase64` or `thumbnailBase64`, is sent as it is. From 2d2e8fa53fc2cb22a8aeacc569a7f8fcd2218430 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 17:22:51 -0800 Subject: [PATCH 014/100] MCP: a screenshot's reply hides library paths outside the read roots The reply is sent after the request was handled, and now hides what any other reply hides. --- crates/ui-egui/src/app/screenshot.rs | 9 ++++--- crates/ui-egui/src/control.rs | 34 +++++++++++++++++++------- crates/ui-egui/src/tests/automation.rs | 20 +++++++++++++++ docs/mcp.md | 4 +-- 4 files changed, 53 insertions(+), 14 deletions(-) diff --git a/crates/ui-egui/src/app/screenshot.rs b/crates/ui-egui/src/app/screenshot.rs index ce97fc747f..c34d022e50 100644 --- a/crates/ui-egui/src/app/screenshot.rs +++ b/crates/ui-egui/src/app/screenshot.rs @@ -157,9 +157,12 @@ impl VectorcraftApp { let pending = self.pending_screenshots.remove(index); self.screenshot_reserved = self.screenshot_reserved.saturating_sub(pending.reserved_bytes); let roots = self.automation_roots.clone(); - let _ = pending - .reply - .send(file_access::confine(roots.as_ref(), || control::save_screenshot(self, &image, pending.path.as_deref(), pending.data))); + let reply = file_access::confine(roots.as_ref(), || { + let mut reply = control::save_screenshot(self, &image, pending.path.as_deref(), pending.data); + control::hide_in_deferred_reply(self, &mut reply, &json!({"path": pending.path})); + reply + }); + let _ = pending.reply.send(reply); } Err(e) => { let error = format!("the screenshot could not be used: {e}"); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 29c9fbbb5c..066d3d82db 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -1027,8 +1027,9 @@ const DOCKING_ACTION: &str = "action"; /// errors included, an error from a panic too: the libraries hidden before it ran and after it. The /// error of a failed request also hides the paths outside the read root that the request sent /// ([`sent_paths`]). A reply with no `/`, `\` or `%` in it is not searched, since every hidden path -/// holds a separator, written or escaped. A deferred reply (a screenshot, a `ui.pointer` reply sent -/// after the tool's edit) holds no library id or path. +/// holds a separator, written or escaped. A screenshot's reply is hidden when it is sent +/// ([`hide_in_deferred_reply`]); a `ui.pointer` reply sent after the tool's edit holds no library id +/// or path. pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { if vectorcraft_engine::file_access::current().is_none() { return dispatch(app, ctx, req); @@ -1056,18 +1057,33 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques } }; let Outcome::Done(mut reply) = outcome else { return outcome }; - if !holds_a_separator(&reply) { - return Outcome::Done(reply); + hide_in_reply(app, &mut reply, &req.params, before); + Outcome::Done(reply) +} + +/// Hides in `reply` the libraries hidden now and in `before`, and for a failed request the paths +/// outside the read root that `sent`, the request's params, holds ([`handle`]). +fn hide_in_reply(app: &VectorcraftApp, reply: &mut Value, sent: &Value, before: Hiding) { + if !holds_a_separator(reply) { + return; } let mut hiding = unreadable_library_ids(app); hiding.merge(before); if reply.get("ok").and_then(Value::as_bool) == Some(false) { - let mut sent = Vec::new(); - sent_paths(&req.params, &mut sent); - hiding.set_hidden(sent, cfg!(windows)); + let mut paths = Vec::new(); + sent_paths(sent, &mut paths); + hiding.set_hidden(paths, cfg!(windows)); + } + hide_library_ids(reply, &hiding); +} + +/// Hides in a reply sent after its request was handled (a screenshot's) what [`handle`] hides in a +/// reply, with `sent` as the request's params. Without a read root in force it leaves the reply as +/// it is. +pub(crate) fn hide_in_deferred_reply(app: &VectorcraftApp, reply: &mut Value, sent: &Value) { + if vectorcraft_engine::file_access::current().is_some() { + hide_in_reply(app, reply, sent, Hiding::default()); } - hide_library_ids(&mut reply, &hiding); - Outcome::Done(reply) } /// The control methods that send synthetic input. diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index 01929f578c..05811869ae 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -704,6 +704,26 @@ fn an_error_hides_a_sent_path_whether_or_not_a_library_was_loaded_from_it() { let _ = std::fs::remove_dir_all(base); } +/// A screenshot's reply, sent after its request was handled, hides a hidden library's path, and in an +/// error the path outside the read root that the request sent. +#[test] +fn a_screenshot_reply_hides_paths_like_any_reply() { + let (mut app, _tx, base) = confined("deferred-reply"); + let path = s(base.join("outside/b.svg")); + app.ui.color_guide_limit = format!("loaded/{path}"); + let sent = s(base.join("outside/shot.png")); + let roots = AutomationRoots::new(&[&base.join("in")], &[&base.join("out")]).unwrap(); + let mut done = json!({"ok": true, "result": {"note": format!("from {path}")}}); + let mut failed = json!({"ok": false, "error": format!("could not write {sent}")}); + vectorcraft_engine::file_access::confine(roots.as_ref(), || { + crate::control::hide_in_deferred_reply(&app, &mut done, &json!({"path": sent})); + crate::control::hide_in_deferred_reply(&app, &mut failed, &json!({"path": sent})); + }); + assert_eq!(done["result"]["note"], "from b.svg", "{done}"); + assert_eq!(failed["error"], "could not write shot.png", "{failed}"); + let _ = std::fs::remove_dir_all(base); +} + /// A reply whose only trace of a hidden library's path is a `file:` URL with every separator escaped /// shows the file name. #[test] diff --git a/docs/mcp.md b/docs/mcp.md index 938833d02d..278d69e8d5 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -154,8 +154,8 @@ the suite) keep the agent to the folders you give it: reopens its panel), and the reply to such a request shows that id and file name wherever the library's id or path appears, an error included. Every reply under the read roots shows a hidden library's id as the id shown and its path as the file name in every string and key, the status text (`ui.status`), dialogs, the command palette query - (`ui.palette_query`), a script's report (`scriptReport`) and the error from an internal fault included, for the - libraries hidden before the request ran and after it. The path is also matched with `%XX` escapes as a `file:` + (`ui.palette_query`), a script's report (`scriptReport`), a screenshot's reply and the error from an internal + fault included, for the libraries hidden before the request ran and after it. The path is also matched with `%XX` escapes as a `file:` URL writes it, and on Windows in any case of its letters. A hidden id or path stays only where it begins another loaded library's whole id with no `.` or `..` part, so a readable library's id that begins with a hidden one stays. The error of a failed request also shows a path outside the read roots that the request sent by its file name, whether or not a library was loaded from it, so that the From 45987bc9484e4bbd194716f486bd12f1f5b7e5ea Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 17:25:19 -0800 Subject: [PATCH 015/100] Plug-ins: a grant one session stops counting stops counting for every session on its folder The grants that should be gone from grants.json but could not be removed were kept per Session, so a second Session attached to the same Plug-ins folder in the same process still counted them for plugin.inspect, plugin.install and loading plug-ins. They are now kept for the process, by the folder's canonical path and the plug-in id, and a successful write of grants.json for the id in any session makes the grant count again. Attaching another folder no longer clears them. A failed removal does not override a write another session made for the id in the meantime. The process keeps the latest 256 grant writes for that check. Another process on the folder still reads grants.json as it is written. --- crates/engine/src/cmd/plugin.rs | 10 +- crates/engine/src/lib.rs | 4 - crates/engine/src/session/documents.rs | 1 - crates/engine/src/store.rs | 123 +++++++++++++--- crates/engine/src/tests/plugins.rs | 188 ++++++++++++++++++++++++- docs/plugins.md | 12 +- docs/target-app-parity.md | 1 + 7 files changed, 302 insertions(+), 37 deletions(-) diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index a5b71d538b..110672da21 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -759,14 +759,16 @@ fn install(s: &mut Session, p: &Value) -> Result { // When the write failed, the old stored module stays in the folder and needs its grant at // the next launch. A grant that can't be removed still approves the old bytes, so the store // gets the module it held back. When that module can't be read or written back, or there - // was none, no module goes with the grant, and the grant stops counting for this session. A - // grants file that is too large or doesn't hold a list approves nothing, and the new module - // stays; one that can't be opened or read may approve the old bytes once it reads again. + // was none, no module goes with the grant, and the grant stops counting for every session in + // the process attached to the folder. A grants file that is too large or doesn't hold a list + // approves nothing, and the new module stays; one that can't be opened or read may approve + // the old bytes once it reads again. + let writes = s.grant_writes(); if stored && !s.store_grant(&m.id, None) && s.holds_plugin_grant(&m.id) { let back = previous.as_ref().is_ok_and(|held| s.put_back_plugin(plugin.id(), held.as_deref())); stored = !back; if !back || matches!(previous, Ok(None)) { - s.ignore_stored_grant(&m.id); + s.ignore_stored_grant(&m.id, writes); } } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 091348a6bd..7d71e8bfea 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -171,10 +171,6 @@ pub struct Session { /// The Scripts folder, whose scripts File › Scripts lists; `None`: no folder /// ([`Session::attach_scripts_dir`]). scripts_dir: Option, - /// The plug-in ids whose grant in the plug-in store should be gone but could not be removed: - /// [`Session::plugin_approver`] and a load of the store do not count those grants, and a successful - /// [`Session::store_grant`] for the id takes it out. - ignored_plugin_grants: std::sync::Mutex>, /// A filter's output that a run made in the background handed in for `plugin.run` to apply /// ([`cmd::plugin::FilterRun::hand_in`]); the next `plugin.run` takes it. plugin_output: Option, diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index 63fbcd5a78..3f45e8aeab 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -87,7 +87,6 @@ impl Session { color_settings_file: None, plugin_dir: None, scripts_dir: None, - ignored_plugin_grants: Default::default(), plugin_output: None, plugin_install_approval: None, plugin_grants_from_requests: false, diff --git a/crates/engine/src/store.rs b/crates/engine/src/store.rs index 01ebc46526..24d04230a1 100644 --- a/crates/engine/src/store.rs +++ b/crates/engine/src/store.rs @@ -88,6 +88,24 @@ static PLUGIN_DATA_LOCK: Mutex<()> = Mutex::new(()); /// The state of each plug-in folder's storage, shared by every storage made for the folder until /// the plug-in is removed, which closes it and drops it. static PLUGIN_DATA_OPEN: Mutex>> = Mutex::new(BTreeMap::new()); +/// The grants of plug-in stores that the sessions of the process don't count, and when each was +/// last written ([`IgnoredGrants`]). Another process reads `grants.json` as it is. +static IGNORED_PLUGIN_GRANTS: Mutex = Mutex::new(IgnoredGrants { ignored: BTreeSet::new(), written: BTreeMap::new(), writes: 0 }); +/// How many of the latest grant writes [`IgnoredGrants`] keeps. +const MAX_GRANT_WRITES_KEPT: usize = 256; + +/// Grants of plug-in stores, by the store's folder ([`plugin_store_key`]) and the plug-in id. +struct IgnoredGrants { + /// The grants that should be gone but could not be removed: no session in the process counts + /// them ([`Session::ignore_stored_grant`]) until one writes the store's grants for the id + /// ([`Session::store_grant`]). + ignored: BTreeSet<(PathBuf, String)>, + /// The latest grant writes of the process, at most [`MAX_GRANT_WRITES_KEPT`], each with the + /// count of writes it made ([`IgnoredGrants::writes`]). + written: BTreeMap<(PathBuf, String), u64>, + /// How many grant writes the sessions of the process have made ([`Session::grant_writes`]). + writes: u64, +} /// The file name (without extension) a stored item called `name` gets: its name made safe for any /// file system, then a hash of the name, so that names that differ only in characters a file name @@ -99,6 +117,37 @@ pub(crate) fn file_stem(name: &str) -> String { format!("{}-{hash:016x}", safe.trim()) } +/// The folder `dir` spelled one way for every path to it, so that sessions attached to it by +/// different paths share [`IGNORED_PLUGIN_GRANTS`]: the canonical path when the folder exists, else +/// the canonical path of its nearest folder that exists followed by the rest of the path, with `.` +/// and `..` taken out by name. The path as given when it has no absolute form (the current folder +/// can't be read). +pub(crate) fn plugin_store_key(dir: &Path) -> PathBuf { + if let Ok(path) = std::fs::canonicalize(dir) { + return path; + } + let Ok(absolute) = std::path::absolute(dir) else { return dir.to_path_buf() }; + let mut parts: Vec = Vec::new(); + for part in absolute.components() { + match part { + std::path::Component::CurDir => {} + std::path::Component::ParentDir => { + if matches!(parts.last(), Some(std::path::Component::Normal(_))) { + parts.pop(); + } + } + _ => parts.push(part), + } + } + for kept in (1..=parts.len()).rev() { + let base: PathBuf = parts[..kept].iter().collect(); + if let Ok(found) = std::fs::canonicalize(&base) { + return parts[kept..].iter().fold(found, |path, part| path.join(part)); + } + } + parts.iter().collect() +} + /// The regular files of `dir` with the extension `ext` (any case), by file name, at most /// [`MAX_STORE_FILES`]; none when the folder can't be read. fn list_files(dir: &Path, ext: &str) -> Vec { @@ -334,9 +383,6 @@ impl Session { let disabled = read_capped(&dir.join(DISABLED_FILE), safe_mode::MAX_DISABLED_FILE_BYTES).map(|b| safe_mode::parse_disabled_file(&b)).unwrap_or_default(); safe_mode::set_disabled(disabled); - if self.plugin_dir.as_ref() != Some(&dir) { - self.ignored_plugin_grants.lock().unwrap_or_else(|e| e.into_inner()).clear(); - } self.plugin_dir = Some(dir); self.load_stored_plugins(); } @@ -347,7 +393,8 @@ impl Session { /// there don't grant that very module ([`grant_covers`]), is skipped with a log record, and /// stays in the folder with its grant. A grant for an id whose stored module lists no /// permissions is forgotten; one that can't be removed from the grants file doesn't count for - /// the rest of the session ([`Session::plugin_approver`]). + /// any session in the process attached to the folder until the file can be written for the id + /// ([`Session::ignore_stored_grant`]). pub fn load_stored_plugins(&mut self) { let Some(dir) = self.plugin_dir.clone() else { return }; if safe_mode::active() { @@ -407,8 +454,11 @@ impl Session { Ok(file) => file.names(id), Err(e) => e.unread, }; - if named && !self.store_grant(id, None) && self.holds_plugin_grant(id) { - self.ignore_stored_grant(id); + if named { + let writes = self.grant_writes(); + if !self.store_grant(id, None) && self.holds_plugin_grant(id) { + self.ignore_stored_grant(id, writes); + } } } } @@ -469,12 +519,16 @@ impl Session { } } - /// The grants kept in the plug-in store `dir` ([`stored_grants`]) without those this session - /// doesn't count ([`Session::ignore_stored_grant`]). + /// The grants kept in the plug-in store `dir` ([`stored_grants`]) without those the process + /// doesn't count for that folder ([`Session::ignore_stored_grant`]). The folder's key is worked + /// out on each call, so a path whose link now leads elsewhere is matched with where it leads. fn counted_grants(&self, dir: &Path) -> BTreeMap { - let ignored = self.ignored_plugin_grants.lock().unwrap_or_else(|e| e.into_inner()); + let key = plugin_store_key(dir); let mut grants = stored_grants(dir); - grants.retain(|id, _| !ignored.contains(id)); + let shared = IGNORED_PLUGIN_GRANTS.lock().unwrap_or_else(|e| e.into_inner()); + for (_, id) in shared.ignored.range((key.clone(), String::new())..).take_while(|(folder, _)| *folder == key) { + grants.remove(id); + } grants } @@ -522,20 +576,53 @@ impl Session { /// be read or doesn't hold a list is left as it is, with a log record, and nothing is recorded; /// so is one whose new content would be larger than `MAX_GRANTS_FILE`. → whether the store now /// holds what was asked (`false` without a folder). When it does, the grant kept for `id` counts - /// again ([`Session::ignore_stored_grant`]). + /// again, for every session in the process attached to the store ([`Session::ignore_stored_grant`]). pub(crate) fn store_grant(&self, id: &str, grant: Option) -> bool { let done = self.write_grant(id, grant); - if done { - self.ignored_plugin_grants.lock().unwrap_or_else(|e| e.into_inner()).remove(id); + if done && let Some(key) = self.plugin_dir.as_deref().map(plugin_store_key) { + let mut shared = IGNORED_PLUGIN_GRANTS.lock().unwrap_or_else(|e| e.into_inner()); + let entry = (key, id.to_string()); + shared.ignored.remove(&entry); + shared.writes = shared.writes.saturating_add(1); + let writes = shared.writes; + shared.written.insert(entry, writes); + if shared.written.len() > MAX_GRANT_WRITES_KEPT + && let Some(oldest) = shared.written.iter().min_by_key(|(_, at)| **at).map(|(entry, _)| entry.clone()) + { + shared.written.remove(&oldest); + } } done } - /// Stop counting the store's grant for the plug-in `id` until [`Session::store_grant`] next - /// succeeds for it or another store is attached: for a grant that should be gone and could not - /// be removed, which would otherwise approve the bytes it covers. - pub(crate) fn ignore_stored_grant(&self, id: &str) { - self.ignored_plugin_grants.lock().unwrap_or_else(|e| e.into_inner()).insert(id.to_string()); + /// How many grant writes the sessions of the process have made ([`Session::store_grant`]). + /// Taken before a write that may fail, for [`Session::ignore_stored_grant`]. + pub(crate) fn grant_writes(&self) -> u64 { + IGNORED_PLUGIN_GRANTS.lock().unwrap_or_else(|e| e.into_inner()).writes + } + + /// How many grant writes the process keeps ([`IgnoredGrants::written`]). + #[cfg(test)] + pub(crate) fn grant_writes_kept() -> usize { + IGNORED_PLUGIN_GRANTS.lock().unwrap_or_else(|e| e.into_inner()).written.len() + } + + /// Stop counting the attached store's grant for the plug-in `id`, in every session in the + /// process attached to the same folder, until [`Session::store_grant`] next succeeds for it in + /// one of them: for a grant that should be gone and could not be removed, which would otherwise + /// approve the bytes it covers. `writes` is [`Session::grant_writes`] from before the failed + /// write: when a session has written the store's grants for `id` since, the file holds what that + /// write asked for, and the grant keeps counting. A write no longer among the latest + /// [`MAX_GRANT_WRITES_KEPT`] is not seen, and the grant stops counting. Another process on the + /// folder reads `grants.json` as it is. + pub(crate) fn ignore_stored_grant(&self, id: &str, writes: u64) { + if let Some(key) = self.plugin_dir.as_deref().map(plugin_store_key) { + let mut shared = IGNORED_PLUGIN_GRANTS.lock().unwrap_or_else(|e| e.into_inner()); + let entry = (key, id.to_string()); + if shared.written.get(&entry).is_none_or(|at| *at <= writes) { + shared.ignored.insert(entry); + } + } } fn write_grant(&self, id: &str, grant: Option) -> bool { diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index a4be6e554f..e73fee06d6 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -1816,11 +1816,11 @@ fn a_grant_that_cannot_be_removed_and_has_no_stored_module_stops_counting() { run(&mut s, "plugin.remove", json!({"id": id})); } -/// A grant that a load of the plug-in store should forget and can't stays out of the approvals for -/// the session: both when `grants.json` can't be rewritten and when it can't be read at the load. -/// A grant recorded later counts again, and another store starts with nothing ignored. +/// A grant that a load of the plug-in store should forget and can't stays out of the approvals in +/// the process: both when `grants.json` can't be rewritten and when it can't be read at the load. +/// A grant recorded later counts again, and another store's grant for the id counts. #[test] -fn a_grant_the_load_cannot_remove_stops_counting_for_the_session() { +fn a_grant_the_load_cannot_remove_stops_counting_in_the_process() { use vectorcraft_plugins::permissions::sha256_hex; let folder = crate::tests_links::Folder::new("plugin-store-load-forget"); let elsewhere = crate::tests_links::Folder::new("plugin-store-load-forget-other"); @@ -1852,8 +1852,12 @@ fn a_grant_the_load_cannot_remove_stops_counting_for_the_session() { next.attach_plugin_store(elsewhere.0.clone()); assert_eq!(run(&mut next, "plugin.inspect", needs_approval.clone())["needsApproval"], false, "that store's grant counts"); + // A load that can rewrite the file forgets the grant, and the store has nothing ignored again. + std::fs::write(&grants, &recorded).unwrap(); + next.attach_plugin_store(folder.0.clone()); + assert!(!grants.exists(), "the load removes the grant"); + // A directory at the file's path gives a read error on every platform. - std::fs::remove_file(&grants).unwrap(); std::fs::create_dir(&grants).unwrap(); vectorcraft_plugins::registry::remove(id); let mut third = session(); @@ -1879,6 +1883,180 @@ fn a_grant_the_load_cannot_remove_stops_counting_for_the_session() { run(&mut third, "plugin.remove", json!({"id": id})); } +/// A grant one session stops counting stops counting for every session in the process attached to +/// the same folder, by any path to it: one attached before, one attached after with no stored module +/// to try the removal for, and one that comes back from another folder. A successful write of the +/// grant in any of them makes it count in all of them again. +#[test] +fn an_ignored_stored_grant_is_shared_by_sessions_on_the_same_folder() { + use vectorcraft_plugins::permissions::sha256_hex; + let folder = crate::tests_links::Folder::new("plugin-store-shared-ignore"); + let elsewhere = crate::tests_links::Folder::new("plugin-store-shared-ignore-other"); + let id = "test.engine.permissions.sharedignore"; + let empty = json!({"objects": []}); + let approved = asking(id, r#"["doc.read"]"#, &empty); + let approved_data = vectorcraft_format::base64_encode(&approved); + let plain = asking(id, "null", &empty); + let needs_approval = json!({"dataBase64": approved_data}); + let grants = folder.0.join("grants.json"); + let entry = json!({"id": id, "hash": sha256_hex(&approved), "permissions": ["doc.read"]}); + let recorded = serde_json::to_vec(&json!([entry])).unwrap(); + std::fs::write(&grants, &recorded).unwrap(); + let mut first = session(); + first.attach_plugin_store(folder.0.clone()); + let inside = folder.0.join("inside"); + std::fs::create_dir(&inside).unwrap(); + let mut other = session(); + other.attach_plugin_store(inside.join("..")); + assert_eq!(run(&mut other, "plugin.inspect", needs_approval.clone())["needsApproval"], false, "the recorded grant counts"); + + // `1e9` is written back as `1000000000.0`, which makes the rewritten file larger than the 1 MiB that is read. + let full = format!(r#"[{entry},{{"n":[{}]}}]"#, vec!["1e9"; 200_000].join(",")).into_bytes(); + std::fs::write(&grants, &full).unwrap(); + run(&mut first, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&plain)})); + let file = first.stored_plugin_file(id).unwrap(); + assert!(!std::path::Path::new(&file).exists(), "the store keeps no module"); + assert_eq!(std::fs::read(&grants).unwrap(), full); + + assert_eq!(run(&mut other, "plugin.inspect", needs_approval.clone())["needsApproval"], true, "the other session doesn't count it"); + let refused = other.execute("plugin.install", &json!({"dataBase64": approved_data, "replace": true})).unwrap_err().to_string(); + assert!(refused.contains("doc.read"), "{refused}"); + std::fs::write(&file, &approved).unwrap(); + vectorcraft_plugins::registry::remove(id); + other.load_stored_plugins(); + assert!(vectorcraft_plugins::registry::get(id).is_none(), "the formerly approved module isn't loaded"); + std::fs::remove_file(&file).unwrap(); + + let mut later = session(); + later.attach_plugin_store(folder.0.clone()); + assert_eq!(run(&mut later, "plugin.inspect", needs_approval.clone())["needsApproval"], true, "a session attached later doesn't count it"); + + std::fs::write(elsewhere.0.join("grants.json"), &recorded).unwrap(); + other.attach_plugin_store(elsewhere.0.clone()); + assert_eq!(run(&mut other, "plugin.inspect", needs_approval.clone())["needsApproval"], false, "that store's grant counts"); + other.attach_plugin_store(folder.0.clone()); + assert_eq!(run(&mut other, "plugin.inspect", needs_approval.clone())["needsApproval"], true, "back on the folder, it doesn't"); + + std::fs::write(&grants, &recorded).unwrap(); + assert!(later.store_grant(id, Some(serde_json::from_value(entry).unwrap())), "the file can be written"); + for s in [&mut first, &mut other, &mut later] { + assert_eq!(run(s, "plugin.inspect", needs_approval.clone())["needsApproval"], false, "it counts again"); + } + std::fs::remove_file(&grants).unwrap(); +} + +/// A plug-in folder that doesn't exist yet gets the key it gets once it is made, and every path to a +/// folder gets the same key. +#[test] +fn a_plugin_folder_has_one_key_for_every_path_to_it() { + use crate::store::plugin_store_key; + let folder = crate::tests_links::Folder::new("plugin-store-key"); + let dir = folder.0.join("Plug-ins"); + let before = plugin_store_key(&dir); + assert_eq!(plugin_store_key(&dir.join("sub").join("..")), before); + assert_eq!(plugin_store_key(&folder.0.join(".").join("Plug-ins")), before); + std::fs::create_dir(&dir).unwrap(); + assert_eq!(plugin_store_key(&dir), before, "the key stays when the folder is made"); + assert_eq!(plugin_store_key(&dir.join(".")), before); + #[cfg(windows)] + { + let plain = std::path::absolute(&dir).unwrap(); + let verbatim = std::path::PathBuf::from(format!(r"\\?\{}", plain.display())); + assert_eq!(plugin_store_key(&verbatim), before, "a verbatim path names the same folder"); + } +} + +/// Sessions attached to a plug-in folder before it exists, by paths that only name the same folder +/// once it is made, share the grants the process doesn't count for it. A grant that one session +/// writes while another fails to remove it keeps counting. +#[test] +fn sessions_attached_before_the_folder_exists_share_its_ignored_grants() { + use vectorcraft_plugins::permissions::sha256_hex; + let folder = crate::tests_links::Folder::new("plugin-store-late-folder"); + let id = "test.engine.permissions.latefolder"; + let approved = asking(id, r#"["doc.read"]"#, &json!({"objects": []})); + let needs_approval = json!({"dataBase64": vectorcraft_format::base64_encode(&approved)}); + let entry = json!({"id": id, "hash": sha256_hex(&approved), "permissions": ["doc.read"]}); + std::fs::create_dir_all(folder.0.join("a").join("b")).unwrap(); + let dir = folder.0.join("a").join("Plug-ins"); + // On disk `link/..` is `a`, so `link/../Plug-ins` is the folder; by name it is a `Plug-ins` beside `a`. + // On Windows the folder is named in other letter case. + #[cfg(unix)] + let alias = { + std::os::unix::fs::symlink(folder.0.join("a").join("b"), folder.0.join("link")).unwrap(); + folder.0.join("link").join("..").join("Plug-ins") + }; + #[cfg(windows)] + let alias = folder.0.join("A").join("PLUG-INS"); + #[cfg(not(any(unix, windows)))] + let alias = dir.clone(); + let mut first = session(); + first.attach_plugin_store(dir.clone()); + let mut other = session(); + other.attach_plugin_store(alias); + std::fs::create_dir(&dir).unwrap(); + std::fs::write(dir.join("grants.json"), serde_json::to_vec(&json!([entry])).unwrap()).unwrap(); + assert_eq!(run(&mut other, "plugin.inspect", needs_approval.clone())["needsApproval"], false, "the recorded grant counts"); + + first.ignore_stored_grant(id, first.grant_writes()); + assert_eq!(run(&mut other, "plugin.inspect", needs_approval.clone())["needsApproval"], true, "the other session doesn't count it"); + assert!(other.store_grant(id, Some(serde_json::from_value(entry.clone()).unwrap())), "the file can be written"); + assert_eq!(run(&mut first, "plugin.inspect", needs_approval.clone())["needsApproval"], false, "it counts again"); + + // A write that fails in one session while another writes the grant for the id leaves the grant + // counting. + let before = first.grant_writes(); + assert!(other.store_grant(id, Some(serde_json::from_value(entry).unwrap())), "the other session writes the grant"); + first.ignore_stored_grant(id, before); + assert_eq!(run(&mut first, "plugin.inspect", needs_approval)["needsApproval"], false, "the written grant counts"); + std::fs::remove_file(dir.join("grants.json")).unwrap(); +} + +/// Grant writes that change nothing, each for another id, don't grow what the process keeps past its +/// limit. +#[test] +fn the_process_keeps_a_bounded_number_of_grant_writes() { + let folder = crate::tests_links::Folder::new("plugin-store-writes-kept"); + let mut s = session(); + s.attach_plugin_store(folder.0.clone()); + for n in 0..300 { + assert!(s.store_grant(&format!("test.engine.permissions.unknown{n}"), None), "nothing to remove is a success"); + } + assert!(Session::grant_writes_kept() <= 256, "{}", Session::grant_writes_kept()); +} + +/// A session attached by a link to a plug-in folder matches the grants the process doesn't count +/// with the folder the link leads to now, not the one it led to when the session was attached. +#[cfg(unix)] +#[test] +fn a_linked_plugin_folder_uses_the_ignored_grants_of_where_the_link_leads() { + use vectorcraft_plugins::permissions::sha256_hex; + let folder = crate::tests_links::Folder::new("plugin-store-relinked"); + let id = "test.engine.permissions.relinked"; + let approved = asking(id, r#"["doc.read"]"#, &json!({"objects": []})); + let needs_approval = json!({"dataBase64": vectorcraft_format::base64_encode(&approved)}); + let entry = json!({"id": id, "hash": sha256_hex(&approved), "permissions": ["doc.read"]}); + let recorded = serde_json::to_vec(&json!([entry])).unwrap(); + let (old, new, link) = (folder.0.join("old"), folder.0.join("new"), folder.0.join("Plug-ins")); + for dir in [&old, &new] { + std::fs::create_dir(dir).unwrap(); + std::fs::write(dir.join("grants.json"), &recorded).unwrap(); + } + std::os::unix::fs::symlink(&old, &link).unwrap(); + let mut linked = session(); + linked.attach_plugin_store(link.clone()); + assert_eq!(run(&mut linked, "plugin.inspect", needs_approval.clone())["needsApproval"], false, "the recorded grant counts"); + + std::fs::remove_file(&link).unwrap(); + std::os::unix::fs::symlink(&new, &link).unwrap(); + let mut direct = session(); + direct.attach_plugin_store(new.clone()); + direct.ignore_stored_grant(id, direct.grant_writes()); + assert_eq!(run(&mut linked, "plugin.inspect", needs_approval.clone())["needsApproval"], true, "the link leads to the ignored grant"); + assert!(linked.store_grant(id, Some(serde_json::from_value(entry).unwrap())), "the file can be written"); + assert_eq!(run(&mut direct, "plugin.inspect", needs_approval)["needsApproval"], false, "it counts again"); +} + /// A grants file that can't be opened or read may name the old module once it reads again, so /// neither a module listing no permissions nor `plugin.remove` gets past it. A directory at the /// file's path gives a read error on every platform. diff --git a/docs/plugins.md b/docs/plugins.md index 06fd89f28a..c21e677083 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -248,14 +248,16 @@ capability model proposed for PhotoCraft plug-ins write the module to the `Plug-ins` folder, or can't remove the grant from `grants.json`, the folder keeps the module it held and `grants.json` keeps the grant. The new module runs until the session ends. When the grant can't be removed and the module already stored can't be read back or written back, the new module stays in the folder, and - the grant does not count for the rest of the session. The grant also does not count when the folder held no module - for that id. A + the grant stops counting. The grant also stops counting when the folder held no module for that id. A `grants.json` larger than 1 MiB, or one that is not a JSON list, grants nothing: the new module stays in the folder, and `plugin.remove` removes the plug-in. A `grants.json` that can't be opened or read counts as a grant that can't be removed. When VectorCraft loads the `Plug-ins` folder, it forgets any grant for an id whose stored module lists no - permissions. When it can't remove that grant from `grants.json`, or can't read the file, the grant does not count for - the rest of the session, or until the plug-in is granted again, including for the next load, which tries the removal - again. In Safe Mode, and for + permissions. When it can't remove that grant from `grants.json`, or can't read the file, the grant stops counting, + including for the next load, which tries the removal again. A grant that stops counting in either of these cases + does not count for any session in the same process that uses the same `Plug-ins` folder, by any path to it, until + `grants.json` can be written for that plug-in, as when the plug-in is granted again. Another process on the same + folder, for example `vectorcraft-cli` beside the app, still reads `grants.json` as it is written. A grant for the + same id kept in another `Plug-ins` folder counts. In Safe Mode, and for a stored module that the user disabled, VectorCraft does not read the module, and `grants.json` keeps the grant for its id. The Additional Plug-ins Folder and `plugin.reload` do not change the `Plug-ins` folder: when they leave a module that lists no permissions under an diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b4c949f742..9902401856 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1659,6 +1659,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Plug-in grants cover one module (G17):** a grant counts only for the module with the id and SHA-256 it records. A module that lists permissions and has other bytes under the same id needs a new approval, even with the same permissions: `plugin.inspect` reports `needsApproval`, `plugin.install` refuses it without `grant`, Install Plug-in… and File › Open ask again, and the plug-in store and plug-in folders skip it. A module with a missing or empty permissions list needs no grant; without the list, it gets its kind's implied permissions. A grant with a malformed hash covers nothing. Regressions: `crates/plugins/src/permissions.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. - **Plug-in approval needs a person (G17):** a plug-in's permissions and heavy budget are approved in a dialog that only a person can confirm: the control channel, MCP and injected input are refused, and `plugin.install` takes `grant` or `allowHeavy` only with that approval. `vectorcraft-cli mcp` and `run` refuse them unless started with `--allow-plugin-grants`. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/automation.rs`, `apps/vectorcraft-cli/tests/it/cli.rs`. Scores and hours unchanged. - **A plug-in with no permissions ends its id's grant (G17):** installing a module that lists no permissions under a granted id forgets the grant, in the process and in `grants.json`, so the formerly approved bytes need a new approval. Attaching a `Plug-ins` folder whose module for that id lists no permissions forgets it too, and plug-in folders forget the process's grant. A refused install leaves the grant. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_safe_mode.rs`. Scores and hours unchanged. +- **A grant that can't be forgotten stops counting in the whole process (G17):** when such a grant can't be removed from `grants.json`, no session in the process that uses the same `Plug-ins` folder, by any path to it, counts it for `plugin.inspect`, `plugin.install` or loading plug-ins, until `grants.json` can be written for that plug-in; another process on the folder reads `grants.json` as it is. Regressions: `crates/engine/src/tests/plugins.rs`. Scores and hours unchanged. - **Plug-in commands (G17):** a filter or live effect may declare up to 32 `commands` (`{id, label, params?}`); each is the command `plugin..` while the plug-in is installed, in a submenu named after the plug-in under Object › Plug-ins (Effect › Plug-ins for a live effect) whatever menu the manifest names, with its label escaped. Commands are recorded in actions, listed in Keyboard Shortcuts for the user to give a shortcut, and run through `run` and MCP with their parameters checked first (a command with parameters opens a dialog from its menu item or shortcut). A run uses the plug-in's permissions (a live effect with commands may also hold `doc.write` and `state`, and gets them without a `permissions` list), is one undo step named after the command, and on failure (a trap or an exhausted instruction budget) leaves the document and the undo history as they were with an error naming the plug-in and the command. For a command and for `plugin.run`, an `ids` that is not a list of object ids is an error rather than the selection. A malformed entry is refused by name when the module is installed. Regressions: `crates/plugins/src/manifest.rs`, `crates/plugins/src/permissions.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. - **Filter plug-ins call commands (G17, #2186):** a filter at plug-in API level 2 with the `commands` permission names the commands it may run in its manifest's `commandAllowlist` and runs them with `env.vc_call`, reading each result with `env.vc_call_result`. Each call sees what the calls before it changed; the calls and the filter's output are one undo step, and a run that fails, is canceled or makes a call that fails leaves every document, its selection and its history as they were. Commands that read or write files, use the clipboard, change preferences or the view, run scripts, actions or plug-ins, switch documents or undo are never called, and a manifest that lists one is refused when the module is installed. The install dialog lists the allowed commands. A run makes at most 1,000 calls, and each call costs 10,000 instructions of its budget. Regressions: `crates/plugins/tests/call.rs`, `crates/engine/tests/plugin_call.rs`, `crates/engine/src/cmd/plugin_call.rs`. Scores and hours unchanged. - **Plug-in progress, Cancel and log lines (G17):** at plug-in API level 2 a plug-in with the `progress` permission imports `env.vc_progress`, `env.vc_cancelled` and `env.vc_log`. A heavy filter's and an export's progress windows show its progress as a percentage bar, their Cancel reaches it through `vc_cancelled`, and its log lines go to the app's log with its id, capped per line and per run. Installing checks every import and names the one it refuses. Regressions: `crates/plugins/src/host.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged. From 386c444ff47c11d44c3bf57900ade8de09250d96 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 17:31:25 -0800 Subject: [PATCH 016/100] MCP: a readable library id written with LOADED/ keeps its text on Windows The loaded/ prefix is matched in any case where the ids and paths are. --- crates/ui-egui/src/control.rs | 3 ++- crates/ui-egui/src/control/tests.rs | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 066d3d82db..a198810b41 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -734,8 +734,9 @@ fn begins_known_id(text: &str, at: usize, end: usize, r: &Rewrite) -> bool { let rest = &text[at..]; let same = |a: &str, b: &str| if r.fold { a.eq_ignore_ascii_case(b) } else { a == b }; let dotted = |id: &str| id.split(['/', '\\']).any(|part| part == "." || part == ".."); + let prefixed = rest.get(.."loaded/".len()).is_some_and(|head| same(head, "loaded/")); r.hiding.readable.iter().filter(|known| !dotted(known)).any(|known| { - let id = if rest.starts_with("loaded/") { known.as_str() } else { known.strip_prefix("loaded/").unwrap_or(known) }; + let id = if prefixed { known.as_str() } else { known.strip_prefix("loaded/").unwrap_or(known) }; id.len() > end - at && rest.get(..id.len()).is_some_and(|head| same(head, id)) && !continues_path(&rest[id.len()..]) }) } diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index 800a57c795..efce2e7a47 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -105,6 +105,11 @@ fn a_reply_hides_a_path_in_any_case_and_escaped() { let mut reply = json!({"error": "x /libs/B.svg y /libs/b.svg z /LIBS/b.svg"}); super::replace_in_value(&mut reply, &super::Rewrite { pairs: super::hidden_text(&two.hidden), hiding: &two, fold: true }); assert_eq!(reply["error"], "x B.svg y b.svg z b.svg", "{reply}"); + // A readable id that begins with a hidden one stays whole in any case, `loaded/` included. + let nested = hiding(&[("loaded//libs/in", "loaded/in")], &["loaded//libs/in/a.svg"]); + let mut reply = json!({"error": "LOADED//LIBS/IN/A.SVG"}); + super::replace_in_value(&mut reply, &super::Rewrite { pairs: super::hidden_text(&nested.hidden), hiding: &nested, fold: true }); + assert_eq!(reply["error"], "LOADED//LIBS/IN/A.SVG", "{reply}"); let mut reply = json!({"error": "file:///C:/Libs/My%20Swatches/b.svg"}); hide_under_a_root("escaped", &mut reply, &hidden); assert_eq!(reply["error"], "file:///b.svg", "{reply}"); From a8989aff6dc8bdcc64fce808e1d10dfd99b18823 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 18:22:01 -0800 Subject: [PATCH 017/100] Commands: app and control channel params of the wrong type are refused view.setZoom, view.screenMode, window.panel, file.revert, file.close, ui.newSwatch, ui.symbolOptions, ui.batchDialog and file.exportSelection read a number or switch of the wrong type as its default and went on, so `view.setZoom {"zoom": "200"}` set 100%. They now refuse it and change nothing. The control channel's ui.key, ui.move, ui.wheel, ui.click, ui.drag, ui.pointer, ui.resize, ui.screenshot, ui.render and ui.set, and the headless session's key, pointer and render calls, do the same for their numbers, switches and mods, before any input is queued. Omitting a param or giving null keeps its default. --- crates/mcp/src/headless.rs | 38 +++++++-- crates/mcp/src/tests.rs | 28 +++++++ crates/ui-egui/src/control.rs | 125 +++++++++++++++++++++------- crates/ui-egui/src/control/tests.rs | 86 +++++++++++++++++++ crates/ui-egui/src/menus.rs | 2 +- crates/ui-egui/src/menus/run.rs | 82 ++++++++++++++---- docs/mcp.md | 13 +++ 7 files changed, 317 insertions(+), 57 deletions(-) diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 00e914ddba..0377cd5ae1 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -56,6 +56,24 @@ fn s<'a>(p: &'a Value, k: &str) -> Option<&'a str> { p.get(k).and_then(Value::as_str) } +/// An optional param read with `read`: none when it is omitted or null, else an error naming the +/// key when `read` gets nothing from its value. +fn opt(p: &Value, k: &str, read: impl Fn(&Value) -> Option, what: &str) -> Result, String> { + match p.get(k) { + None | Some(Value::Null) => Ok(None), + Some(v) => read(v).map(Some).ok_or_else(|| format!("`{k}` must be {what}")), + } +} + +fn opt_bool(p: &Value, k: &str) -> Result, String> { + opt(p, k, Value::as_bool, "true or false") +} + +/// A pointer packet's `mods`: none when omitted or null, else an object of booleans. +fn packet_mods(p: &Value) -> Result, String> { + opt(p, "mods", |m| serde_json::from_value(m.clone()).ok(), "an object of true or false values") +} + pub(crate) fn pointer_kind(k: &str) -> Option { Some(match k { "down" => PointerKind::Down, @@ -210,12 +228,13 @@ impl Headless { fn pointer(&mut self, p: &Value) -> Result { let events = p.get("events").and_then(Value::as_array).ok_or("missing `events`")?; - let base: Mods = p.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or_default(); + let base = packet_mods(p)?.unwrap_or_default(); let default = PointerEvent::json_backquote(p, false)?; let held = events.iter().map(|e| PointerEvent::json_backquote(e, default)).collect::, _>>()?; + let mods = events.iter().map(|e| Ok(packet_mods(e)?.unwrap_or(base))).collect::, String>>()?; self.ensure_document(); let mut requests = vec![]; - for (e, backquote) in events.iter().zip(held) { + for ((e, backquote), mods) in events.iter().zip(held).zip(mods) { let kind = s(e, "kind").unwrap_or(""); let kind = pointer_kind(kind).ok_or_else(|| format!("unknown pointer kind `{kind}` (down|drag|up|move|doubleclick)"))?; if s(e, "space") == Some("screen") { @@ -223,7 +242,6 @@ impl Headless { } let x = e.get("x").and_then(Value::as_f64).ok_or("pointer event needs numeric `x`")?; let y = e.get("y").and_then(Value::as_f64).ok_or("pointer event needs numeric `y`")?; - let mods = e.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or(base); let ev = PointerEvent { kind, pos: Point::new(x, y), @@ -253,8 +271,8 @@ impl Headless { fn key(&mut self, p: &Value) -> Result { let key = s(p, "key").ok_or("missing `key`")?; - let b = |n: &str| p.get(n).and_then(Value::as_bool).unwrap_or(false); - let mods = Mods { shift: b("shift"), alt: b("alt"), cmd: b("cmd"), ctrl: b("ctrl"), ..Mods::default() }; + let b = |n: &str| opt_bool(p, n).map(|on| on.unwrap_or(false)); + let mods = Mods { shift: b("shift")?, alt: b("alt")?, cmd: b("cmd")?, ctrl: b("ctrl")?, ..Mods::default() }; // A letter reaches the tool only when the tool claims it without Cmd, Ctrl or Alt (E while // a selection marquee is dragged); otherwise it is a shortcut. let mut tk = tool_key(key); @@ -296,16 +314,18 @@ impl Headless { } fn render(&mut self, p: &Value) -> Result { + let idx = opt(p, "artboard", |v| v.as_u64().and_then(|i| usize::try_from(i).ok()), "a whole number of 0 or more")?.unwrap_or(0); + let scale = opt(p, "scale", Value::as_f64, "a number")?.unwrap_or(1.0).clamp(0.01, 16.0); + let trace_views = opt_bool(p, "traceViews")? == Some(true); + let data = opt_bool(p, "data")? == Some(true); let st = self.session.active().ok_or("no document")?; let doc = st.doc.clone(); - let idx = p.get("artboard").and_then(Value::as_u64).unwrap_or(0) as usize; let r = doc.artboards.get(idx).map(|a| a.rect).ok_or("no such artboard")?; - let scale = p.get("scale").and_then(Value::as_f64).unwrap_or(1.0).clamp(0.01, 16.0); vectorcraft_render::raster_size(r, scale)?; // Rendered with the options of a PNG export on white, so its pixels are the exported file's. // `traceViews: true` draws Image Trace objects as their View shows them, as the canvas does. let mut opts = RasterExportOptions { background: Some([255; 3]), ..Default::default() }.render_options(RasterFormat::Png); - opts.trace_views = p.get("traceViews").and_then(Value::as_bool) == Some(true); + opts.trace_views = trace_views; let img = self.renderer.render_region_with(&doc, r, scale, &opts); // A cancelled render stops drawing: the picture is not whole and is not written. vectorcraft_engine::cancel::check().map_err(|e| e.to_string())?; @@ -317,7 +337,7 @@ impl Headless { vectorcraft_format::write_atomic(std::path::Path::new(path), &png).map_err(|e| format!("write {path}: {e}"))?; out["path"] = json!(path); // `data: true` asks for the image as well as the file. - if p.get("data").and_then(Value::as_bool) == Some(true) { + if data { out["pngBase64"] = json!(vectorcraft_format::base64_encode(&png)); } } diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index d7de8dd7fb..dbe429a281 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -1452,6 +1452,34 @@ fn headless_backend_methods() { assert!(h.call("ui.resize", json!({})).is_err()); } +/// The headless key, pointer and screenshot calls refuse a value of the wrong type and change +/// nothing; omitting it or giving null keeps its default. +#[test] +fn headless_methods_refuse_params_of_the_wrong_type() { + let mut h = Headless::new(); + h.call("engine.execute", json!({"command": "file.new", "params": {"width": 300, "height": 200}})).unwrap(); + h.call("ui.tool.select", json!({"tool": "rectangle"})).unwrap(); + let before = h.call("document.json", json!({})).unwrap(); + let drag = |mods: Value| json!({"events": [{"kind": "down", "x": 10, "y": 10}, {"kind": "drag", "x": 60, "y": 60, "mods": mods}, {"kind": "up", "x": 60, "y": 60}]}); + for (method, params, key) in [ + ("ui.render", json!({"artboard": "0"}), "artboard"), + ("ui.render", json!({"scale": "2"}), "scale"), + ("ui.render", json!({"traceViews": 1}), "traceViews"), + ("ui.screenshot", json!({"data": "yes", "path": "x.png"}), "data"), + ("ui.key", json!({"key": "Delete", "shift": "true"}), "shift"), + ("ui.pointer", drag(json!({"shift": "yes"})), "mods"), + ("ui.pointer", json!({"mods": 1, "events": []}), "mods"), + ] { + let e = h.call(method, params.clone()).unwrap_err(); + assert!(e.contains(&format!("`{key}`")), "{method} {params}: {e}"); + } + assert_eq!(h.call("document.json", json!({})).unwrap(), before, "a refused call changes nothing"); + let r = h.call("ui.render", json!({"artboard": null, "scale": null, "traceViews": null})).unwrap(); + assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(300), Some(200))); + h.call("ui.pointer", drag(Value::Null)).unwrap(); + assert_ne!(h.call("document.json", json!({})).unwrap(), before, "null mods keep the packet's"); +} + // ---------- workflow tools ---------- fn id_of(result: &Value) -> u64 { diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index b02c3b6e1d..77e931bdc1 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -494,9 +494,23 @@ fn key_from(name: &str) -> Option { /// The modifier keys a request holds down (`cmd`, `ctrl`, `alt`, `shift`; `cmd` is Command on /// macOS, Ctrl elsewhere). -fn modifiers(p: &Value) -> egui::Modifiers { - let b = |n: &str| p.get(n).and_then(Value::as_bool).unwrap_or(false); - egui::Modifiers { alt: b("alt"), ctrl: b("ctrl"), shift: b("shift"), mac_cmd: b("cmd") && cfg!(target_os = "macos"), command: b("cmd") } +fn modifiers(p: &Value) -> Result { + let b = |n: &str| crate::menus::opt_bool(p, n).map(|on| on.unwrap_or(false)); + let cmd = b("cmd")?; + Ok(egui::Modifiers { alt: b("alt")?, ctrl: b("ctrl")?, shift: b("shift")?, mac_cmd: cmd && cfg!(target_os = "macos"), command: cmd }) +} + +/// An optional number param of a control request as `f32`, `default` when it is omitted or null. +fn coord(p: &Value, key: &str, default: f32) -> Result { + crate::menus::opt_number(p, key).map(|n| n.map_or(default, |n| n as f32)) +} + +/// A pointer packet's `mods`: none when omitted or null, else an object of booleans. +fn packet_mods(value: Option<&Value>) -> Result, String> { + match value { + None | Some(Value::Null) => Ok(None), + Some(m) => serde_json::from_value(m.clone()).map(Some).map_err(|e| format!("`mods` must be an object of true or false values: {e}")), + } } pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { @@ -532,10 +546,13 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques "ui.pointer" => pointer_events(app, ctx, req), "ui.key" => { let Some(k) = s("key").and_then(key_from) else { return err("unknown or missing `key`") }; + let m = match modifiers(p) { + Ok(m) => m, + Err(e) => return err(e), + }; if let Err(e) = reserve_synthetic(app, 2 + usize::from(s("text").is_some())) { return err(e); } - let m = modifiers(p); app.synthetic.push(egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: m }); // Typed text comes with the press, so it shares the key's frame and modifiers. if let Some(t) = s("text") { @@ -546,31 +563,39 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques ok(Value::Null) } "ui.move" => { + let pos = match coord(p, "x", 0.0).and_then(|x| Ok(egui::pos2(x, coord(p, "y", 0.0)?))) { + Ok(pos) => pos, + Err(e) => return err(e), + }; if let Err(e) = reserve_synthetic(app, 1) { return err(e); } - let pos = egui::pos2(p.get("x").and_then(Value::as_f64).unwrap_or(0.0) as f32, p.get("y").and_then(Value::as_f64).unwrap_or(0.0) as f32); app.synthetic.push(egui::Event::PointerMoved(pos)); ctx.request_repaint(); ok(Value::Null) } "ui.click" | "ui.drag" => synthetic_click(app, ctx, req), "ui.wheel" => { + // Mouse wheel notches over screen point (x, y): `dy` up (+) or down, `dx` sideways + // (`unit: "point"` for trackpad-like points), with modifiers as `ui.click` takes them. + let given = + (|| Ok::<_, String>(([coord(p, "dx", 0.0)?, coord(p, "dy", 0.0)?, coord(p, "x", 0.0)?, coord(p, "y", 0.0)?], modifiers(p)?)))(); + let ([dx, dy, x, y], modifiers) = match given { + Ok(given) => given, + Err(e) => return err(e), + }; if let Err(e) = reserve_synthetic(app, 2) { return err(e); } - // Mouse wheel notches over screen point (x, y): `dy` up (+) or down, `dx` sideways - // (`unit: "point"` for trackpad-like points), with modifiers as `ui.click` takes them. - let f = |k: &str| p.get(k).and_then(Value::as_f64).unwrap_or(0.0) as f32; let unit = if s("unit") == Some("point") { egui::MouseWheelUnit::Point } else { egui::MouseWheelUnit::Line }; // Notches, not a flood: a turn stays within what a real wheel sends. - let delta = egui::vec2(f("dx"), f("dy")).clamp(egui::Vec2::splat(-100.0), egui::Vec2::splat(100.0)); - app.synthetic.push(egui::Event::PointerMoved(egui::pos2(f("x"), f("y")))); + let delta = egui::vec2(dx, dy).clamp(egui::Vec2::splat(-100.0), egui::Vec2::splat(100.0)); + app.synthetic.push(egui::Event::PointerMoved(egui::pos2(x, y))); app.synthetic.push(egui::Event::MouseWheel { unit, delta, phase: egui::TouchPhase::Move, - modifiers: modifiers(p), + modifiers, source: egui::MouseWheelSource::Unknown, }); ctx.request_repaint(); @@ -635,8 +660,10 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques ok(Value::Null) } "ui.resize" => { - let w = p.get("width").and_then(Value::as_f64).unwrap_or(1440.0) as f32; - let h = p.get("height").and_then(Value::as_f64).unwrap_or(900.0) as f32; + let (w, h) = match coord(p, "width", 1440.0).and_then(|w| Ok((w, coord(p, "height", 900.0)?))) { + Ok(size) => size, + Err(e) => return err(e), + }; ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(w, h))); ok(Value::Null) } @@ -649,32 +676,48 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques if let Some(Err(e)) = s("path").map(vectorcraft_engine::file_access::check_write) { return err(e); } - if p.get("focus").and_then(Value::as_bool).unwrap_or(true) { + let flags = crate::menus::opt_bool(p, "focus").and_then(|focus| Ok((focus, crate::menus::opt_bool(p, "data")?))); + let (focus, data) = match flags { + Ok(flags) => flags, + Err(e) => return err(e), + }; + if focus.unwrap_or(true) { ctx.send_viewport_cmd(egui::ViewportCommand::Focus); } ctx.request_repaint(); - Outcome::Screenshot { path: s("path").map(str::to_string), data: p.get("data").and_then(Value::as_bool) == Some(true) } + Outcome::Screenshot { path: s("path").map(str::to_string), data: data == Some(true) } } "ui.render" => { + let given = (|| { + Ok::<_, String>(( + crate::menus::opt_index(p, "artboard")?, + crate::menus::opt_number(p, "scale")?, + crate::menus::opt_bool(p, "traceViews")?, + crate::menus::opt_bool(p, "data")?, + )) + })(); + let (index, scale, trace_views, data) = match given { + Ok(given) => given, + Err(e) => return err(e), + }; let Some(st) = app.session.active() else { return err("no document") }; let doc = st.doc.clone(); // The artboard asked for (0-based, as headless), else the first. - let index = p.get("artboard").and_then(Value::as_u64).map_or(Some(0), |i| usize::try_from(i).ok()); - let Some(r) = index.and_then(|i| doc.artboards.get(i)).map(|a| a.rect) else { return err("no such artboard") }; - let scale = p.get("scale").and_then(Value::as_f64).unwrap_or(1.0); + let Some(r) = doc.artboards.get(index.unwrap_or(0)).map(|a| a.rect) else { return err("no such artboard") }; + let scale = scale.unwrap_or(1.0); if let Err(e) = vectorcraft_render::raster_size(r, scale) { return err(e); } // Rendered with the options of a PNG export on white, so its pixels are the exported file's. // `traceViews: true` draws Image Trace objects as their View shows them, as the canvas does. let mut opts = RasterExportOptions { background: Some([255; 3]), ..Default::default() }.render_options(RasterFormat::Png); - opts.trace_views = p.get("traceViews").and_then(Value::as_bool) == Some(true); + opts.trace_views = trace_views == Some(true); let img = app.canvas.renderer.render_region_with(&doc, r, scale, &opts); let png = match img.to_png() { Ok(png) => png, Err(e) => return err(e), }; - let data = p.get("data").and_then(Value::as_bool) == Some(true); + let data = data == Some(true); match s("path") { Some(path) => match app.services.write.as_mut() { Some(w) => wrap( @@ -926,7 +969,21 @@ fn pointer_events_inner(app: &mut VectorcraftApp, ctx: &egui::Context, req: &Con if rotating && let Err(error) = validate_rotate_view_events(p, events) { return err(error); } - let base_mods: Mods = p.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or_default(); + // Every event is read before any is sent, so a malformed packet sends none of them. + let read = packet_mods(p.get("mods")).and_then(|base| { + let base = base.unwrap_or_default(); + events + .iter() + .map(|e| { + let at = |k: &str| crate::menus::opt_number(e, k).map(|n| n.unwrap_or(0.0)); + Ok((at("x")?, at("y")?, packet_mods(e.get("mods"))?.unwrap_or(base))) + }) + .collect::, String>>() + }); + let read = match read { + Ok(read) => read, + Err(e) => return err(e), + }; let view = app.view_info(); let xf = app.canvas_rect.zip(app.view().copied()).map(|(rect, v)| Xf::new(rect, &v)); let from = app.session.input_count(); @@ -936,7 +993,7 @@ fn pointer_events_inner(app: &mut VectorcraftApp, ctx: &egui::Context, req: &Con Ok(held) => held, Err(e) => return err(e), }; - for (e, backquote) in events.iter().zip(held) { + for ((e, backquote), (x, y, mods)) in events.iter().zip(held).zip(read) { let kind = match e.get("kind").and_then(Value::as_str).unwrap_or("") { "down" => PointerKind::Down, "drag" => PointerKind::Drag, @@ -945,10 +1002,7 @@ fn pointer_events_inner(app: &mut VectorcraftApp, ctx: &egui::Context, req: &Con "doubleclick" | "dblclick" => PointerKind::DoubleClick, other => return err(format!("unknown pointer kind `{other}`")), }; - let x = e.get("x").and_then(Value::as_f64).unwrap_or(0.0); - let y = e.get("y").and_then(Value::as_f64).unwrap_or(0.0); let screen = e.get("space").and_then(Value::as_str) == Some("screen"); - let mods = e.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or(base_mods); if rotating { if let Err(e) = rotate_view_pointer(app, kind, vectorcraft_geom::Point::new(x, y), screen, mods.shift) { return err(e); @@ -1007,18 +1061,24 @@ fn synthetic_click(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlR let Some(events) = count.checked_mul(2).and_then(|n| n.checked_add(steps)).and_then(|n| n.checked_add(1)) else { return err("synthetic input event count is out of range"); }; + // Screen-space (egui points) pointer input through egui itself: reaches every widget. + let drag = req.method == "ui.drag"; + let given = (|| { + let a = egui::pos2(coord(p, "x", 0.0)?, coord(p, "y", 0.0)?); + let end = if drag { egui::pos2(coord(p, "toX", 0.0)?, coord(p, "toY", 0.0)?) } else { a }; + Ok::<_, String>((a, end, modifiers(p)?)) + })(); + let (a, end, modifiers) = match given { + Ok(given) => given, + Err(e) => return err(e), + }; if let Err(e) = reserve_synthetic(app, events) { return err(e); } - // Screen-space (egui points) pointer input through egui itself: reaches every widget. - let f = |k: &str| p.get(k).and_then(Value::as_f64).unwrap_or(0.0) as f32; let button = match s("button") { Some("right") | Some("secondary") => egui::PointerButton::Secondary, _ => egui::PointerButton::Primary, }; - let modifiers = modifiers(p); - let a = egui::pos2(f("x"), f("y")); - let end = if req.method == "ui.drag" { egui::pos2(f("toX"), f("toY")) } else { a }; app.synthetic.push(egui::Event::PointerMoved(a)); app.synthetic.push(egui::Event::PointerButton { pos: a, button, pressed: true, modifiers }); if req.method == "ui.drag" { @@ -1040,6 +1100,11 @@ fn synthetic_click(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlR fn set_ui(app: &mut VectorcraftApp, req: &ControlRequest) -> Outcome { let p = &req.params; let s = |k: &str| p.get(k).and_then(Value::as_str); + // A flag of the wrong type is refused before anything is set. + let flags = ["rulers", "outline", "grid", "smartGuides", "boundingBox", "controlBar", "imageTracePreview"]; + if let Some(e) = flags.iter().find_map(|k| crate::menus::opt_bool(p, k).err()) { + return err(e); + } // Outline is a setting of the active document's view: without one, nothing is set. if p.get("outline").is_some_and(Value::is_boolean) && app.session.active().is_none() { return err("no document"); diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index 0a9c042a1e..2cdf13a4d7 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -314,6 +314,92 @@ fn render_takes_the_artboard_asked_for() { assert_eq!(got, size.map(Some), "{params}: {r}"); } assert!(request("ui.render", json!({"artboard": 2}))["error"].is_string()); + for (params, key) in [ + (json!({"artboard": "1"}), "artboard"), + (json!({"artboard": -1}), "artboard"), + (json!({"scale": "0.5"}), "scale"), + (json!({"traceViews": 1}), "traceViews"), + (json!({"data": "yes"}), "data"), + ] { + let r = request("ui.render", params.clone()); + assert!(r["error"].as_str().is_some_and(|e| e.contains(&format!("`{key}`"))), "{params}: {r}"); + } + let r = request("ui.render", json!({"artboard": null, "scale": null})); + assert_eq!([&r["result"]["width"], &r["result"]["height"]].map(Value::as_u64), [Some(64), Some(64)], "null keeps the default: {r}"); +} + +/// The control channel's input methods refuse a number or switch of the wrong type before they +/// queue any event; omitting it keeps its default. +#[test] +fn control_input_refuses_params_of_the_wrong_type() { + let move_to = |mods: Value| json!({"events": [{"kind": "move", "x": 5, "y": 5}, {"kind": "move", "x": 6, "y": 6, "mods": mods}]}); + for (method, params, key) in [ + ("ui.move", json!({"x": "10"}), "x"), + ("ui.move", json!({"y": true}), "y"), + ("ui.wheel", json!({"dy": "1"}), "dy"), + ("ui.wheel", json!({"dy": 1, "shift": "yes"}), "shift"), + ("ui.click", json!({"x": [1]}), "x"), + ("ui.click", json!({"cmd": 1}), "cmd"), + ("ui.drag", json!({"toX": "5"}), "toX"), + ("ui.key", json!({"key": "Enter", "alt": "true"}), "alt"), + ("ui.resize", json!({"width": "800"}), "width"), + ("ui.screenshot", json!({"focus": "no"}), "focus"), + ("ui.screenshot", json!({"data": 1}), "data"), + ("ui.set", json!({"rulers": "true"}), "rulers"), + ("ui.set", json!({"grid": true, "controlBar": 0}), "controlBar"), + ("ui.pointer", json!({"events": [{"kind": "move", "x": "5", "y": 0}]}), "x"), + ("ui.pointer", move_to(json!({"shift": "yes"})), "mods"), + ("ui.pointer", json!({"mods": "shift", "events": [{"kind": "move", "x": 1, "y": 1}]}), "mods"), + ] { + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + app.synthetic.push(egui::Event::Text("pending".into())); + let before = (format!("{:?}", app.synthetic), app.ui.view.rulers, app.ui.view.grid, app.ui.control_bar); + let r = input_request(&mut app, method, params.clone()); + assert_eq!(r["ok"], false, "{method} {params}: {r}"); + assert!(r["error"].as_str().is_some_and(|e| e.contains(&format!("`{key}`"))), "{method} {params}: {r}"); + let after = (format!("{:?}", app.synthetic), app.ui.view.rulers, app.ui.view.grid, app.ui.control_bar); + assert_eq!(after, before, "{method} {params}: nothing is queued or set"); + } + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + assert_eq!(input_request(&mut app, "ui.move", json!({"x": null, "y": 4}))["ok"], true); + assert!(matches!(app.synthetic.first(), Some(egui::Event::PointerMoved(p)) if *p == egui::pos2(0.0, 4.0))); +} + +/// The app's own commands refuse a param of the wrong type and change nothing; omitting it or +/// giving null keeps its default. +#[test] +fn ui_commands_refuse_params_of_the_wrong_type() { + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 40})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + app.run("view.setZoom", json!({"zoom": 300, "center": [5, 5]})).unwrap(); + let view = |app: &VectorcraftApp| app.view().map(|v| (v.zoom, v.center.x, v.center.y)); + let before = (view(&app), app.ui.screen_mode, app.session.documents().len(), app.ui.dialog.is_some(), app.ui.open_panel.clone()); + for (id, params, key) in [ + ("view.setZoom", json!({"zoom": "200"}), "zoom"), + ("view.setZoom", json!({"zoom": 200, "center": [1]}), "center"), + ("view.setZoom", json!({"zoom": 200, "center": "0,0"}), "center"), + ("view.screenMode", json!({"mode": "2"}), "mode"), + ("view.screenMode", json!({"mode": 1.5}), "mode"), + ("window.panel", json!({"panel": "layers", "toggle": "true"}), "toggle"), + ("window.panel", json!({"panel": "layers", "toggle": true, "toggleGroup": 1}), "toggleGroup"), + ("file.revert", json!({"confirmed": "yes"}), "confirmed"), + ("file.close", json!({"index": "0"}), "index"), + ("ui.newSwatch", json!({"spot": "true"}), "spot"), + ("ui.newSwatch", json!({"global": 1}), "global"), + ("ui.symbolOptions", json!({"edit": 1}), "edit"), + ("ui.batchDialog", json!({"set": "1"}), "set"), + ("ui.batchDialog", json!({"action": -1}), "action"), + ("file.exportSelection", json!({"multiple": "true"}), "multiple"), + ] { + let e = app.run(id, params.clone()).unwrap_err(); + assert!(e.contains(&format!("`{key}`")), "{id} {params}: {e}"); + let after = (view(&app), app.ui.screen_mode, app.session.documents().len(), app.ui.dialog.is_some(), app.ui.open_panel.clone()); + assert_eq!(after, before, "{id} {params}: nothing changes"); + } + app.run("view.setZoom", json!({"zoom": null, "center": null})).unwrap(); + assert_eq!(app.view().map(|v| v.zoom), Some(crate::state::zoom_from_display_percent(100.0, app.session.prefs.display_print_size))); } #[test] diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 3d3f2a8c03..7326a3157c 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -75,7 +75,7 @@ pub(crate) use labels::lists_plugin; use labels::shows_a_name; pub use labels::{CONTEXT_LABELS, DYNAMIC_LABELS, display_label, dynamic_label, item_shortcut, menu_strings}; pub use run::{check_params, run_ui_command}; -pub(crate) use run::{normalize_panel, opt_bool}; +pub(crate) use run::{normalize_panel, opt_bool, opt_index, opt_number}; #[cfg(test)] use slots::RECENT_FONT_IDS; pub use state::{checked, enabled, plugin_revision, shortcut_of, shown_state}; diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index 1223e1e426..febb8313ea 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -25,6 +25,34 @@ pub(crate) fn opt_bool(p: &Value, key: &str) -> Result, String> { } } +/// An optional `{key?: number}` param: none when it is omitted or null. +pub(crate) fn opt_number(p: &Value, key: &str) -> Result, String> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => v.as_f64().map(Some).ok_or_else(|| format!("`{key}` must be a number")), + } +} + +/// An optional `{key?: index}` param, a whole number of 0 or more: none when it is omitted or null. +pub(crate) fn opt_index(p: &Value, key: &str) -> Result, String> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => v.as_u64().and_then(|i| usize::try_from(i).ok()).map(Some).ok_or_else(|| format!("`{key}` must be a whole number of 0 or more")), + } +} + +/// An optional `{key?: [x, y]}` param: none when it is omitted or null. +fn opt_point(p: &Value, key: &str) -> Result, String> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => match v.as_array().map(Vec::as_slice) { + Some([x, y]) => x.as_f64().zip(y.as_f64()).map(|(x, y)| Some(vectorcraft_geom::Point::new(x, y))), + _ => None, + } + .ok_or_else(|| format!("`{key}` must be [x, y] numbers")), + } +} + /// Handle a UI command. `None` = not a UI command (the engine handles it). /// `params` has only keys the params description of engine command `id` names, or the parameter /// error naming the one it doesn't (#1442). The UI's own commands aren't checked yet: their @@ -48,13 +76,18 @@ pub fn check_params(session: &vectorcraft_engine::Session, id: &str, params: &Va pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option> { // Window menu items and the named panel function keys toggle against the checked state; // other commands that open panels show them. - if id == "window.panel" && p.get("toggle").and_then(Value::as_bool) == Some(true) && checked(app, id, p) == Some(true) { + let toggles = if id == "window.panel" { opt_bool(p, "toggle").and_then(|t| Ok((t, opt_bool(p, "toggleGroup")?))) } else { Ok((None, None)) }; + let (toggle, toggle_group) = match toggles { + Ok(flags) => flags, + Err(e) => return Some(Err(e)), + }; + if toggle == Some(true) && checked(app, id, p) == Some(true) { let panel = p.get("panel").and_then(Value::as_str).and_then(normalize_panel).unwrap_or_default(); if app.ui.open_panel.as_deref() == Some(panel) { app.ui.open_panel = None; return Some(Ok(json!({"open": null}))); } - if p.get("toggleGroup").and_then(Value::as_bool) == Some(true) { + if toggle_group == Some(true) { return Some(crate::panel_docking::hide_group(app, panel)); } return Some(app.run("window.panel.close", json!({"panel": panel}))); @@ -131,7 +164,7 @@ fn run_ui_part1(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option io::new_from_template(app, s("path")), - "file.revert" if p.get("confirmed").and_then(Value::as_bool) != Some(true) => io::ask_revert(app), + "file.revert" if opt_bool(p, "confirmed") != Ok(Some(true)) => opt_bool(p, "confirmed").and_then(|_| io::ask_revert(app)), "file.reveal" => io::reveal(app), "file.cancelExport" => Ok(io::cancel_exports(app)), "file.exportInBackground" => Ok(io::hide_exports(app)), @@ -386,9 +419,11 @@ fn run_ui_part5(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option { let print = app.session.prefs.display_print_size; - let z = crate::state::zoom_from_display_percent(p.get("zoom").and_then(Value::as_f64).unwrap_or(100.0), print); - let center = - p.get("center").and_then(Value::as_array).and_then(|a| Some(vectorcraft_geom::Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?))); + let (zoom, center) = match opt_number(p, "zoom").and_then(|z| Ok((z, opt_point(p, "center")?))) { + Ok(given) => given, + Err(e) => return Some(Err(e)), + }; + let z = crate::state::zoom_from_display_percent(zoom.unwrap_or(100.0), print); match app.view_mut() { Some(v) => { v.zoom = z.clamp(0.0313, 640.0); @@ -417,7 +452,11 @@ fn run_ui_part5(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option { - app.ui.screen_mode = match p.get("mode").and_then(Value::as_u64) { + let mode = match opt_index(p, "mode") { + Ok(mode) => mode, + Err(e) => return Some(Err(e)), + }; + app.ui.screen_mode = match mode { Some(m) => m.min(3) as u8, // F cycles the three screen modes. Without a mode, the command also goes from // Presentation Mode (not in the cycle) back to Normal. @@ -664,9 +703,10 @@ fn run_ui_part7(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option match p.get("index").and_then(Value::as_u64).map(|i| i as usize) { - Some(i) => crate::unsaved::close(app, i), - None => { + "file.close" => match opt_index(p, "index") { + Err(e) => Err(e), + Ok(Some(i)) => crate::unsaved::close(app, i), + Ok(None) => { app.session.active_index()?; crate::doc_windows::close_current(app) } @@ -678,8 +718,10 @@ fn run_ui_part7(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option Err("missing `name`".into()), }, "ui.newSwatch" => { - let on = |k: &str| p.get(k).and_then(Value::as_bool).unwrap_or(false); - crate::dialogs::new_swatch::open(app, on("spot"), on("global"), s("group").as_deref()) + let on = |k: &str| opt_bool(p, k).map(|on| on.unwrap_or(false)); + on("spot") + .and_then(|spot| Ok((spot, on("global")?))) + .and_then(|(spot, global)| crate::dialogs::new_swatch::open(app, spot, global, s("group").as_deref())) } "ui.newColorGroup" => { let names = p.get("swatches").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()); @@ -697,7 +739,10 @@ fn run_ui_part7(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::panels::text_styles::open_new(app, crate::panels::text_styles::Kind::Para), "ui.symbolOptions" => { let name = s("name"); - let edit = p.get("edit").and_then(Value::as_bool).unwrap_or(false); + let edit = match opt_bool(p, "edit") { + Ok(edit) => edit.unwrap_or(false), + Err(e) => return Some(Err(e)), + }; if edit || name.is_some() { crate::dialogs::symbol_options::open_edit(app, name.as_deref()) } else { @@ -899,9 +944,8 @@ fn run_ui_part11(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::panels::actions::continue_playback(app, p), crate::panels::actions::DELETE_STEP => crate::panels::actions::delete_step(app, p), "ui.batchDialog" => { - let set = p.get("set").and_then(Value::as_u64).unwrap_or(0) as usize; - let action = p.get("action").and_then(Value::as_u64).unwrap_or(0) as usize; - crate::dialogs::action_batch::open(app, set, action) + let index = |k: &str| opt_index(p, k).map(|i| i.unwrap_or(0)); + index("set").and_then(|set| Ok((set, index("action")?))).and_then(|(set, action)| crate::dialogs::action_batch::open(app, set, action)) } crate::panels::actions::SAVE_SET => crate::panels::actions::save_set(app, p), crate::panels::actions::LOAD_SET => crate::panels::actions::load_set(app, p), @@ -934,7 +978,11 @@ fn run_ui_part12(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option { - app.run("assets.add", json!({ "multiple": p.get("multiple").and_then(Value::as_bool).unwrap_or(false) })).map(|r| { + let multiple = match opt_bool(p, "multiple") { + Ok(multiple) => multiple.unwrap_or(false), + Err(e) => return Some(Err(e)), + }; + app.run("assets.add", json!({ "multiple": multiple })).map(|r| { let ids: Vec = r["assets"].as_array().into_iter().flatten().filter_map(Value::as_u64).collect(); crate::dialogs::open_export_for_screens_assets(app, Some(&ids)); json!({ "assets": ids }) diff --git a/docs/mcp.md b/docs/mcp.md index 5826c5c8c4..7ced9eb156 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1265,6 +1265,19 @@ fails with no change. A description with a `…` entry takes more keys than it l object can carry several formats' options). A command that takes its own reply back (`document.setup`, `text.areaOptions`) says so the same way. +The app's own commands are not checked against their descriptions, but those listed here refuse a value of the wrong +type and change nothing: `view.setZoom` (`zoom`, `center`), `view.screenMode` (`mode`), `window.panel` (`toggle`, +`toggleGroup`), `file.revert` (`confirmed`), `file.close` (`index`), `ui.newSwatch` (`spot`, `global`), +`ui.symbolOptions` (`edit`), `ui.batchDialog` (`set`, `action`) and `file.exportSelection` (`multiple`). So do the +control channel's `ui.key`, `ui.move`, `ui.wheel`, `ui.click`, `ui.drag`, `ui.pointer`, `ui.resize`, `ui.screenshot`, +`ui.render` and `ui.set` for their numbers, switches and `mods`, and the headless session's key, pointer and screenshot +calls. Omitting a param or giving null keeps its default: + +```json +{"id": "view.setZoom", "params": {"zoom": "200"}} +→ `zoom` must be a number +``` + Saved actions aren't checked this way. When the Actions panel plays an action (Play at any speed, an action's function key, the Batch dialog or `ui.action.play`), a key a command no longer reads, recorded by an older version, is ignored and the action still plays. `Session::replay` plays a journal the same way. The command still refuses an effect From 123c9b5e7db6801e8b5c96015a31093fd9216d09 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 18:33:53 -0800 Subject: [PATCH 018/100] Brushes: built-in Bristle Brushes library with one bristle brush for each tip shape Window > Brush Libraries > Bristle Brushes holds ten original bristle brushes generated in code, one for each tip shape: Fine Liner, Pointed Flat, Stubby Round, Dry Scrub, Soft Dome, Filbert, Angled Round, Angled Flat, Fan Blender and Grass Fan. Their names and values are VectorCraft's own, and every value lies inside the range the bristle brush keeps. A test checks the ranges, that every tip shape is covered, that no name matches a default brush or another library's brush, that adding the library is one undo step and a second add adds nothing, and that a saved library reopens with the same brushes. The library and brush names have rows in every catalog. docs/mcp.md lists the library. --- crates/engine/src/cmd/brushlib.rs | 30 ++++++- crates/engine/src/tests/brushlib.rs | 78 ++++++++++++++++++- crates/ui-egui/src/i18n/bg.tsv | 11 +++ crates/ui-egui/src/i18n/bs.tsv | 11 +++ crates/ui-egui/src/i18n/ca.tsv | 11 +++ crates/ui-egui/src/i18n/cs.tsv | 11 +++ crates/ui-egui/src/i18n/da.tsv | 11 +++ crates/ui-egui/src/i18n/de.tsv | 11 +++ crates/ui-egui/src/i18n/el.tsv | 11 +++ crates/ui-egui/src/i18n/es.tsv | 11 +++ crates/ui-egui/src/i18n/fi.tsv | 11 +++ crates/ui-egui/src/i18n/fr.tsv | 11 +++ crates/ui-egui/src/i18n/hi.tsv | 11 +++ crates/ui-egui/src/i18n/hu.tsv | 11 +++ crates/ui-egui/src/i18n/id.tsv | 11 +++ crates/ui-egui/src/i18n/it.tsv | 11 +++ crates/ui-egui/src/i18n/ja.tsv | 11 +++ crates/ui-egui/src/i18n/ka.tsv | 11 +++ crates/ui-egui/src/i18n/ko.tsv | 11 +++ crates/ui-egui/src/i18n/nl.tsv | 11 +++ crates/ui-egui/src/i18n/pl.tsv | 11 +++ crates/ui-egui/src/i18n/pt-br.tsv | 11 +++ crates/ui-egui/src/i18n/ru.tsv | 11 +++ crates/ui-egui/src/i18n/sv.tsv | 11 +++ crates/ui-egui/src/i18n/tr.tsv | 11 +++ crates/ui-egui/src/i18n/uk.tsv | 11 +++ crates/ui-egui/src/i18n/vi.tsv | 11 +++ crates/ui-egui/src/i18n/zh-hans.tsv | 11 +++ crates/ui-egui/src/i18n/zh-hant.tsv | 11 +++ .../src/panels/tests_brush_libraries.rs | 1 + docs/gaps.md | 6 +- docs/mcp.md | 4 +- docs/roadmap.md | 2 +- docs/target-app-parity.md | 9 ++- 34 files changed, 413 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/cmd/brushlib.rs b/crates/engine/src/cmd/brushlib.rs index 8de9dd5c84..163518e591 100644 --- a/crates/engine/src/cmd/brushlib.rs +++ b/crates/engine/src/cmd/brushlib.rs @@ -11,7 +11,9 @@ use std::collections::HashSet; use std::sync::{Arc, LazyLock}; use serde_json::{Value, json}; -use vectorcraft_brush::{self as brush, ArtBrush, Brush, BrushKind, Calligraphic, Colorization, PatternBrush, PatternFit, Scatter, Variation}; +use vectorcraft_brush::{ + self as brush, ArtBrush, Bristle, BristleShape, Brush, BrushKind, Calligraphic, Colorization, PatternBrush, PatternFit, Scatter, Variation, +}; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{Appearance, AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, PatternDef, Symbol}; use vectorcraft_geom::{Affine, FillRule, PathData, Point, Rect, SubPath, shapes}; @@ -169,6 +171,7 @@ pub struct BuiltinBrushLibrary { pub const BUILTIN: &[BuiltinBrushLibrary] = &[ BuiltinBrushLibrary { id: "art-ribbons", name: "Art Ribbons", make: art_ribbons }, BuiltinBrushLibrary { id: "calligraphic-nibs", name: "Calligraphic Nibs", make: calligraphic_nibs }, + BuiltinBrushLibrary { id: "bristle-brushes", name: "Bristle Brushes", make: bristle_brushes }, BuiltinBrushLibrary { id: "pattern-borders", name: "Pattern Borders", make: pattern_borders }, BuiltinBrushLibrary { id: "scatter-shapes", name: "Scatter Shapes", make: scatter_shapes }, BuiltinBrushLibrary { id: "game-trails", name: "Game Trails", make: game_trails }, @@ -290,6 +293,31 @@ fn calligraphic_nibs() -> BrushLibrary { built("Calligraphic Nibs", Document::new(100.0, 100.0), brushes) } +/// A bristle brush: `shape`, then size (pt), bristle length, density, thickness, paint opacity +/// and stiffness (%). +fn bristle(name: &str, shape: BristleShape, [size, length, density, thickness, opacity, stiffness]: [f64; 6]) -> Brush { + Brush { name: name.into(), kind: BrushKind::Bristle(Bristle { shape, size, length, density, thickness, opacity, stiffness }) } +} + +/// Bristle Brushes: one bristle brush for each tip shape, from a fine liner to wide fans for +/// blending and texture. +fn bristle_brushes() -> BrushLibrary { + use BristleShape::*; + let brushes = vec![ + bristle("Fine Liner", RoundPoint, [2.0, 140.0, 30.0, 30.0, 90.0, 75.0]), + bristle("Pointed Flat", FlatPoint, [5.0, 120.0, 55.0, 35.0, 80.0, 60.0]), + bristle("Stubby Round", RoundBlunt, [8.0, 60.0, 70.0, 50.0, 85.0, 80.0]), + bristle("Dry Scrub", FlatBlunt, [10.0, 40.0, 25.0, 60.0, 60.0, 95.0]), + bristle("Soft Dome", RoundCurve, [12.0, 160.0, 85.0, 20.0, 45.0, 25.0]), + bristle("Filbert", FlatCurve, [9.0, 110.0, 65.0, 40.0, 70.0, 45.0]), + bristle("Angled Round", RoundAngle, [7.0, 130.0, 50.0, 35.0, 75.0, 55.0]), + bristle("Angled Flat", FlatAngle, [11.0, 100.0, 60.0, 45.0, 80.0, 65.0]), + bristle("Fan Blender", RoundFan, [16.0, 220.0, 40.0, 15.0, 35.0, 20.0]), + bristle("Grass Fan", FlatFan, [20.0, 260.0, 15.0, 25.0, 65.0, 35.0]), + ]; + built("Bristle Brushes", Document::new(100.0, 100.0), brushes) +} + fn border(name: &str, side: Node, corner: Option, fit: PatternFit) -> Brush { let kind = BrushKind::Pattern(PatternBrush { side, outer_corner: corner.clone(), inner_corner: corner, fit, ..Default::default() }); Brush { name: name.into(), kind } diff --git a/crates/engine/src/tests/brushlib.rs b/crates/engine/src/tests/brushlib.rs index 491a471088..243ea564f4 100644 --- a/crates/engine/src/tests/brushlib.rs +++ b/crates/engine/src/tests/brushlib.rs @@ -104,7 +104,7 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() { let list = run(&mut s, "brush.libraries", json!({})); let libs = list["libraries"].as_array().unwrap(); let ids: Vec<&str> = libs.iter().map(|l| l["id"].as_str().unwrap()).collect(); - assert_eq!(ids, ["art-ribbons", "calligraphic-nibs", "pattern-borders", "scatter-shapes", "game-trails", "energy-beams"]); + assert_eq!(ids, ["art-ribbons", "calligraphic-nibs", "bristle-brushes", "pattern-borders", "scatter-shapes", "game-trails", "energy-beams"]); assert!(libs.iter().all(|l| l["category"] == "builtIn")); let lib = run(&mut s, "brush.library", json!({"library": "art ribbons"})); assert_eq!( @@ -117,13 +117,13 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() { trails["brushes"].as_array().unwrap().iter().map(|b| b["name"].as_str().unwrap()).collect::>(), ["Speed Line", "Dash Trail", "Burst Ray", "Zigzag"] ); - assert_eq!(libs[4]["count"].as_u64(), Some(4)); + assert_eq!(libs[5]["count"].as_u64(), Some(4)); let beams = run(&mut s, "brush.library", json!({"library": "energy-beams"})); assert_eq!( beams["brushes"].as_array().unwrap().iter().map(|b| b["name"].as_str().unwrap()).collect::>(), ["Laser Bolt", "Plasma Arc", "Charge Wave", "Spark Trail"] ); - assert_eq!(libs[5]["count"].as_u64(), Some(4)); + assert_eq!(libs[6]["count"].as_u64(), Some(4)); run(&mut s, "file.new", json!({"width": 200, "height": 200})); let id = run(&mut s, "shape.rectangle", json!({"x": 10, "y": 20, "width": 40, "height": 8}))["id"].as_u64().unwrap(); let added = run(&mut s, "brush.addFromLibrary", json!({"library": "energy-beams", "name": "Laser Bolt", "apply": true, "ids": [id]})); @@ -132,6 +132,7 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() { for (id, kind) in [ ("art-ribbons", "art"), ("calligraphic-nibs", "calligraphic"), + ("bristle-brushes", "bristle"), ("pattern-borders", "pattern"), ("scatter-shapes", "scatter"), ("game-trails", "art"), @@ -165,6 +166,77 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() { assert!(s.execute("brush.library", &json!({"library": "nope"})).is_err()); } +/// Bristle Brushes: one brush per tip shape, every value inside the range the brush keeps (a value +/// outside would be clamped without notice), names no other library or default brush uses; added +/// in one step, not twice, and saved as a library they reopen unchanged. +#[test] +fn the_bristle_brushes_library_covers_every_tip_shape_within_the_bristle_ranges() { + use vectorcraft_brush::{Bristle, BristleShape}; + let mut s = session(); + let (_, lib) = cmd::brushlib::library(&s, "bristle-brushes").unwrap(); + assert_eq!(lib.name, "Bristle Brushes"); + let names: Vec = lib.brushes.iter().map(|b| b.name.clone()).collect(); + assert!((8..=10).contains(&names.len()), "{names:?}"); + let within = |v: f64, (lo, hi): (f64, f64)| (lo..=hi).contains(&v); + let mut shapes = vec![]; + for b in &lib.brushes { + let BrushKind::Bristle(x) = &b.kind else { panic!("{} is not a bristle brush", b.name) }; + assert!(within(x.size, Bristle::SIZE_RANGE) && within(x.length, Bristle::LENGTH_RANGE), "{}", b.name); + for v in [x.density, x.thickness, x.opacity, x.stiffness] { + assert!(within(v, Bristle::PERCENT_RANGE), "{}: {v}", b.name); + } + let mut kept = b.clone(); + kept.sanitize(); + assert_eq!(&kept, b, "sanitizing changes {}", b.name); + assert!(b.name.chars().count() <= Bristle::MAX_NAME_CHARS, "{}", b.name); + if !shapes.contains(&x.shape) { + shapes.push(x.shape); + } + } + let every = [ + BristleShape::RoundPoint, + BristleShape::FlatPoint, + BristleShape::RoundBlunt, + BristleShape::FlatBlunt, + BristleShape::RoundCurve, + BristleShape::FlatCurve, + BristleShape::RoundAngle, + BristleShape::FlatAngle, + BristleShape::RoundFan, + BristleShape::FlatFan, + ]; + assert!(every.iter().all(|x| shapes.contains(x)), "{shapes:?}"); + // Names of its own: no default brush's, no other built-in library's, none twice. + let defaults: Vec<&str> = vectorcraft_brush::defaults().iter().map(|b| b.name.as_str()).collect(); + let others: Vec = cmd::brushlib::BUILTIN + .iter() + .filter(|b| b.id != "bristle-brushes") + .flat_map(|b| cmd::brushlib::library(&s, b.id).unwrap().1.brushes.iter().map(|x| x.name.clone()).collect::>()) + .collect(); + for (i, n) in names.iter().enumerate() { + assert!(!defaults.contains(&n.as_str()) && !others.contains(n) && !names[..i].contains(n), "{n}"); + } + + // Added to a document in one undo step; adding them again adds nothing. + let before = (undo_len(&s), brushes(&s)); + let r = run(&mut s, "brush.addFromLibrary", json!({"library": "Bristle Brushes"})); + assert_eq!(strs(&r["added"]), names.iter().map(String::as_str).collect::>()); + assert_eq!(undo_len(&s), before.0 + 1); + assert_eq!(brushes(&s), [before.1.clone(), names.clone()].concat()); + for b in &lib.brushes { + assert_eq!(&brush(&s, &b.name), b); + } + let again = undo_len(&s); + let r = run(&mut s, "brush.addFromLibrary", json!({"library": "bristle-brushes"})); + assert_eq!((strs(&r["added"]).len(), strs(&r["existing"]).len(), undo_len(&s)), (0, names.len(), again)); + // Saved as a library and loaded back: the same brushes, in the same order. + let saved = run(&mut s, "brush.saveLibrary", json!({ "names": names, "name": "Bristles" })); + let b64 = saved["dataBase64"].as_str().unwrap().to_string(); + let r = run(&mut s, "brush.loadLibrary", json!({"dataBase64": b64, "name": "Bristles.vectorcraft"})); + let (_, reopened) = cmd::brushlib::library(&s, r["library"].as_str().unwrap()).unwrap(); + assert_eq!(reopened.brushes, lib.brushes); +} + #[test] fn a_saved_library_reopens_and_its_brushes_join_another_document_in_one_step() { let dir = folder("user"); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index d83b5ac3c6..a58b3d6ac8 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -5974,6 +5974,17 @@ axis Y Axis Y ос Charge Wave Вълна на заряд Spark Trail Следа от искри Calligraphic Nibs Калиграфски писци + Bristle Brushes Четки с косъм + Fine Liner Тънък контур + Pointed Flat Заострена плоска + Stubby Round Къса кръгла + Dry Scrub Сухо търкане + Soft Dome Мек купол + Filbert Плоска овална + Angled Round Скосена кръгла + Angled Flat Скосена плоска + Fan Blender Ветрило за смесване + Grass Fan Ветрило за трева Pattern Borders Бордюри с мостри Scatter Shapes Разсеяни фигури Save Brush Library… Запази библиотека с четки… diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 663e95ceaa..68547afeb7 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -6024,6 +6024,17 @@ axis Y Axis Y osa Charge Wave Talas naboja Spark Trail Trag iskri Calligraphic Nibs Kaligrafski vrhovi + Bristle Brushes Četkice s čekinjama + Fine Liner Tanka linija + Pointed Flat Šiljata ravna + Stubby Round Kratka okrugla + Dry Scrub Suho trljanje + Soft Dome Meka kupola + Filbert Ovalna ravna + Angled Round Kosa okrugla + Angled Flat Kosa ravna + Fan Blender Lepeza za stapanje + Grass Fan Lepeza za travu Pattern Borders Ivice od uzoraka Scatter Shapes Raspršeni oblici Save Brush Library… Sačuvaj biblioteku četkica… diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 9c60e87e00..4efbeeb129 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -607,6 +607,17 @@ antiAlias Sharp Nítid Calligraphic Brush Pinzell cal·ligràfic Calligraphic Brush Options Opcions del pinzell cal·ligràfic Calligraphic Nibs Plomes cal·ligràfiques + Bristle Brushes Pinzells de cerres + Fine Liner Traç fi + Pointed Flat Pla punxegut + Stubby Round Rodó curt + Dry Scrub Fregat sec + Soft Dome Cúpula suau + Filbert Llengua de gat + Angled Round Rodó bisellat + Angled Flat Pla bisellat + Fan Blender Ventall difuminador + Grass Fan Ventall d'herba Can't export “{name}”. No es pot exportar “{name}”. Can't open “{name}”. No es pot obrir “{name}”. Can't print these settings: see the Summary No es poden imprimir aquests paràmetres: consulta el resum diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index e8f9666fae..b1360ad04a 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6209,6 +6209,17 @@ axis Y Axis osa Y Charge Wave Nabíjecí vlna Spark Trail Jiskrová stopa Calligraphic Nibs Kaligrafická pera + Bristle Brushes Štětinové štětce + Fine Liner Jemná linka + Pointed Flat Špičatý plochý + Stubby Round Krátký kulatý + Dry Scrub Suché drhnutí + Soft Dome Měkká kopule + Filbert Plochý oválný + Angled Round Šikmý kulatý + Angled Flat Šikmý plochý + Fan Blender Vějíř na prolínání + Grass Fan Vějíř na trávu Pattern Borders Vzorkové okraje Scatter Shapes Rozptýlené tvary Save Brush Library… Uložit knihovnu štětců… diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 31915ed6bf..0c70f895e6 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -5989,6 +5989,17 @@ axis Y Axis Y-akse Charge Wave Opladningsbølge Spark Trail Gnistsvær Calligraphic Nibs Kalligrafiske penne + Bristle Brushes Børstepensler + Fine Liner Fin streg + Pointed Flat Spids flad + Stubby Round Kort rund + Dry Scrub Tør skrubning + Soft Dome Blød kuppel + Filbert Oval flad + Angled Round Skrå rund + Angled Flat Skrå flad + Fan Blender Blendevifte + Grass Fan Græsvifte Pattern Borders Mønsterborter Scatter Shapes Spredte former Save Brush Library… Gem penselbibliotek… diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 2c5424d81f..ba3ba0d4ca 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5792,6 +5792,17 @@ axis Y Axis Y-Achse Charge Wave Ladewelle Spark Trail Funkenspur Calligraphic Nibs Kalligrafie-Federn + Bristle Brushes Borstenpinsel-Set + Fine Liner Feiner Liner + Pointed Flat Spitzer Flachpinsel + Stubby Round Kurzer Rundpinsel + Dry Scrub Trockenes Schrubben + Soft Dome Weiche Kuppel + Filbert Katzenzunge + Angled Round Schräger Rundpinsel + Angled Flat Schräger Flachpinsel + Fan Blender Fächer zum Verblenden + Grass Fan Grasfächer Pattern Borders Musterbordüren Scatter Shapes Streuformen Save Brush Library… Pinsel-Bibliothek speichern… diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 6071ed5be4..b0b3f807b5 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -6160,6 +6160,17 @@ axis Y Axis Άξονας Y Charge Wave Κύμα φόρτισης Spark Trail Ίχνος σπινθήρων Calligraphic Nibs Καλλιγραφικές πένες + Bristle Brushes Πινέλα με τρίχες + Fine Liner Λεπτή γραμμή + Pointed Flat Μυτερό πλακέ + Stubby Round Κοντό στρογγυλό + Dry Scrub Ξηρό τρίψιμο + Soft Dome Μαλακός θόλος + Filbert Γλώσσα γάτας + Angled Round Λοξό στρογγυλό + Angled Flat Λοξό πλακέ + Fan Blender Βεντάλια ανάμειξης + Grass Fan Βεντάλια για γρασίδι Pattern Borders Περιθώρια μοτίβου Scatter Shapes Σχήματα διασποράς Save Brush Library… Αποθήκευση βιβλιοθήκης πινέλων… diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 4a74e3ae39..5f0aa42ee0 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -6151,6 +6151,17 @@ axis Y Axis Eje Y Charge Wave Onda de carga Spark Trail Estela de chispas Calligraphic Nibs Plumillas caligráficas + Bristle Brushes Pinceles de cerdas + Fine Liner Delineador fino + Pointed Flat Plano puntiagudo + Stubby Round Redondo corto + Dry Scrub Frotado seco + Soft Dome Cúpula suave + Filbert Lengua de gato + Angled Round Redondo biselado + Angled Flat Plano biselado + Fan Blender Abanico difuminador + Grass Fan Abanico de hierba Pattern Borders Bordes de motivo Scatter Shapes Formas de dispersión Save Brush Library… Guardar biblioteca de pinceles… diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index f2ec64f602..1da1ad5a25 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6239,6 +6239,17 @@ axis Y Axis Y-akseli Charge Wave Latausaalto Spark Trail Kipinävanne Calligraphic Nibs Kalligrafiakärjet + Bristle Brushes Harjassiveltimet + Fine Liner Ohut viiva + Pointed Flat Suippo litteä + Stubby Round Lyhyt pyöreä + Dry Scrub Kuiva hankaus + Soft Dome Pehmeä kupu + Filbert Kissankieli + Angled Round Vino pyöreä + Angled Flat Vino litteä + Fan Blender Sekoitusviuhka + Grass Fan Ruohoviuhka Pattern Borders Kuvioreunukset Scatter Shapes Sirontamuodot Save Brush Library… Tallenna sivellinkirjasto… diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 807b3363b4..490fc5cb70 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6204,6 +6204,17 @@ axis Y Axis Axe Y Charge Wave Vague de charge Spark Trail Traînée d'étincelles Calligraphic Nibs Plumes calligraphiques + Bristle Brushes Pinceaux à soies + Fine Liner Traceur fin + Pointed Flat Plat pointu + Stubby Round Rond court + Dry Scrub Frottis sec + Soft Dome Dôme doux + Filbert Langue de chat + Angled Round Rond biseauté + Angled Flat Plat biseauté + Fan Blender Éventail estompeur + Grass Fan Éventail à herbe Pattern Borders Bordures de motif Scatter Shapes Formes de dispersion Save Brush Library… Enregistrer la bibliothèque de formes… diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 572a19779f..52e6e4be81 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -689,6 +689,17 @@ antiAlias Sharp शार्प Calligraphic Brush कैलिग्राफिक ब्रश Calligraphic Brush Options कैलिग्राफिक ब्रश ऑप्शन्स Calligraphic Nibs कैलिग्राफिक निब्स + Bristle Brushes ब्रिसल ब्रश संग्रह + Fine Liner बारीक लाइनर + Pointed Flat नुकीला फ्लैट + Stubby Round छोटा गोल + Dry Scrub सूखी रगड़ + Soft Dome मुलायम गुंबद + Filbert फिल्बर्ट + Angled Round तिरछा गोल + Angled Flat तिरछा फ्लैट + Fan Blender मिश्रण पंखा + Grass Fan घास पंखा Can't export “{name}”. "{name}" निर्यात नहीं कर सकते। Can't open “{name}”. "{name}" नहीं खोल सकते हैं। Can't print these settings: see the Summary इन सेटिंग्स को मुद्रित नहीं कर सकते: सारांश देखें diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 8c2d2339bc..01ced9e05f 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6192,6 +6192,17 @@ axis Y Axis Y tengely Charge Wave Töltéshullám Spark Trail Szikracsík Calligraphic Nibs Kalligrafikus tollhegyek + Bristle Brushes Sörteecsetek + Fine Liner Finom kontúr + Pointed Flat Hegyes lapos + Stubby Round Rövid kerek + Dry Scrub Száraz dörzsölés + Soft Dome Lágy kupola + Filbert Macskanyelv + Angled Round Ferde kerek + Angled Flat Ferde lapos + Fan Blender Elmosó legyező + Grass Fan Fűlegyező Pattern Borders Mintázatszegélyek Scatter Shapes Szórt alakzatok Save Brush Library… Ecsetkönyvtár mentése… diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 606ffae97c..70ceb67db9 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -5989,6 +5989,17 @@ axis Y Axis Sumbu Y Charge Wave Gelombang Muatan Spark Trail Jejak Percikan Calligraphic Nibs Pena kaligrafi + Bristle Brushes Kuas-kuas bristle + Fine Liner Garis halus + Pointed Flat Pipih runcing + Stubby Round Bulat pendek + Dry Scrub Gosok kering + Soft Dome Kubah lembut + Filbert Pipih oval + Angled Round Bulat miring + Angled Flat Pipih miring + Fan Blender Kipas pembaur + Grass Fan Kipas rumput Pattern Borders Bingkai pola Scatter Shapes Bentuk sebar Save Brush Library… Simpan pustaka kuas… diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index bf4c9e7b58..e03211a3bb 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5826,6 +5826,17 @@ axis Y Axis Asse Y Charge Wave Onda di carica Spark Trail Scia di scintille Calligraphic Nibs Pennini calligrafici + Bristle Brushes Pennelli a setole + Fine Liner Tratto fine + Pointed Flat Piatto appuntito + Stubby Round Tondo corto + Dry Scrub Strofinato secco + Soft Dome Cupola morbida + Filbert Lingua di gatto + Angled Round Tondo obliquo + Angled Flat Piatto obliquo + Fan Blender Ventaglio sfumino + Grass Fan Ventaglio per erba Pattern Borders Bordi a motivo Scatter Shapes Forme a dispersione Save Brush Library… Salva libreria pennelli… diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 7a12afcadb..e56c64967d 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5891,6 +5891,17 @@ axis Y Axis Y 軸 Charge Wave チャージウェーブ Spark Trail スパークトレイル Calligraphic Nibs カリグラフィペン先 + Bristle Brushes 剛毛ブラシ集 + Fine Liner 細線ライナー + Pointed Flat 先細平筆 + Stubby Round 短丸筆 + Dry Scrub ドライスクラブ + Soft Dome ソフトドーム + Filbert フィルバート + Angled Round 斜め丸筆 + Angled Flat 斜め平筆 + Fan Blender ぼかし扇筆 + Grass Fan 草描き扇筆 Pattern Borders パターンボーダー Scatter Shapes 散布シェイプ Save Brush Library… ブラシライブラリを保存… diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index c58480c426..a344ab813f 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6160,6 +6160,17 @@ axis Y Axis Y ღერძი Charge Wave მუხტის ტალღა Spark Trail ნაპერწკლის კვალი Calligraphic Nibs კალიგრაფიული წვერები + Bristle Brushes ჯაგრის ფუნჯები + Fine Liner წვრილი ხაზი + Pointed Flat წაწვეტებული ბრტყელი + Stubby Round მოკლე მრგვალი + Dry Scrub მშრალი ხეხვა + Soft Dome რბილი გუმბათი + Filbert ოვალური ბრტყელი + Angled Round დახრილი მრგვალი + Angled Flat დახრილი ბრტყელი + Fan Blender შერევის მარაო + Grass Fan ბალახის მარაო Pattern Borders პატერნის ბორდიურები Scatter Shapes გაბნეული ფიგურები Save Brush Library… ფუნჯების ბიბლიოთეკის შენახვა… diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 39efdecf2e..29e265aa59 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5787,6 +5787,17 @@ axis Y Axis Y축 Charge Wave 차지 웨이브 Spark Trail 스파크 트레일 Calligraphic Nibs 서예 펜촉 + Bristle Brushes 강모 브러시 모음 + Fine Liner 가는 라이너 + Pointed Flat 뾰족한 평붓 + Stubby Round 짧은 둥근 붓 + Dry Scrub 마른 문지르기 + Soft Dome 부드러운 돔 + Filbert 필버트 + Angled Round 사선 둥근 붓 + Angled Flat 사선 평붓 + Fan Blender 블렌딩 부채붓 + Grass Fan 잔디 부채붓 Pattern Borders 패턴 테두리 Scatter Shapes 흩뿌리기 모양 Save Brush Library… 브러시 라이브러리 저장… diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index dd1d22edc0..417b9d5d1f 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -6040,6 +6040,17 @@ axis Y Axis Y-as Charge Wave Laadgolf Spark Trail Vonkenspoor Calligraphic Nibs Kalligrafische penpunten + Bristle Brushes Borstelpenselen + Fine Liner Fijne liner + Pointed Flat Spitse platte + Stubby Round Korte ronde + Dry Scrub Droog schrobben + Soft Dome Zachte koepel + Filbert Kattentong + Angled Round Schuine ronde + Angled Flat Schuine platte + Fan Blender Vervloeiwaaier + Grass Fan Graswaaier Pattern Borders Patroonranden Scatter Shapes Strooivormen Save Brush Library… Penseelbibliotheek opslaan… diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index dd850a3a4a..403b4b338a 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -6063,6 +6063,17 @@ axis Y Axis Oś Y Charge Wave Fala ładunku Spark Trail Ślad iskier Calligraphic Nibs Stalówki kaligraficzne + Bristle Brushes Pędzle włosiankowe + Fine Liner Cienka linia + Pointed Flat Szpiczasty płaski + Stubby Round Krótki okrągły + Dry Scrub Suche szorowanie + Soft Dome Miękka kopuła + Filbert Koci język + Angled Round Skośny okrągły + Angled Flat Skośny płaski + Fan Blender Wachlarz do mieszania + Grass Fan Wachlarz do trawy Pattern Borders Obramowania wzorków Scatter Shapes Kształty rozproszone Save Brush Library… Zapisz bibliotekę pędzli… diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index f67ae6cda2..bcb1738d3c 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -6124,6 +6124,17 @@ axis Y Axis Eixo Y Charge Wave Onda de carga Spark Trail Rastro de faíscas Calligraphic Nibs Penas Caligráficas + Bristle Brushes Pincéis de Cerdas + Fine Liner Traço Fino + Pointed Flat Chato Pontudo + Stubby Round Redondo Curto + Dry Scrub Esfregado Seco + Soft Dome Cúpula Suave + Filbert Língua de Gato + Angled Round Redondo Chanfrado + Angled Flat Chato Chanfrado + Fan Blender Leque Esfumador + Grass Fan Leque de Grama Pattern Borders Bordas de Padrão Scatter Shapes Formas de Dispersão Save Brush Library… Salvar Biblioteca de Pincéis… diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 8eb5425823..7b0363fd4f 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5820,6 +5820,17 @@ axis Y Axis Ось Y Charge Wave Волна заряда Spark Trail Искра-след Calligraphic Nibs Каллиграфические перья + Bristle Brushes Щетинные кисти + Fine Liner Тонкий лайнер + Pointed Flat Заострённая плоская + Stubby Round Короткая круглая + Dry Scrub Сухое растирание + Soft Dome Мягкий купол + Filbert Кошачий язык + Angled Round Скошенная круглая + Angled Flat Скошенная плоская + Fan Blender Веер для растушёвки + Grass Fan Веер для травы Pattern Borders Узорные каймы Scatter Shapes Рассеянные фигуры Save Brush Library… Сохранить библиотеку кистей… diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index fb2c463de0..6de152e9c8 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -6232,6 +6232,17 @@ axis Y Axis Y-axel Charge Wave Laddningsvåg Spark Trail Gnistsvår Calligraphic Nibs Kalligrafiska pennspetsar + Bristle Brushes Borstpenslar + Fine Liner Fin linje + Pointed Flat Spetsig platt + Stubby Round Kort rund + Dry Scrub Torr skrubbning + Soft Dome Mjuk kupol + Filbert Oval platt + Angled Round Sned rund + Angled Flat Sned platt + Fan Blender Blandande solfjäder + Grass Fan Grässolfjäder Pattern Borders Mönsterbårder Scatter Shapes Spridningsformer Save Brush Library… Spara penselbibliotek… diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index d45a171cc6..c77b19c0f1 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6165,6 +6165,17 @@ axis Y Axis Y ekseni Charge Wave Şarj Dalgası Spark Trail Kıvılcım İzi Calligraphic Nibs Kaligrafi Uçları + Bristle Brushes Kıl Fırçalar + Fine Liner İnce Çizgi + Pointed Flat Sivri Düz + Stubby Round Kısa Yuvarlak + Dry Scrub Kuru Ovma + Soft Dome Yumuşak Kubbe + Filbert Kedi Dili + Angled Round Eğik Yuvarlak + Angled Flat Eğik Düz + Fan Blender Harmanlama Yelpazesi + Grass Fan Çim Yelpazesi Pattern Borders Desen Kenarlıkları Scatter Shapes Dağılım Şekilleri Save Brush Library… Fırça Kitaplığını Kaydet… diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index acaf20b6b9..edd8e376e9 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5806,6 +5806,17 @@ axis Y Axis Вісь Y Charge Wave Хвиля заряду Spark Trail Слід іскор Calligraphic Nibs Каліграфічні пера + Bristle Brushes Щетинні пензлі + Fine Liner Тонкий лайнер + Pointed Flat Загострений плаский + Stubby Round Короткий круглий + Dry Scrub Сухе розтирання + Soft Dome М'який купол + Filbert Котячий язик + Angled Round Скошений круглий + Angled Flat Скошений плаский + Fan Blender Віяло для розтушовування + Grass Fan Віяло для трави Pattern Borders Візерункові облямівки Scatter Shapes Розсіяні фігури Save Brush Library… Зберегти бібліотеку пензлів… diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 4a2bbd5a62..4800dc0d66 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5807,6 +5807,17 @@ axis Y Axis Trục Y Charge Wave Sóng nạp Spark Trail Vệt tia lửa Calligraphic Nibs Ngòi bút thư pháp + Bristle Brushes Bộ cọ lông + Fine Liner Nét mảnh + Pointed Flat Cọ dẹt nhọn + Stubby Round Cọ tròn ngắn + Dry Scrub Chà khô + Soft Dome Vòm mềm + Filbert Cọ lưỡi mèo + Angled Round Cọ tròn xiên + Angled Flat Cọ dẹt xiên + Fan Blender Cọ quạt hòa trộn + Grass Fan Cọ quạt vẽ cỏ Pattern Borders Viền họa tiết Scatter Shapes Hình phân tán Save Brush Library… Lưu thư viện cọ… diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 0eb7c7ebe5..8b354491fe 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -6047,6 +6047,17 @@ axis Y Axis Y 轴 Charge Wave 充能波 Spark Trail 火花尾迹 Calligraphic Nibs 书法笔尖 + Bristle Brushes 鬃毛画笔集 + Fine Liner 细勾线笔 + Pointed Flat 尖头平笔 + Stubby Round 短圆笔 + Dry Scrub 干擦 + Soft Dome 柔和圆顶 + Filbert 榛形笔 + Angled Round 斜圆笔 + Angled Flat 斜平笔 + Fan Blender 混色扇形笔 + Grass Fan 画草扇形笔 Pattern Borders 图案边框 Scatter Shapes 散布形状 Save Brush Library… 保存画笔库… diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 084a7e7708..367e54e7aa 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5844,6 +5844,17 @@ axis Y Axis Y 軸 Charge Wave 充能波 Spark Trail 火花尾跡 Calligraphic Nibs 書法筆尖 + Bristle Brushes 鬃毛筆刷集 + Fine Liner 細勾線筆 + Pointed Flat 尖頭平筆 + Stubby Round 短圓筆 + Dry Scrub 乾擦 + Soft Dome 柔和圓頂 + Filbert 榛形筆 + Angled Round 斜圓筆 + Angled Flat 斜平筆 + Fan Blender 混色扇形筆 + Grass Fan 畫草扇形筆 Pattern Borders 圖樣邊框 Scatter Shapes 散佈形狀 Save Brush Library… 儲存筆刷資料庫… diff --git a/crates/ui-egui/src/panels/tests_brush_libraries.rs b/crates/ui-egui/src/panels/tests_brush_libraries.rs index 6918e9118f..4d8959f262 100644 --- a/crates/ui-egui/src/panels/tests_brush_libraries.rs +++ b/crates/ui-egui/src/panels/tests_brush_libraries.rs @@ -132,6 +132,7 @@ fn the_window_menu_and_the_brushes_panel_open_brush_libraries() { [ json!("art-ribbons"), json!("calligraphic-nibs"), + json!("bristle-brushes"), json!("pattern-borders"), json!("scatter-shapes"), json!("game-trails"), diff --git a/docs/gaps.md b/docs/gaps.md index b722ace84c..0b7d52275f 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -630,8 +630,8 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi libraries ([public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/create-new-brush-libraries.html), [import](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/import-brushes-from-brush-libraries-or-other-files.html)): Window › Brush Libraries and the Brushes panel's Brush Libraries Menu open each library in a library panel of - its own (six original libraries generated in code: Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter - Shapes, Game Trails and Energy Beams; User Defined; Other Library… reads the brushes of any document VectorCraft opens); a + its own (seven original libraries generated in code: Art Ribbons, Calligraphic Nibs, Bristle Brushes (one + bristle brush for each tip shape), Pattern Borders, Scatter Shapes, Game Trails and Energy Beams; User Defined; Other Library… reads the brushes of any document VectorCraft opens); a click adds a brush to the document's Brushes with the patterns, symbols and other resources its art uses, as one undo step (applied to the selected paths in the same step), dragging one onto a path adds and applies it and @@ -649,7 +649,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi Game HUD with Health Fill, Mana Glow, Damage Flash, Quest Outline, Coin Shine and Boss Alert, and Status Effects with Poison Tint, Freeze Edge, Burn Aura, Stun Pulse, Stealth Fade and Shield Buff; User Defined; Other Library…; `graphicStyle.libraries`, `graphicStyle.addFromLibrary`). -- **Missing:** more built-in brush libraries beyond the six shipped (and further graphic-style libraries beyond the eight shipped, and further symbol libraries beyond Basic +- **Missing:** more built-in brush libraries beyond the seven shipped (and further graphic-style libraries beyond the eight shipped, and further symbol libraries beyond Basic Shapes, UI Frames, Map Markers, Inventory Icons and Skill Icons; original, generated in code, never Adobe's); dynamic symbols' `.ai` read and write; Global Edit of groups holding type or images, rotated copies as matches and highlighting matches before the start; 9-slice guides outside the symbol's art (a dragged guide stops at the art's edge). diff --git a/docs/mcp.md b/docs/mcp.md index 5826c5c8c4..17efd02c69 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3223,8 +3223,8 @@ error. ## Brush libraries -Brush libraries are read-only sets of brushes: six generated in code (Art Ribbons, Calligraphic Nibs, Pattern -Borders, Scatter Shapes, Game Trails, Energy Beams), the `.vectorcraft` files in the user library folder of the desktop app (User +Brush libraries are read-only sets of brushes: seven generated in code (Art Ribbons, Calligraphic Nibs, Bristle +Brushes with one bristle brush for each tip shape, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams), the `.vectorcraft` files in the user library folder of the desktop app (User Defined) and the brushes of any document loaded with `brush.loadLibrary` (a document without brushes of its own has the default ones). `brush.libraries` lists them (`id`, `name`, `category`: `builtIn`, `user` or `loaded`, `count`) and `brush.library {library}` returns one's brushes (`name`, `type`; `library` is an id or a name). diff --git a/docs/roadmap.md b/docs/roadmap.md index 1b55740614..7c474a0a2f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -149,7 +149,7 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l | M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type, and the Edit Custom Dictionary dialog (#4936; the Check Spelling dialog remains); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Hanging punctuation keeps its published amounts under Glyph Scaling, and Standard burasagari counts a hung line start as room; Fullwidth `!` / `?` keep an upright fallback in vertical type; Hyphenation… and Justification… per paragraph (#2310, #2755), Auto Leading also driving the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel (including font-defined Stylistic Sets), Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | | M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Anchor Point spine conversion/handle editing and selection priority (#2544), with #2425 Shift reshape preserved; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 | | M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder, Color Adjustments and Document Raster Effects Settings; PDF export warns when the pixel cap lowers raster-effect resolution; Artistic › Colored Pencil, Cutout, Dry Brush, Film Grain, Fresco, Neon Glow, Paint Daubs, Palette Knife, Poster Edges, Rough Pastels, Smudge Stick, Sponge, Underpainting and Watercolor and Sketch › Halftone Pattern, Bas Relief, Charcoal, Conté Crayon, Photocopy, Stamp, Torn Edges and Water Paper done (57/57 Photoshop filters); Revolve with automatic smooth vector shading, default-on circular caps and continuous rims, Extrude & Bevel, Inflate and Rotate with one to ten colored directional lights and four original lighting presets; SVG Filters, remaining raster effects and 3D materials and shadows pending | 81–130 | -| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol libraries done (Window › Symbol Libraries, five generated libraries (Basic Shapes, UI Frames, Map Markers, Inventory Icons, Skill Icons), User Defined, Other Library…, Save Symbols); brush libraries done (Window › Brush Libraries, six generated libraries, User Defined, Other Library…, Save Brushes); symbol editing shows draggable 9-slice guides (#5280), with guides outside the artwork still unsupported; brushes/symbols in progress | 23–37 | +| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol libraries done (Window › Symbol Libraries, five generated libraries (Basic Shapes, UI Frames, Map Markers, Inventory Icons, Skill Icons), User Defined, Other Library…, Save Symbols); brush libraries done (Window › Brush Libraries, seven generated libraries including Bristle Brushes, User Defined, Other Library…, Save Brushes); symbol editing shows draggable 9-slice guides (#5280), with guides outside the artwork still unsupported; brushes/symbols in progress | 23–37 | | M11 | Artboards & views (selected-set deletion shared by Artboards menu, trash and Properties, retaining artboard 1 when all are selected; Artboard deletion keys keep the first of an all-selected set and protect artwork; history prunes invalid indices while preserving valid multiselection (history tests and full workspace CI pass); click-to-fit objects with the Artboard tool (#1227), artboard escape routing and document setup modal handoff done; artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator with synchronized Artboard tool selection done; Window › Arrange › Tile and Consolidate All Windows done; multiple windows, presentation polish) (cached canvas reprojection preserves scale during resize, rotation and DPI changes; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) (cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; regression-tested; estimates unchanged); status-bar pointer coordinates follow the active ruler origin and General units | 🟡; Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting includes all drawn document art across artboards and off-board; artwork bounds are cached by active document and revision, and selection uses the shared bounds cache across artboards | 15–25 | | M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (junction-returning pieces retain their positioned boundaries; a simpler contour walk with identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments; large images trace with a warning; Auto Color keeps rare distinct colours; quick presets trace at once and the Image Trace button opens the adjustment controls; Properties reopens them; tiny fitted contours retain islands and holes; Create Strokes: exact crop EDT, Guo–Hall thinning and maximal-disc graph simplification; 15 presets, the 14 documented ones each with an enhanced version; Ignore Color, Shapes, Auto Grouping and Gradients options; measured in `image-trace-quality.md`; closed outlines retain their interior after straightening; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients; live trace result bounds and selection highlights exclude the retained bitmap except in source-image views), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; Graph Design and Marker designs, Transpose and Switch x/y, `\|` label line breaks, the pie options, drop shadows, the legend across the top, overlapping widths, First Row/Column in Front, filled lines, kept label formatting and the Graph Data worksheet done; graph Column designs pending; Vector Halftone Preview keeps the selection box without drawing dot selection marks (#6369) | 6–10 | | M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 MCP `add_text` exposes existing vertical point, area and path type; `script.run` and File › Scripts › Other Script… run JavaScript files, and Window › Script Console shows their output; the script document model and batch work remain | 18–27 | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b4c949f742..361deecfcc 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -218,7 +218,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Drawing tools (Pen, Curvature, Pencil, shapes, Shaper) | 7 | 85% | Shaper construction widget, Pencil/Pen nuances, Blob Brush options, Touch Type, Dimension tool; see [ui-parity.md](ui-parity.md) | 8–13 | estimated; Curvature point display regression-tested (#1236, in part); Polygon/Star exact-size count persistence regression-covered; Live Corners' Relative rounding done (#3091), score unchanged; Copy and Cut with the Pen or Curvature tool take the anchors a Cmd/Ctrl-lent Direct Selection picked (#6123), score and hours unchanged | | Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 80% | Shape Builder on the reporter's file (#937: art past 4,096 segments is cut by lines again; unconfirmed on the file itself); gap detection shares Live Paint’s finder, with a 72 pt custom cap and the existing options; Live Paint Gap Options | 4–8 | estimated; Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog (lowered from 86%: open issues); compound attributes regression-tested (#1197); single-result source names survive SVG export/import (#1293), with [SVG ID semantics](https://www.w3.org/TR/SVG2/struct.html#IDAttribute) and original-shape tests; Simplify component selection and preview regression-tested (#1272); Simplify of a Direct Selection part of a path regression-tested (`tests/simplify_part.rs`); Divide open-cutting-path geometry and appearance regression-tested (#1307); the Shape Builder's edge-source grid is bounded in memory and files a segment once per cell (`planar.rs` test `rect_index_holds_a_bounded_number_of_references`); live compound shapes (Make, Alt-click, member Shape Modes, Release, Expand, saved and exported) regression-tested (#4522) | | Colour, swatches, gradients, patterns, mesh, recolor | 7 | 92% | 30.0's gradient additions are done (dither, perceptual blending, presets); freeform gradients in SVG/PDF, mixed spot/process gradients in SVG, PDF gradients mixing a spot color with RGB or Lab stops, and dithered spot gradient fills and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, which PDF export writes as images in process colors | 4–7 | estimated; Recolor model selection is available through the engine and MCP (#2520), and the dialog's color sliders (HSB, RGB, CMYK, Lab, Tint for a spot color) edit the selected row's new color in its mode, and the Edit tab's Global Adjust sliders (Saturation, Brightness, Temperature, Luminosity, -100% to 100%) change every recolored color at once (lowered from 96%: target moved); unpainted zero-width stroke restoration fixed (#1253), score and hours unchanged; Eyedropper source reads regression-tested (#1251); screen-bounded Recolor Edit chip list (#3416), score and hours unchanged | -| Strokes, brushes, width profiles | 5 | 88% | Shift-double-click adjoining adjustment is implemented; brush libraries are done (six generated libraries including Game Trails and Energy Beams, User Defined, Other Library…, Save Brushes); Art brush Overlap and Pattern brush auto corners are done; remaining: pen pressure on macOS, Stylus Wheel, more built-in brush libraries, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | estimated; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed; evidence below; finite-arithmetic follow-up regressions and full CI passed (evidence below) | +| Strokes, brushes, width profiles | 5 | 88% | Shift-double-click adjoining adjustment is implemented; brush libraries are done (seven generated libraries including Bristle Brushes, Game Trails and Energy Beams, User Defined, Other Library…, Save Brushes); Art brush Overlap and Pattern brush auto corners are done; remaining: pen pressure on macOS, Stylus Wheel, more built-in brush libraries, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | estimated; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed; evidence below; finite-arithmetic follow-up regressions and full CI passed (evidence below) | | Appearance, transparency, graphic styles, masks | 5 | 97% | raster effect reach in preview bounds | 1–2 | estimated; Transform typing, sliders, preview, and undo are covered in `crates/ui-egui/src/panels/tests_appearance.rs` | | 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, painter visibility); materials, light presets, shadows, ray tracing, the rest of 3D Classic (Revolve, Rotate, Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | | Type core | 9 | 80% | Auto-leading style resets are implemented; Hyphenation… and Justification… options are done (#2310, #2755), and a paragraph's Auto Leading also sets the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; remaining: Optical Margin Alignment, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated; eleven-language pattern hyphenation in both composers, with grapheme and shaping-cluster protection (#4947, `hyphen::tests` and `layout::wrapping_tests`); Sanskrit patterns pending an allowlisted source; recent-font groups in font pickers (#3515); full-name font matches regression-tested (#1258); mixed paragraph alignment indication implemented; headless regressions, full integration CI and visual review pass; localized legacy family-name fallback with loaded/catalog regressions (#2208); Fractional Widths per type object regression-tested; color emoji fonts with bitmap glyphs (`sbix`, `CBDT`) draw and export; threading ports: every selected area type and type on a path shows its in and out ports (empty, arrow, red "+"), and the Selection tool threads from either, the in port into a frame before it, and a double-click on a threaded port breaks the thread there (`threads::tests::break_splits_the_thread_and_the_story_flows_into_the_first_part`, `select::tests::a_double_click_on_a_threaded_port_breaks_the_thread`, `canvas::tests::a_double_click_on_a_threaded_out_port_breaks_the_thread`) (#2572; `select::tests::a_click_on_the_in_port_threads_into_a_frame_before_it`, `text::tests::selected_type_shows_its_ports_in_each_state`, `threads::tests::add_before_threads_the_story_from_the_new_frame_into_the_clicked_one`, live screenshot); the Glyphs panel highlights and scrolls to the character selected with the Type tool (#4908, `panels::glyphs` tests); the OpenType panel lists only `ss01`–`ss20` sets present in the selected font and uses its custom names when supplied; Type-range desktop Copy publishes styled art and keeps font family, size and color on canvas Paste after editing (#5636); paint-only Arabic harakat keep joining with conditional GDEF-mark ownership and Insert Kashidas base-letter paint (`tests::harakat`) | @@ -487,7 +487,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Drawing tools | 7 | 86% | rough-square Shaper recognition and orientation steps corrected; editable Shaper Groups landed; Pencil: Alt key toggles to Smooth Tool (#2001); remaining: Shaper construction widget/gesture refinements, pencil and the remaining pen modifier nuances, Blob Brush Merge Only with Selection, Touch Type, Live Corners' Relative rounding | 7–12 | | Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 86% | Live Paint gap options, Shape Builder gap detection, options (Tool Options dialog, clicking a stroke in merge mode) and Shift-drag marquee | 3–6 | | Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | freeform gradients in SVG/PDF and mixed spot/process gradients in SVG (exported as stops; PDF export keeps the spot inks in a DeviceN shading, except next to RGB or Lab stops; dithered fills and Perceptual gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve are written as images in process colors), pattern fills in Expand | 2–4; Eyedropper source reads regression-tested (#1251) | -| Strokes, brushes, width profiles | 5 | 90% | more built-in brush libraries (six generated in code including Game Trails and Energy Beams are done; Art brush Overlap and Pattern brush auto corners are done), pen pressure on macOS (the windowing layer reports none there), Stylus Wheel, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | +| Strokes, brushes, width profiles | 5 | 90% | more built-in brush libraries (seven generated in code including Bristle Brushes, Game Trails and Energy Beams are done; Art brush Overlap and Pattern brush auto corners are done), pen pressure on macOS (the windowing layer reports none there), Stylus Wheel, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | | Appearance, transparency, graphic styles, masks | 5 | 98% | raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 | | Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap (since done: #3061, #3063, #3785), SVG Filters, Transform Patterns and Scale Strokes & Effects on a fill's or stroke's own Transform effect | 6–10 | | 3D and Materials | 4 | ~25% | Initial Revolve, Extrude & Bevel, Inflate and Rotate, with simple lighting; materials, light presets, shadows, ray tracing, Revolve (Classic), Rotate (Classic), Map Art and more complete visibility remain | 25–45 | @@ -539,7 +539,7 @@ since 2026-10-01. | **Interaction fidelity:** modifiers, cursors, small behaviours | ~30–40% | The dedicated pass hasn't started, and there has been no side-by-side session with Illustrator yet. A power user notices this first | | **Look & feel** | ~75–80% | Measured against Illustrator 2026 screenshots (2026-10-02); the panels added since haven't been re-measured | | **File interop** | ~85% | SVG/SVGZ, PDF and PDF-compatible `.ai` (and legacy version 7 `.ai` out, keeping layers, groups and type as text), EPS, DXF, EMF/WMF, raster formats, PSD export and PSD/PSB placement (merged image), Place and Links, Package, Print, clipboard flavours. Affinity documents (Affinity 1–3) open natively as editable art, checked against 51 pinned public files (33 current `.af`), with selected saved-thumbnail comparisons and every-board native/SVG/PDF/PSD checks for current files; Affinity export is missing and effects, adjustments and brushes don't import. Affinity isn't an Illustrator feature, so this doesn't raise the parity score. The editing copy inside an Illustrator EPS or `.ai` is read for its structure: layers, groups, names, hidden objects and type, colours, gradients, transparency, images and every artboard (see Out of scope for what it doesn't read yet); DWG has no open spec | -| **Bundled content:** brush, symbol, style and swatch libraries | ~46% | Ours are original and generated in code, and far fewer than Illustrator ships; symbol libraries have five (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons, Skill Icons), graphic style libraries eight (including Game HUD and Status Effects) and brush libraries six (Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams) beside User Defined and Other Library… | +| **Bundled content:** brush, symbol, style and swatch libraries | ~46% | Ours are original and generated in code, and far fewer than Illustrator ships; symbol libraries have five (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons, Skill Icons), graphic style libraries eight (including Game HUD and Status Effects) and brush libraries seven (Art Ribbons, Calligraphic Nibs, Bristle Brushes, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams) beside User Defined and Other Library… | | **Performance** | unverified | Multithreaded, off-thread rendering and caches are in place. The last budget run (2026-10-01, loaded machine) measured 290 ms for a 50k-path fit against a 16 ms budget. Re-run `vectorcraft-cli perf` on an idle machine | | **Robustness** | good, new | ~2,840 tests, property tests, no panics in shipped code (lints, the `guard` safety net, import fuzzing), Data Recovery. Missing: a corpus of real-world files, Windows/Linux/browser QA | | **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work, and Finder opens every readable format with the macOS app (#295, #354); on macOS the menus are in the system menu bar, laid out as a Mac app's, with the in-window menus as a fallback (`--in-window-menus`; verified on Windows and by cross-compilation, not yet by hand on a Mac); hybrid-graphics laptops render on the power-saving GPU by default on Windows and macOS (#306), and Windows starts on Direct3D 12, falling back to OpenGL, and loads no Vulkan driver unless `WGPU_BACKEND` asks for it (#545: Vulkan flickered black on an Intel GPU, DX12 and OpenGL didn't; #806: a faulty Vulkan driver crashed the app before its window appeared; verified by unit tests and the reporter's runs with `WGPU_BACKEND`), and Linux on the GPU the desktop runs on, since a Wayland compositor may refuse frames from the other one; a window that fails on its GPU while starting up starts again on the next one instead of crashing (#502; the dual-GPU Wayland case is verified by unit tests and cross-compilation, not on such hardware); web drops land in the document they were dropped on (#359); desktop drops carry no pointer position (winit 0.30), so a dropped document opens as a tab of its own and a picture or text is placed in the middle of the view, while the web places anything dropped on the canvas and opens what is dropped off it, as Illustrator does (#472); a web page that loses its WebGPU device or WebGL context restarts its graphics with the same documents (#369); on Linux/Wayland the clipboard reads the compositor's own (data-control) and copied image files paste, but files can't be dropped on the window under native Wayland until eframe moves to winit 0.31 (#398; the Wayland side is verified by compilation only), so Preferences › Performance › Linux Display Server › X11 (XWayland) opens the window through XWayland, where drops work (#1151; verified by unit tests and cross-compilation, not on a Wayland desktop); on Linux the Paste menu checks the system clipboard on a background thread, so a clipboard owner that never answers no longer freezes the window, while Windows and macOS keep their quick in-line check (#510; the X11 hang verified by the contributor, here by headless tests); on KDE Plasma 6.3+ under Wayland, pens and pen displays reach the app only under XWayland (`WAYLAND_DISPLAY=`) or with KWin's `KWIN_WAYLAND_EMULATE_TABLET=1`, since winit 0.30 has no Wayland tablet input (#491, not verified on hardware); Windows/Linux packaging and accessibility are pending | @@ -823,7 +823,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Expand of symbol instances:** Object › Expand turns selected symbol instances (without effects, not in perspective, in groups or placed documents too) into their art as Break Link to Symbol places it, one level deep, as one undo step; the Expand dialog's Fill and Stroke reach that art (`object.expand`, `crates/engine/src/tests/expand_symbols.rs`; estimated, score unchanged). - **Reset Transformation** ([Transform symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/transform-symbols.html), [Symbols panel options](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html)): the Symbols panel menu's Reset Transformation and the Control bar's Reset, shown while symbol instances are selected, give the selected instances their symbol's own scale again, without rotation or shear, as one undo step (`symbol.instance.resetTransform`); an instance in perspective is left as it is. VectorCraft's own rules: each instance keeps the center of its bounding box in place, reflection is removed too, and the menu item and the button are enabled only while a selected instance would change. Tests: `tests::brushsym::reset_transformation_restores_the_symbols_scale_and_keeps_the_center`, `reset_transformation_keeps_the_center_of_art_away_from_the_origin`, `reset_transformation_ignores_a_stored_size_that_is_not_positive`, `reset_transformation_leaves_unchanged_instances_and_refuses_bad_ids`, `panels::symbols::tests::reset_transformation_is_enabled_for_a_transformed_instance_and_resets_it`. - **Expand of dimensions:** Object › Expand, Expand Appearance, Create Outlines and Outline Stroke turn a dimension whose art they change into a plain group of that art, which stays as it is through later moves, scales and edits of what it measured; undo gives the live dimension back (`crates/engine/tests/it/dimensions.rs`; VectorCraft's own rule; estimated, score unchanged). - - **Brush libraries** ([public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/create-new-brush-libraries.html), [import](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/import-brushes-from-brush-libraries-or-other-files.html), [Brushes panel](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/brushes-panel-overview.html)): Window › Brush Libraries, the Brushes panel's Brush Libraries Menu and its menu's Open Brush Library open each library in a library panel of its own, as swatch, graphic style and symbol libraries do. The built-in libraries (Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams) are original brushes generated in code (`crates/engine/src/cmd/brushlib.rs`); Striped Ribbon's art is filled with the library's own pattern and Sparkles scatters the library's own symbol; Game Trails adds motion art brushes (Speed Line, Dash Trail, Burst Ray, Zigzag); Energy Beams adds laser and plasma art brushes (Laser Bolt, Plasma Arc, Charge Wave, Spark Trail). A click on a brush adds it to the document's Brushes as one undo step with every resource its art uses (patterns, symbols, swatches, graphic styles, images), makes it the current brush and, with paths selected, applies it to them in the same step; a brush the document already has is not added again and a different brush of the same name comes in numbered ("Wave 2"). Shift/Cmd-clicks select brushes for Add to Brushes; a brush dragged onto a path is added and applied to it, and one dragged onto the Brushes panel is added. Save Brushes… / the Brushes panel's Save Brush Library… write the document's brushes (or the selected ones) as a `.vectorcraft` library into the `Brushes` folder next to the preferences, listed under User Defined, or to a file; Other Library… reads the brushes of any document VectorCraft opens. Thumbnails, in the library panels, the Brushes panel and Brush Options, render brush art with the patterns and symbols it uses. Copying brushed art now carries the patterns and symbols its brush's art uses too. Tests: `crates/engine/src/tests/brushlib.rs`, `crates/ui-egui/src/panels/tests_brush_libraries.rs`, `panels::brushes::tests::library_brush_previews_show_the_patterns_their_art_uses`, `canvas::tests::a_library_brush_dropped_on_a_path_is_added_and_applied_in_one_step`. + - **Brush libraries** ([public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/create-new-brush-libraries.html), [import](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/import-brushes-from-brush-libraries-or-other-files.html), [Brushes panel](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/brushes-panel-overview.html)): Window › Brush Libraries, the Brushes panel's Brush Libraries Menu and its menu's Open Brush Library open each library in a library panel of its own, as swatch, graphic style and symbol libraries do. The built-in libraries (Art Ribbons, Calligraphic Nibs, Bristle Brushes, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams) are original brushes generated in code (`crates/engine/src/cmd/brushlib.rs`); Striped Ribbon's art is filled with the library's own pattern and Sparkles scatters the library's own symbol; Game Trails adds motion art brushes (Speed Line, Dash Trail, Burst Ray, Zigzag); Energy Beams adds laser and plasma art brushes (Laser Bolt, Plasma Arc, Charge Wave, Spark Trail). A click on a brush adds it to the document's Brushes as one undo step with every resource its art uses (patterns, symbols, swatches, graphic styles, images), makes it the current brush and, with paths selected, applies it to them in the same step; a brush the document already has is not added again and a different brush of the same name comes in numbered ("Wave 2"). Shift/Cmd-clicks select brushes for Add to Brushes; a brush dragged onto a path is added and applied to it, and one dragged onto the Brushes panel is added. Save Brushes… / the Brushes panel's Save Brush Library… write the document's brushes (or the selected ones) as a `.vectorcraft` library into the `Brushes` folder next to the preferences, listed under User Defined, or to a file; Other Library… reads the brushes of any document VectorCraft opens. Thumbnails, in the library panels, the Brushes panel and Brush Options, render brush art with the patterns and symbols it uses. Copying brushed art now carries the patterns and symbols its brush's art uses too. Tests: `crates/engine/src/tests/brushlib.rs`, `crates/ui-egui/src/panels/tests_brush_libraries.rs`, `panels::brushes::tests::library_brush_previews_show_the_patterns_their_art_uses`, `canvas::tests::a_library_brush_dropped_on_a_path_is_added_and_applied_in_one_step`. + - **Bristle Brushes library** ([What's new](https://helpx.adobe.com/illustrator/desktop/new-features/whats-new.html), [About brushes](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/about-brushes.html)): Window › Brush Libraries › Bristle Brushes holds ten bristle brushes, one for each tip shape (Fine Liner, Pointed Flat, Stubby Round, Dry Scrub, Soft Dome, Filbert, Angled Round, Angled Flat, Fan Blender, Grass Fan). The brushes, their names and their values are VectorCraft's own, and every value lies inside the range the bristle brush keeps (`brush.libraries`, `brush.addFromLibrary`; `the_bristle_brushes_library_covers_every_tip_shape_within_the_bristle_ranges`). - A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, Remove Anchor Points when anchors are selected, masks, compound paths, guides, Transform, Arrange, Select, Export Selection, and on type Bullets and Numbering and Spelling › Auto Spell Check, checked while it is on), or the view commands on empty canvas. - A right-click over locked objects adds an Unlock submenu to the canvas context menu. The submenu lists up to 20 locked objects under the pointer, topmost first, by the name the Layers panel shows, and choosing one unlocks it (`layer.setProps`). A locked group is listed as one item. Objects on hidden, locked or template layers are not listed. - Native macOS save sheets offer a localised Format menu (#866), built from the request's existing format filters. It selects the suggested filename's format and updates the suffix when changed, including legacy `.drawcraft` names. `apps/vectorcraft/src/mac_save_format.rs` has filename regressions; an actual native SVG save, selection of PDF and cancellation without changing the saved document were verified. From 68cbf041a4b67212341379ee8850bc4e861ef9b3 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 18:50:00 -0800 Subject: [PATCH 019/100] MCP: replies hide each sent path the same way whatever the outcome Each hidden id or path is replaced once, chosen from the reply as given, so a hidden path inside another library's shown id is not replaced again, and a readable id written with %XX escapes keeps the hidden path it begins with. A path quoted with {:?} is matched with its quotes and control characters escaped. A path outside the read roots that the request sent is shown by its file name in a reply that succeeded as in one that failed, and a path param counts with a relative path too. A relative path is not replaced where it ends a longer path. Without a read root, a file: URL that names another host stays in ui.inspect's recorded steps. --- crates/ui-egui/src/control.rs | 216 +++++++++++++------------ crates/ui-egui/src/control/tests.rs | 83 +++++++++- crates/ui-egui/src/tests/automation.rs | 5 +- docs/mcp.md | 8 +- 4 files changed, 200 insertions(+), 112 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index a198810b41..c1b7350527 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -441,13 +441,19 @@ impl Hiding { } /// The paths outside the read roots that `v`'s strings and keys name at any depth, written as paths -/// ([`unreadable_path_like`]), each as a hidden library id (`loaded/`) with `loaded/` -/// shown. The error of a failed request hides them as it hides a hidden library's path, with that file -/// name also for a hidden library shown as `loaded/ (2)`, so that it reads the same whether -/// or not a library was loaded from one of them. +/// ([`unreadable_path_like`]) or held by a path param +/// ([`vectorcraft_engine::file_access::is_path_key`]), a relative one too, each as a hidden library id +/// (`loaded/`) with `loaded/` shown. A reply hides them as it hides a hidden library's +/// path, with that file name also for a hidden library shown as `loaded/ (2)`, so that it +/// reads the same whether or not a library was loaded from one of them. fn sent_paths(v: &Value, out: &mut Vec<(String, String)>) { - fn add(s: &str, out: &mut Vec<(String, String)>) { - if !unreadable_path_like(s) { + collect_sent_paths(v, false, out); +} + +/// [`sent_paths`] on `v`, which a path param holds when `path_param` is set. +fn collect_sent_paths(v: &Value, path_param: bool, out: &mut Vec<(String, String)>) { + fn add(s: &str, path_param: bool, out: &mut Vec<(String, String)>) { + if !unreadable_path_like(s) && !(path_param && !readable_value(s)) { return; } // A URL that names another host as the path after its scheme (`//host/share/…`). @@ -463,12 +469,12 @@ fn sent_paths(v: &Value, out: &mut Vec<(String, String)>) { } } match v { - Value::String(s) => add(s, out), - Value::Array(items) => items.iter().for_each(|item| sent_paths(item, out)), + Value::String(s) => add(s, path_param, out), + Value::Array(items) => items.iter().for_each(|item| collect_sent_paths(item, path_param, out)), Value::Object(fields) => { for (key, item) in fields { - add(key, out); - sent_paths(item, out); + add(key, false, out); + collect_sent_paths(item, vectorcraft_engine::file_access::is_path_key(key), out); } } _ => {} @@ -510,7 +516,7 @@ fn collect_library_panel_ids(v: &Value, ids: &mut std::collections::BTreeSet