From 45987bc9484e4bbd194716f486bd12f1f5b7e5ea Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 17:25:19 -0800 Subject: [PATCH 01/43] 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 a8989aff6dc8bdcc64fce808e1d10dfd99b18823 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 18:22:01 -0800 Subject: [PATCH 02/43] 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 db97cc1a8faaa751b370376703f9f2021d7610a5 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 19:35:33 -0800 Subject: [PATCH 03/43] JavaScript: a bare E4X name inside with or a filter can be assigned With Options::e4x set, an E4X name standing alone (@a, @ns::a, @[expr], ns::a, *) is now a valid target for =, compound assignment, ++, --, delete and for/for each loops, as well as a read. The target is the nearest XML or XMLList value in scope whose [[HasProperty]] accepts the name (ECMA-357 section 11.1). The name is evaluated once, and the target resolves before the right-hand side runs. When no XML value in scope holds the name, a read or write throws a ReferenceError and creates no child or attribute. A read no longer falls back to the first XML value in scope, so a filter such as list.(@id == '1') throws when an item has no @id and no outer XML value holds one, instead of treating that item as not matching. --- crates/js/src/ast.rs | 3 +- crates/js/src/bytecode.rs | 6 ++- crates/js/src/compiler.rs | 31 ++++++++++++- crates/js/src/interp.rs | 1 + crates/js/src/parser.rs | 7 +-- crates/js/src/xml.rs | 38 ++++++++++------ crates/js/src/xml_tests.rs | 89 ++++++++++++++++++++++++++++++++++++++ docs/target-app-parity.md | 1 + 8 files changed, 156 insertions(+), 20 deletions(-) diff --git a/crates/js/src/ast.rs b/crates/js/src/ast.rs index 5a4363108a..8d9ffc06c0 100644 --- a/crates/js/src/ast.rs +++ b/crates/js/src/ast.rs @@ -187,7 +187,8 @@ pub enum Expr { qualifier: Option, name: XmlNamePart, }, - /// An E4X name standing alone (`@a` in a filter): looked up on the nearest XML value in scope. + /// An E4X name standing alone (`@a` in a filter): looked up on the nearest XML value in scope + /// that holds it, and also an assignment, update or `delete` target. XmlScopeName(ExprId), /// `object..name`. XmlDescendants { diff --git a/crates/js/src/bytecode.rs b/crates/js/src/bytecode.rs index 3092530d46..72bd9ef90e 100644 --- a/crates/js/src/bytecode.rs +++ b/crates/js/src/bytecode.rs @@ -160,8 +160,11 @@ pub(crate) enum Op { parts: u32, list: bool, }, - /// `name` → the value of the name on the nearest XML value in scope. + /// `name` → the value of the name on the nearest XML value in scope that holds it. XmlScopeGet, + /// `name` → `object name`: the nearest XML value in scope that holds the name, for an + /// assignment, update or `delete`. + XmlScopeRef, /// `object` → the filter's state. XmlFilterStart, /// `state` → `state item`, or jumps with `state` when the items are used up. @@ -308,6 +311,7 @@ fn effect(op: Op, finally_tables: &[Vec]) -> Result { Op::XmlDescendants | Op::XmlFilterTest => e(2, 1), Op::XmlEscape(_) | Op::XmlScopeGet | Op::XmlFilterStart | Op::XmlFilterEnd => e(1, 1), Op::XmlLiteral { parts, .. } => e(parts, 1), + Op::XmlScopeRef => e(1, 2), Op::ForEachValue => e(2, 2), Op::SetDefaultNamespace => e(1, 0), }) diff --git a/crates/js/src/compiler.rs b/crates/js/src/compiler.rs index 071e99a60e..effb475f92 100644 --- a/crates/js/src/compiler.rs +++ b/crates/js/src/compiler.rs @@ -538,6 +538,11 @@ impl<'a> FnCompiler<'a> { self.emit(Op::Rot3); self.emit(Op::SetIndex); } + Some(Expr::XmlScopeName(name)) => { + self.xml_scope_ref(target, *name)?; + self.emit(Op::Rot3); + self.emit(Op::SetIndex); + } _ => { self.expr(target)?; self.emit(Op::Pop); @@ -854,6 +859,15 @@ impl<'a> FnCompiler<'a> { Ok(()) } + /// Pushes the nearest XML value in scope that holds the E4X name `name` of the expression + /// `id`, then the name, as an assignment, update or `delete` uses them. + fn xml_scope_ref(&mut self, id: ExprId, name: ExprId) -> CResult<()> { + self.expr(name)?; + self.at(self.ast.expr_span(id).start); + self.emit(Op::XmlScopeRef); + Ok(()) + } + fn xml_literal(&mut self, parts: &[XmlPart], list: bool) -> CResult<()> { for part in parts { match part { @@ -1096,6 +1110,10 @@ impl<'a> FnCompiler<'a> { self.expr(*index)?; self.emit(Op::DeleteIndex); } + Some(Expr::XmlScopeName(name)) => { + self.xml_scope_ref(arg, *name)?; + self.emit(Op::DeleteIndex); + } _ => { self.expr(arg)?; self.emit(Op::Pop); @@ -1202,6 +1220,13 @@ impl<'a> FnCompiler<'a> { self.emit(Op::GetIndex); } } + Some(Expr::XmlScopeName(name)) => { + self.xml_scope_ref(target, *name)?; + if op.is_some() { + self.emit(Op::Dup2); + self.emit(Op::GetIndex); + } + } _ => { self.expr(target)?; if op.is_none() { @@ -1222,7 +1247,7 @@ impl<'a> FnCompiler<'a> { let n = self.name_key(key_of(&property.name)); self.emit(Op::SetMember(n)); } - Some(Expr::Index { .. }) => self.emit(Op::SetIndex), + Some(Expr::Index { .. } | Expr::XmlScopeName(_)) => self.emit(Op::SetIndex), _ => self.throw_invalid_target(), } Ok(()) @@ -1246,6 +1271,10 @@ impl<'a> FnCompiler<'a> { self.expr(*index)?; self.emit(Op::IncIndex { prefix, dec }); } + Some(Expr::XmlScopeName(name)) => { + self.xml_scope_ref(target, *name)?; + self.emit(Op::IncIndex { prefix, dec }); + } _ => { self.expr(target)?; self.emit(Op::ToNumber); diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index afb6cde635..b8213fbaf3 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -922,6 +922,7 @@ impl Interp { Op::XmlEscape(kind) => self.op_xml_escape(kind), Op::XmlLiteral { parts, list } => self.op_xml_literal(parts, list), Op::XmlScopeGet => self.op_xml_scope_get(), + Op::XmlScopeRef => self.op_xml_scope_ref(), Op::XmlFilterStart => self.op_xml_filter_start(), Op::XmlFilterNext(t) => self.op_xml_filter_next(t), Op::XmlFilterTest => self.op_xml_filter_test(), diff --git a/crates/js/src/parser.rs b/crates/js/src/parser.rs index 3c2ab3ed30..fa201c58ff 100644 --- a/crates/js/src/parser.rs +++ b/crates/js/src/parser.rs @@ -873,8 +873,9 @@ impl<'s> Parser<'s> { } /// An assignment's, `++`'s, `--`'s or `for-in`'s target: an identifier (in strict code - /// neither `eval` nor `arguments`), a property, or outside strict mode code a call (assigning - /// to its result fails at run time, as ECMA-262 2015 Annex B and web browsers have it). + /// neither `eval` nor `arguments`), a property, an E4X name standing alone (`@a`, `ns::a`; + /// ECMA-357 section 11.1), or outside strict mode code a call (assigning to its result fails + /// at run time, as ECMA-262 2015 Annex B and web browsers have it). fn check_target(&self, expr: ExprId, message: &str) -> Result<()> { let inner = self.ast.unparenthesized(expr); match self.ast.expr(inner) { @@ -884,7 +885,7 @@ impl<'s> Parser<'s> { } Ok(()) } - Some(Expr::Member { .. } | Expr::Index { .. }) => Ok(()), + Some(Expr::Member { .. } | Expr::Index { .. } | Expr::XmlScopeName(_)) => Ok(()), Some(Expr::Call { .. }) if !self.ctx.strict => Ok(()), _ => Err(self.error_at_offset(self.ast.expr_span(expr).start, message)), } diff --git a/crates/js/src/xml.rs b/crates/js/src/xml.rs index 9dc6d71f85..9104ba83f7 100644 --- a/crates/js/src/xml.rs +++ b/crates/js/src/xml.rs @@ -2797,27 +2797,24 @@ impl Interp { self.replace(n, value) } - pub(crate) fn op_xml_scope_get(&mut self) -> JsResult<()> { - let name = self.peek(0)?; - let n = self.xml_name_only(&name)?; + /// The nearest XML or XMLList value in scope whose [[HasProperty]] accepts `n`, or a + /// ReferenceError when none does (ECMA-357 section 11.1). + fn xml_scope_holder(&mut self, n: &NameRef) -> JsResult { let mut cur = Some(self.frame()?.env); - let mut first = None; + let mut any_xml = false; while let Some(e) = cur { match self.heap.env(e) { Some(Env::Decl { outer, .. }) => cur = *outer, Some(&Env::Obj { object, outer, .. }) => { let v = Value::Object(object); if self.is_xml(&v) { - if first.is_none() { - first = Some(object); - } + any_xml = true; let found = match self.classify(&v) { - Xv::Node(x) => self.node_has_name(x, &n)?, - _ => self.list_has_name(object, &n)?, + Xv::Node(x) => self.node_has_name(x, n)?, + _ => self.list_has_name(object, n)?, }; if found { - first = Some(object); - break; + return Ok(object); } } cur = outer; @@ -2825,13 +2822,26 @@ impl Interp { None => return Err(Abrupt::Internal("environment out of range".into())), } } - let Some(object) = first else { - return Err(self.throw(ErrorType::Reference, "an XML name is used outside any XML value")); - }; + let message = if any_xml { "no XML value in scope has the XML name" } else { "an XML name is used outside any XML value" }; + Err(self.throw(ErrorType::Reference, message)) + } + + pub(crate) fn op_xml_scope_get(&mut self) -> JsResult<()> { + let name = self.peek(0)?; + let n = self.xml_name_only(&name)?; + let object = self.xml_scope_holder(&n)?; let v = self.xml_select(object, &n)?; self.replace(1, Value::Object(v)) } + pub(crate) fn op_xml_scope_ref(&mut self) -> JsResult<()> { + let name = self.peek(0)?; + let n = self.xml_name_only(&name)?; + let object = self.xml_scope_holder(&n)?; + self.replace(1, Value::Object(object))?; + self.push(name) + } + pub(crate) fn op_xml_filter_start(&mut self) -> JsResult<()> { let base = self.peek(0)?; let input = match self.classify(&base) { diff --git a/crates/js/src/xml_tests.rs b/crates/js/src/xml_tests.rs index cfe508a483..96ac9c3aed 100644 --- a/crates/js/src/xml_tests.rs +++ b/crates/js/src/xml_tests.rs @@ -636,6 +636,95 @@ fn a_scope_name_skips_a_list_without_that_name() { truth("var outer = ; var inner = <>; with (outer) with (inner) { @id == '2' }"); } +#[test] +fn bare_attribute_assignment_uses_nearest_holder() { + truth("var x = ; with (x) { @b = '2'; } x.@b == '2'"); + truth("var outer = ; var inner = ; with (outer) with (inner) { @id = '9'; } outer.@id == '9' && inner.@id.length() === 0"); + truth("var outer = ; var inner = ; with (outer) with (inner) { @id = '9'; } outer.@id == '1' && inner.@id == '9'"); + truth("var outer = ; var inner = <>; with (outer) with (inner) { @id = '9'; } outer.@id == '9' && inner.@id.length() === 0"); + truth("var x = ; with (x) with ({ b: 7 }) { @b = '3'; } x.@b == '3'"); + truth("with () { (@b = 5) === 5 }"); + truth("var x = ; with (x) { @b = @c = 'v'; } x.@b == 'v' && x.@c == 'v'"); + truth("var n = 0; var x = ; with (x) { @[(n++, 'b')] = 'z'; } n === 1 && x.@b == 'z' && x.attributes().length() === 1"); + truth("var x = 1; with (x) { *::c = '2'; } x.c == '2' && x.*.length() === 1"); + truth("var x = ; x.@b = '4'; x.@b == '4'"); +} + +#[test] +fn bare_qualified_assignment_keeps_namespace() { + truth( + "var ns = new Namespace('u'); var x = 10; with (x) { ns::c = '2'; } \ + x.ns::c == '2' && x.ns::c.name().uri === 'u' && x.c == '0' && x.*.length() === 2", + ); + truth( + "var ns = new Namespace('u'); var x = ; with (x) { @ns::t = '5'; } \ + x.@ns::t == '5' && x.@ns::t.name().uri === 'u' && x.@t == '0'", + ); +} + +#[test] +fn bare_assignment_in_filter_mutates_each_matching_item() { + truth("var l = <>; var r = l.(@id = 'z', true); r.length() === 2 && l[0].@id == 'z' && l[1].@id == 'z'"); + truth("var l = <>; var r = l.(@n = 0); r.length() === 0 && l[0].@n == '0' && l[1].@n == '0'"); + truth("var l = <>; var r = l.(@n = 'k'); r.length() === 2"); + truth("var o = ; var l = <>; with (o) { l.(@k += 'x', true); } o.@k == '1xx' && l[0].@k.length() === 0"); +} + +#[test] +fn bare_assignment_without_holder_throws_reference_error_before_rhs() { + assert_eq!(thrown_name("@a = 1"), "ReferenceError"); + assert_eq!(thrown_name("with ({ a: 1 }) { @a = 1 }"), "ReferenceError"); + assert_eq!(thrown_name("with () { @a = 1 }"), "ReferenceError"); + assert_eq!(thrown_name("with () { @a += 1 }"), "ReferenceError"); + assert_eq!(thrown_name("with () { @a++ }"), "ReferenceError"); + assert_eq!(thrown_name("var ns = new Namespace('u'); with () { ns::c = 1 }"), "ReferenceError"); + truth("var n = 0; var e = ''; try { with () { @a = (n++, 1); } } catch (err) { e = err.name; } e === 'ReferenceError' && n === 0"); + truth( + "var b = 'g'; var x = ; var e = ''; try { with (x) { @b = 'w'; } } catch (err) { e = err.name; } \ + e === 'ReferenceError' && b === 'g' && x.@b.length() === 0 && x.*.length() === 0", + ); +} + +#[test] +fn bare_name_read_without_holder_throws_reference_error() { + assert_eq!(thrown_name("with () { @a }"), "ReferenceError"); + assert_eq!(thrown_name("var l = <>; l.(@id == '1')"), "ReferenceError"); + truth("var o = ; var l = <>; var r; with (o) { r = l.(@id == '1'); } r.length() === 1"); +} + +#[test] +fn bare_scope_compound_and_updates_resolve_once() { + truth("var n = 0; var x = ; with (x) { @[(n++, 't')] += 1; } n === 1 && x.@t == '11'"); + truth("var n = 0; var x = ; with (x) { @[(n++, 't')]++; } n === 1 && x.@t == '2'"); + truth("var x = ; var r; with (x) { r = ++@t; } r === 2 && x.@t == '2'"); + truth("var x = ; var r; with (x) { r = @t++; } r === 1 && x.@t == '2'"); + truth("var x = ; var r; with (x) { r = --@t; } r === 4 && x.@t == '4'"); + truth("var x = ; var r; with (x) { r = @t--; } r === 5 && x.@t == '4'"); +} + +#[test] +fn bare_scope_delete_removes_attribute() { + truth("var x = ; var r; with (x) { r = delete @t; } r === true && x.@t.length() === 0 && x.@u == '2'"); + truth("var x = ; with (x) { delete *::c; } x.c.length() === 0 && x.d.length() === 1"); + assert_eq!(thrown_name("with () { delete @t }"), "ReferenceError"); +} + +#[test] +fn bare_scope_for_targets_assign_each_iteration() { + truth("var x = ; var s = ''; with (x) { for (@k in { p: 1, q: 2 }) s += @k; } s === 'pq' && x.@k == 'q'"); + truth("var x = ; var s = ''; with (x) { for each (@k in [3, 4]) s += @k; } s === '34' && x.@k == '4'"); + assert_eq!(thrown_name("with () { for (@k in { p: 1 }); }"), "ReferenceError"); +} + +#[test] +fn bare_scope_targets_need_e4x_option() { + for src in ["@a = 1", "ns::a = 1", "@a += 1", "++@a", "@a--", "for (@a in o);", "for each (@a in o);", "\"use strict\"; @a = 1"] { + assert!(parse(src, &Options::default()).is_err(), "{src}"); + assert!(parse(src, &e4x()).is_ok(), "{src}"); + } + truth("'use strict'; var l = <>; l.(@b = '2', true); l[0].@b == '2'"); +} + #[test] fn a_bracket_key_converts_to_a_string_before_it_names_a_child() { truth("var x = 1; x[true] == '1' && (true in x) && !(false in x)"); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 18d50447ff..ce1200ef6a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -764,6 +764,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - `vectorcraft-js` provides the ECMAScript 5.1 built-ins `Object` with its property-descriptor functions, `create`, `keys`, `seal` and `freeze` (section 15.2), `Function.prototype.bind` (15.3.4.5), `Array` with every ES5 method (15.4), the error types (15.11), `Math` (15.8), `JSON` with revivers, replacers and indentation (15.12), and the global functions with Annex B's `escape` and `unescape` (15.1, B.2). The array methods work on array-like objects. A method visits only the indices that exist when its range is longer than 4,096 indices and more than 8 times the properties on the object and its prototypes, so `new Array(4294967295).join("")` ends at once; a shorter range is visited index by index, holes included, and each index takes fuel. `join` writes a cycle back to an array as an empty string, `JSON.stringify` throws a `TypeError` on a cycle, and deep `JSON` nesting uses no native stack. Every loop takes fuel and every result is checked against the heap limit. `cargo xtask test262` runs the suite's ES5 built-in tests that need only these built-ins: 6,798 of 6,866 runs pass (the 34 failing tests expect rules of later editions). It also fetches the `json2.js` polyfill at a pinned commit and checks that its `stringify` and `parse` agree with the built-in `JSON`. Tests: `crates/js/src/builtins_tests.rs`, `crates/js/tests/test262.rs`, `crates/js/tests/json2.rs`. - `vectorcraft-js` has `String`, `Number`, `RegExp` and `Date` as ECMAScript 5.1 defines them (sections 15.5, 15.7, 15.9 and 15.10, https://262.ecma-international.org/5.1/), with Annex B's `substr`, `getYear`, `setYear` and `toGMTString`. `toFixed`, `toExponential` and `toPrecision` format a number from its exact decimal value, and `toString(radix)` writes any base from 2 to 36. Regular expressions (flags `g`, `i` and `m`) run on a backtracking matcher whose choice points and capture state count against the heap limit and which takes fuel for every step, so a catastrophic pattern stops at the fuel limit and 50,000 nested groups give a `SyntaxError` at the default depth limit. A string method that would build a string past the heap limit stops before it allocates. `Date` reads the clock and time zone the host sets (`Realm::set_clock`, `Realm::set_time_zone`; local time is UTC by default). `cargo xtask test262` runs the suite's `built-ins` tests for the four (https://github.com/tc39/test262): of the runs of the ES5 tests that need only the built-ins present, 1,379 of 1,411 pass for `String`, 298 of 298 for `Number`, 954 of 988 for `RegExp` and 8 of 8 for `Date`. The others expect later editions' rules (built-in functions whose `length` can be deleted, flags given with a RegExp object, a negative `lastIndex`, `RegExp.prototype` as a plain object, and `RegExp.prototype`'s `global`, `ignoreCase` and `multiline` properties that can be deleted). Tests: `crates/js/src/text_tests.rs`, `crates/js/tests/test262.rs`. - `vectorcraft-js` runs ECMAScript for XML (ECMA-357, 2nd edition, https://ecma-international.org/publications-and-standards/standards/ecma-357/) when `Options::e4x` is set, which it is not by default: XML and XMLList literals with embedded `{}` expressions, the `..`, `.@`, `.*`, `.()` and `::` selectors, assignment and `delete` on selected nodes, `for each`, `default xml namespace`, and the `XML`, `XMLList`, `Namespace` and `QName` objects with their methods and settings (`xpath` is not provided). `` in an XML string gives a `SyntaxError`. Parsing, serializing, comparing and copying trees use no native stack, and each takes fuel and counts against the heap limit. Inserting a node into itself or its descendants throws a `TypeError` before the tree changes, a literal or XML string nested past `Options::max_depth` gives a `SyntaxError`, and a tree built past that depth stops at the heap limit. Tests: `crates/js/src/xml_tests.rs`. + - With `Options::e4x` set, an E4X name standing alone (`@a`, `@ns::a`, `@[expr]`, `ns::a`, `*`) inside `with` or a filter predicate can be assigned, with `=` or a compound operator, incremented, decremented, deleted and used as a `for` or `for each` variable, as well as read. Each of these uses the nearest XML or XMLList value in scope that holds the name and evaluates the name once. When no XML value in scope holds the name, the read or write throws a `ReferenceError` before the right-hand side runs and creates no child or attribute, as ECMA-357 section 11.1 (https://ecma-international.org/publications-and-standards/standards/ecma-357/) specifies, so a filter such as `list.(@id == '1')` throws when an item has no `@id` and no outer XML value holds one. Tests: `crates/js/src/xml_tests.rs`. - **UI:** Illustrator 2026 layout restyled to measured values: - Medium Dark theme, categorized and Advanced toolbars (a tool group's flyout opens on a right press, a long press or a press on its corner triangle), 35 pt document tabs, 33 pt panel tabs. - Hint bar, contextual task bar, 19 dock panels with ≡ menus. From 7c28138ce3ca14edf62990880157eb609e8802d6 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 19:57:39 -0800 Subject: [PATCH 04/43] Commands: pointer packets are read whole before any event runs A headless or control channel pointer packet now checks every event's kind, coordinates and mods before it sends any of them, so a malformed later event no longer leaves the earlier ones applied. A mods value that is not an object, such as an array, is refused. Rotate View takes null mods as omitted. The MCP docs name the params that refuse null. --- crates/mcp/src/headless.rs | 37 +++++++++++++------------- crates/mcp/src/tests.rs | 5 ++++ crates/ui-egui/src/control.rs | 29 +++++++++++--------- crates/ui-egui/src/control/tests.rs | 24 +++++++++++++++++ crates/ui-egui/src/tests/rotateview.rs | 3 ++- docs/mcp.md | 4 ++- 6 files changed, 70 insertions(+), 32 deletions(-) diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 0377cd5ae1..cf150623ef 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -71,7 +71,7 @@ fn opt_bool(p: &Value, k: &str) -> Result, String> { /// 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") + opt(p, "mods", |m| m.as_object().and_then(|_| serde_json::from_value(m.clone()).ok()), "an object of true or false values") } pub(crate) fn pointer_kind(k: &str) -> Option { @@ -231,25 +231,26 @@ impl Headless { 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>>()?; + // Every event is read before any is sent, so a malformed packet sends none of them. + let read = events + .iter() + .zip(held) + .map(|(e, backquote)| { + let mods = packet_mods(e)?.unwrap_or(base); + 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") { + return Err("screen-space pointer events need the desktop app; use document coordinates".to_string()); + } + 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`")?; + Ok((e, kind, Point::new(x, y), mods, backquote)) + }) + .collect::, String>>()?; self.ensure_document(); let mut requests = vec![]; - 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") { - return Err("screen-space pointer events need the desktop app; use document coordinates".into()); - } - 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 ev = PointerEvent { - kind, - pos: Point::new(x, y), - mods, - backquote, - pressure: PointerEvent::json_pressure(e), - pen: PointerEvent::json_pen(e), - }; + for (e, kind, pos, mods, backquote) in read { + let ev = PointerEvent { kind, pos, mods, backquote, pressure: PointerEvent::json_pressure(e), pen: PointerEvent::json_pen(e) }; let reqs = self.session.pointer(&ev, self.view).map_err(|e| e.to_string())?; self.apply_ui_requests(reqs, &mut requests)?; // With no frames to show the highlight or apply an edit in later, each event waits for diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index dbe429a281..86cc6228a0 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -1461,6 +1461,7 @@ fn headless_methods_refuse_params_of_the_wrong_type() { 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}]}); + let after_rect = |last: Value| json!({"events": [{"kind": "down", "x": 10, "y": 10}, {"kind": "drag", "x": 60, "y": 60}, {"kind": "up", "x": 60, "y": 60}, last]}); for (method, params, key) in [ ("ui.render", json!({"artboard": "0"}), "artboard"), ("ui.render", json!({"scale": "2"}), "scale"), @@ -1469,6 +1470,10 @@ fn headless_methods_refuse_params_of_the_wrong_type() { ("ui.key", json!({"key": "Delete", "shift": "true"}), "shift"), ("ui.pointer", drag(json!({"shift": "yes"})), "mods"), ("ui.pointer", json!({"mods": 1, "events": []}), "mods"), + ("ui.pointer", drag(json!([true])), "mods"), + // A whole rectangle comes before the malformed event, and none of it is drawn. + ("ui.pointer", after_rect(json!({"kind": "move", "x": "bad", "y": 0})), "x"), + ("ui.pointer", after_rect(json!({"kind": "wiggle", "x": 0, "y": 0})), "wiggle"), ] { let e = h.call(method, params.clone()).unwrap_err(); assert!(e.contains(&format!("`{key}`")), "{method} {params}: {e}"); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 77e931bdc1..34eaa7944e 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -509,6 +509,7 @@ fn coord(p: &Value, key: &str, default: f32) -> Result { fn packet_mods(value: Option<&Value>) -> Result, String> { match value { None | Some(Value::Null) => Ok(None), + Some(m) if !m.is_object() => Err("`mods` must be an object of true or false values".to_string()), 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}")), } } @@ -920,7 +921,7 @@ fn rotate_view_pointer( /// A Rotate View packet accepts only the documented boolean modifier fields. fn validate_rotate_view_mods(value: Option<&Value>) -> Result<(), String> { - let Some(value) = value else { return Ok(()) }; + let Some(value) = value.filter(|value| !value.is_null()) else { return Ok(()) }; let Some(fields) = value.as_object() else { return Err("Rotate View `mods` must be an object of booleans".into()) }; if fields.iter().any(|(key, value)| !matches!(key.as_str(), "shift" | "alt" | "cmd" | "ctrl" | "space") || !value.is_boolean()) { return Err("Rotate View `mods` accepts only boolean shift, alt, cmd, ctrl and space".into()); @@ -975,8 +976,16 @@ fn pointer_events_inner(app: &mut VectorcraftApp, ctx: &egui::Context, req: &Con events .iter() .map(|e| { + let kind = match e.get("kind").and_then(Value::as_str).unwrap_or("") { + "down" => PointerKind::Down, + "drag" => PointerKind::Drag, + "up" => PointerKind::Up, + "move" => PointerKind::Move, + "doubleclick" | "dblclick" => PointerKind::DoubleClick, + other => return Err(format!("unknown pointer kind `{other}`")), + }; 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))) + Ok((kind, at("x")?, at("y")?, packet_mods(e.get("mods"))?.unwrap_or(base))) }) .collect::, String>>() }); @@ -986,6 +995,10 @@ fn pointer_events_inner(app: &mut VectorcraftApp, ctx: &egui::Context, req: &Con }; let view = app.view_info(); let xf = app.canvas_rect.zip(app.view().copied()).map(|(rect, v)| Xf::new(rect, &v)); + let screen = |e: &Value| e.get("space").and_then(Value::as_str) == Some("screen"); + if !rotating && xf.is_none() && events.iter().any(screen) { + return err("no canvas yet"); + } let from = app.session.input_count(); let held = PointerEvent::json_backquote(p, false).and_then(|d| events.iter().map(|e| PointerEvent::json_backquote(e, d)).collect::, _>>()); @@ -993,16 +1006,8 @@ fn pointer_events_inner(app: &mut VectorcraftApp, ctx: &egui::Context, req: &Con Ok(held) => held, Err(e) => return err(e), }; - 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, - "up" => PointerKind::Up, - "move" => PointerKind::Move, - "doubleclick" | "dblclick" => PointerKind::DoubleClick, - other => return err(format!("unknown pointer kind `{other}`")), - }; - let screen = e.get("space").and_then(Value::as_str) == Some("screen"); + for ((e, backquote), (kind, x, y, mods)) in events.iter().zip(held).zip(read) { + let screen = screen(e); if rotating { if let Err(e) = rotate_view_pointer(app, kind, vectorcraft_geom::Point::new(x, y), screen, mods.shift) { return err(e); diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index 2cdf13a4d7..021365d817 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -349,6 +349,7 @@ fn control_input_refuses_params_of_the_wrong_type() { ("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", move_to(json!([true])), "mods"), ("ui.pointer", json!({"mods": "shift", "events": [{"kind": "move", "x": 1, "y": 1}]}), "mods"), ] { let mut app = VectorcraftApp::new(Session::new(), Services::default()); @@ -456,6 +457,29 @@ fn synthetic_pointer_rejects_a_backquote_that_is_not_a_boolean() { } } +/// `ui.pointer` refuses an unknown event kind, or a screen-space event with no canvas, before any +/// of the packet's events runs. +#[test] +fn synthetic_pointer_rejects_an_unknown_kind_before_any_event_runs() { + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + app.run("file.new", json!({"width": 400, "height": 400})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + assert_eq!(input_request(&mut app, "ui.tool.select", json!({"tool": "selection"}))["ok"], true); + let before = app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().clone(); + let moves = [json!({"kind": "down", "x": 125, "y": 125}), json!({"kind": "drag", "x": 145, "y": 125}), json!({"kind": "up", "x": 145, "y": 125})]; + for (last, error) in [ + (json!({"kind": "wiggle", "x": 145, "y": 125}), "unknown pointer kind `wiggle`"), + (json!({"kind": "move", "x": 145, "y": 125, "space": "screen"}), "no canvas yet"), + ] { + let events = moves.iter().cloned().chain([last]).collect::>(); + let r = input_request(&mut app, "ui.pointer", json!({"events": events})); + assert_eq!(r["ok"], false, "{error}: {r}"); + assert!(r["error"].as_str().is_some_and(|e| e.contains(error)), "{r}"); + assert_eq!(app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap(), &before, "{error}: the drag does not run"); + } +} + /// `ui.pointer` replies with the key object: a click on one of two selected objects makes it /// the key, and a click on the key again lets it go. #[test] diff --git a/crates/ui-egui/src/tests/rotateview.rs b/crates/ui-egui/src/tests/rotateview.rs index dbdb0283fd..715610f08e 100644 --- a/crates/ui-egui/src/tests/rotateview.rs +++ b/crates/ui-egui/src/tests/rotateview.rs @@ -117,7 +117,8 @@ fn control_rotate_view_keeps_press_coordinates_and_shift_snapping_across_request send(&mut app, &ctx, "ui.pointer", json!({"mods": {"shift": true}, "events": [{"kind": "drag", "x": x, "y": y}]})); assert_eq!(app.view().unwrap().rotation, 45.0, "the same document point does not turn again as the view changes"); } - send(&mut app, &ctx, "ui.pointer", json!({"events": [{"kind": "up", "x": x, "y": y}]})); + // Null `mods` keep the default, as an omitted value does. + send(&mut app, &ctx, "ui.pointer", json!({"mods": null, "events": [{"kind": "up", "x": x, "y": y, "mods": null}]})); assert_eq!(app.view().unwrap().rotation, 45.0); } diff --git a/docs/mcp.md b/docs/mcp.md index 7ced9eb156..078488e9d4 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1271,7 +1271,9 @@ type and change nothing: `view.setZoom` (`zoom`, `center`), `view.screenMode` (` `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: +calls. Omitting a param or giving null keeps its default, with two exceptions: `ui.click`'s `count` and `ui.drag`'s +`count` and `steps` refuse null, and a headless pointer event needs its `x` and `y`. A pointer packet with one +malformed event sends none of its events: ```json {"id": "view.setZoom", "params": {"zoom": "200"}} From e2eb364f836f85d672fb89172483a6a4b97c08f7 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 20:35:25 -0800 Subject: [PATCH 05/43] Scripts: operators, JSON.stringify and join count a flattened string against the heap limit Flattening a concatenated string copies its units into a new buffer. The string built-ins reserve that copy with flat_charged, but several other paths flattened without it, so a script could build copies the heap limit never saw: - the relational operators (`<`, `>`, `<=`, `>=`) on two strings, `==` between a string and a number, unary `+` and every other ToNumber of a string, and reading a character by index (`s[0]`) - a property name made from an object whose toString returns a long string, and a long string used as a property name, which now reserves its copy before it is made instead of after - direct `eval` of a concatenated source - `JSON.stringify` of a string value and its `space` argument - `Array.prototype.join`'s separator and each string element it writes Property names and eval source reserve the copy's heap bytes without taking fuel. --- crates/js/src/builtins/array.rs | 6 +++-- crates/js/src/builtins/json.rs | 8 +++++-- crates/js/src/interp.rs | 40 +++++++++++++++++++++------------ crates/js/src/text_tests.rs | 17 ++++++++++++++ 4 files changed, 53 insertions(+), 18 deletions(-) diff --git a/crates/js/src/builtins/array.rs b/crates/js/src/builtins/array.rs index 2f81bf7666..263656437c 100644 --- a/crates/js/src/builtins/array.rs +++ b/crates/js/src/builtins/array.rs @@ -13,6 +13,7 @@ use super::{ }; use crate::heap::{Class, NativeKind, ObjId}; use crate::interp::{Abrupt, Interp, JsResult, strict_eq}; +use crate::string_builtins::flat_charged; use crate::value::{Str, Value, to_uint32}; pub(super) fn install(interp: &mut Interp) -> JsResult<()> { @@ -278,7 +279,7 @@ fn join_with(interp: &mut Interp, o: ObjId, separator: &Value, item: fn(&mut Int if len == 0 { return Ok(Value::str("")); } - let flat = sep.flat(); + let flat = flat_charged(interp, &sep)?; let sep = &flat[..]; // A result that cannot fit is refused before any element is read. let least = (sep.len() as u64).saturating_mul(u64::from(len - 1)); @@ -301,7 +302,8 @@ fn join_elements(interp: &mut Interp, o: ObjId, len: u32, sep: &[u16], item: fn( let Some(s) = item(interp, v)? else { continue }; out.push_repeated(interp, sep, k.saturating_sub(seps))?; seps = k; - out.push_units(interp, &s.flat())?; + let units = flat_charged(interp, &s)?; + out.push_units(interp, &units)?; } out.push_repeated(interp, sep, u64::from(len - 1).saturating_sub(seps))?; Ok(out.finish(interp)) diff --git a/crates/js/src/builtins/json.rs b/crates/js/src/builtins/json.rs index 4375303fe0..991495ba9d 100644 --- a/crates/js/src/builtins/json.rs +++ b/crates/js/src/builtins/json.rs @@ -13,6 +13,7 @@ use std::collections::HashSet; use super::{Builder, ELEMENT_BYTES, arg, data, key_of, length_of, method, new_array, new_string, to_integer}; use crate::heap::{Class, Key, OBJECT_BYTES, OPEN, ObjId, Object, Prop, key, string_bytes}; use crate::interp::{Desc, ErrorType, Hint, Interp, JsResult}; +use crate::string_builtins::flat_charged; use crate::value::{Str, Value, number_to_str}; pub(super) fn install(interp: &mut Interp) -> JsResult<()> { @@ -473,7 +474,7 @@ fn settings(interp: &mut Interp, replacer: &Value, space: &Value) -> JsResult s.gap = t.flat().iter().take(10).copied().collect(), + Value::String(t) => s.gap = flat_charged(interp, &t)?.iter().take(10).copied().collect(), _ => {} } Ok(s) @@ -632,7 +633,10 @@ fn write_primitive(interp: &mut Interp, out: &mut Builder, v: &Value) -> JsResul Value::Bool(b) => out.push_str(interp, if *b { "true" } else { "false" }), Value::Number(n) if n.is_finite() => out.push_units(interp, &number_to_str(*n).flat()), Value::Number(_) => out.push_str(interp, "null"), - Value::String(s) => write_quoted(interp, out, &s.flat()), + Value::String(s) => { + let units = flat_charged(interp, s)?; + write_quoted(interp, out, &units) + } Value::Undefined | Value::Object(_) => Ok(()), } } diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index afb6cde635..afca1a1024 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -26,6 +26,7 @@ use crate::heap::{ ArgMap, Binding, CONFIGURABLE, Class, ENUMERABLE, ENV_BYTES, Env, EnvId, ForIn, Func, HIDDEN, Heap, Key, NativeKind, OBJECT_BYTES, OPEN, ObjId, Object, PROP_BYTES, Prop, PropMap, ROPE_BYTES, Roots, WRITABLE, code_bytes, key, string_bytes, }; +use crate::string_builtins::flat_charged; use crate::value::{Str, Value, array_index, index_key, number_to_str, string_to_number, to_int32, to_uint32}; use crate::{LimitKind, Limits, Options}; @@ -1290,7 +1291,7 @@ impl Interp { if let Some(i) = array_index(k) && (i as usize) < s.len() { - let unit = s.flat().get(i as usize).copied().unwrap_or(0); + let unit = flat_charged(self, s)?.get(i as usize).copied().unwrap_or(0); return Ok(Value::String(Str::from_units(vec![unit]))); } } @@ -2397,7 +2398,7 @@ impl Interp { }; let strict = self.strict(); self.check_source_units(s.len()).map_err(|e| self.compile_error_to_abrupt(e))?; - let units = s.flat(); + let units = self.flat_reserved(s)?; let code = self.compile_source(&units, strict, CodeKind::Eval).map_err(|e| self.compile_error_to_abrupt(e))?; self.charge_code(&code, declaration_bytes(&code).saturating_add(ENV_BYTES))?; let frame = self.frame()?; @@ -2562,7 +2563,7 @@ impl Interp { Value::Null => 0.0, Value::Bool(b) => f64::from(u8::from(*b)), Value::Number(n) => *n, - Value::String(s) => string_to_number(&s.flat()), + Value::String(s) => string_to_number(&flat_charged(self, s)?), Value::Object(_) => { let p = self.to_primitive(v, Hint::Number)?; return self.to_number(&p); @@ -2596,19 +2597,27 @@ impl Interp { #[allow(clippy::wrong_self_convention)] pub(crate) fn to_key(&mut self, v: &Value) -> JsResult { match v { - Value::String(s) => { - let rope = s.is_rope(); - let flat = s.flat(); - if rope { - self.heap.bytes += string_bytes(flat.len()); - } - Ok(flat) - } + Value::String(s) => self.flat_reserved(s), Value::Number(n) if *n >= 0.0 && *n < 4_294_967_295.0 && n.fract() == 0.0 => Ok(index_key(*n as u32)), - _ => Ok(self.to_str(v)?.flat()), + _ => { + let s = self.to_str(v)?; + self.flat_reserved(&s) + } } } + /// The code units of `s`. Flattening a rope makes a copy of its units, so for a rope that + /// copy's bytes are reserved and added to the heap first, without taking fuel. Everything + /// the caller still needs must be reachable from the roots. + fn flat_reserved(&mut self, s: &Str) -> JsResult { + if s.is_rope() { + let bytes = string_bytes(s.len()); + self.reserve_heap(bytes)?; + self.heap.bytes = self.heap.bytes.saturating_add(bytes); + } + Ok(s.flat()) + } + /// ToObject (section 9.9). #[allow(clippy::wrong_self_convention)] pub(crate) fn to_object(&mut self, v: &Value) -> JsResult { @@ -2731,7 +2740,8 @@ impl Interp { (self.to_primitive(x, Hint::Number)?, py) }; if let (Value::String(a), Value::String(b)) = (&px, &py) { - return Ok(Some(*a.flat() < *b.flat())); + let (a, b) = (flat_charged(self, a)?, flat_charged(self, b)?); + return Ok(Some(*a < *b)); } let nx = self.to_number(&px)?; let ny = self.to_number(&py)?; @@ -2756,7 +2766,9 @@ impl Interp { | (Value::String(_), Value::String(_)) | (Value::Bool(_), Value::Bool(_)) | (Value::Object(_), Value::Object(_)) => return Ok(strict_eq(&x, &y)), - (Value::Number(n), Value::String(s)) | (Value::String(s), Value::Number(n)) => return Ok(*n == string_to_number(&s.flat())), + (Value::Number(n), Value::String(s)) | (Value::String(s), Value::Number(n)) => { + return Ok(*n == string_to_number(&flat_charged(self, s)?)); + } (Value::Bool(v), _) => x = Value::Number(f64::from(u8::from(*v))), (_, Value::Bool(v)) => y = Value::Number(f64::from(u8::from(*v))), (Value::Number(_) | Value::String(_), Value::Object(_)) => y = self.to_primitive(&y, Hint::Default)?, diff --git a/crates/js/src/text_tests.rs b/crates/js/src/text_tests.rs index c873bdaf59..3134e318df 100644 --- a/crates/js/src/text_tests.rs +++ b/crates/js/src/text_tests.rs @@ -309,6 +309,23 @@ fn flattening_a_rope_counts_against_the_heap() { } } +#[test] +fn operators_and_builtins_count_a_flattened_rope_against_the_heap() { + // Three ropes of about 524,288 units, each 1 MiB once flat, with a 3 MiB heap. + let build = "var s = 'x'; for (var i = 0; i < 19; i++) s = s + s; var t = s + 'y', u = s + 'z';"; + let limits = Limits { heap_bytes: 3 << 20, ..Limits::default() }; + let not_stopped: Vec<_> = + ["X < 'a'", "X == 1", "+X", "X[0]", "({})[{ toString: function () { return X; } }]", "JSON.stringify(0, null, X)", "[1].join(X)"] + .into_iter() + .filter(|read| { + let src = format!("{build} {}; {}; {}; 0", read.replace('X', "s"), read.replace('X', "t"), read.replace('X', "u")); + let result = Realm::with_options(Options::default(), limits.clone()).run(&src); + !matches!(result, Err(ref e) if e.kind == RunErrorKind::Limit(LimitKind::Heap)) + }) + .collect(); + assert!(not_stopped.is_empty(), "not stopped by the heap limit: {not_stopped:?}"); +} + #[test] fn compiling_a_pattern_counts_against_the_heap() { let src = "var s = '[\\\\s\\\\S]a*'; for (var i = 0; i < 14; i++) s = s + s; new RegExp(s).source.length"; From b172e3672dd1153050c756dec44f8db896eddfbb Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 20:35:23 -0800 Subject: [PATCH 06/43] JavaScript: for each evaluates its target before reading the value A for each loop read the property's value before evaluating its target, so a target such as (o.p = 2, t).v, t[(o.p = 3, 'w')] or a bare @[(o.p = 2, 'k')] stored the value from before the target ran. The target's object and name are now evaluated first and the value is read after them (ECMA-357 section 12.3). --- crates/js/src/bytecode.rs | 7 +++--- crates/js/src/compiler.rs | 51 +++++++++++++++++++++++++++++++++++--- crates/js/src/interp.rs | 2 +- crates/js/src/xml.rs | 8 +++--- crates/js/src/xml_tests.rs | 8 ++++++ 5 files changed, 65 insertions(+), 11 deletions(-) diff --git a/crates/js/src/bytecode.rs b/crates/js/src/bytecode.rs index 72bd9ef90e..6992fd5606 100644 --- a/crates/js/src/bytecode.rs +++ b/crates/js/src/bytecode.rs @@ -173,8 +173,9 @@ pub(crate) enum Op { XmlFilterTest, /// `state` → the XMLList of the items kept. XmlFilterEnd, - /// `iterator name` → `iterator value`: the value under the name a `for each` got. - ForEachValue, + /// `iterator name x₁…xₙ` → `iterator name x₁…xₙ value`: the value under the name a `for each` + /// got, read after the `n` values its target pushed. + ForEachValue(u32), /// `value` → nothing: sets the default XML namespace of the running code. SetDefaultNamespace, } @@ -312,7 +313,7 @@ fn effect(op: Op, finally_tables: &[Vec]) -> Result { Op::XmlEscape(_) | Op::XmlScopeGet | Op::XmlFilterStart | Op::XmlFilterEnd => e(1, 1), Op::XmlLiteral { parts, .. } => e(parts, 1), Op::XmlScopeRef => e(1, 2), - Op::ForEachValue => e(2, 2), + Op::ForEachValue(n) => e(n + 2, n + 3), Op::SetDefaultNamespace => e(1, 0), }) } diff --git a/crates/js/src/compiler.rs b/crates/js/src/compiler.rs index effb475f92..3023e3b127 100644 --- a/crates/js/src/compiler.rs +++ b/crates/js/src/compiler.rs @@ -497,16 +497,21 @@ impl<'a> FnCompiler<'a> { let brk = self.new_label(); self.bind(head); self.jump(Op::ForInNext, brk); - if each { - self.emit(Op::ForEachValue); - } match target { ForInTarget::Var(d) => { let n = self.var_name(&d.name.name); + if each { + self.emit(Op::ForEachValue(0)); + } self.emit(Op::SetVar(n)); } + ForInTarget::Expr(e) if each => self.store_value_to(*e)?, ForInTarget::Expr(e) => self.store_key_to(*e)?, } + if each { + // The value; the name under it goes next. + self.emit(Op::Pop); + } self.emit(Op::Pop); self.loop_body(body, labels, brk, head)?; self.jump(Op::Jump, head); @@ -552,6 +557,46 @@ impl<'a> FnCompiler<'a> { Ok(()) } + /// Assigns the value under the name on top of the stack to a `for each` target expression, + /// leaving the name and then the value on top. The target is evaluated before the value is + /// read (ECMA-357 section 12.3). + fn store_value_to(&mut self, target: ExprId) -> CResult<()> { + let target = self.ast.unparenthesized(target); + self.at(self.ast.expr_span(target).start); + match self.ast.expr(target) { + Some(Expr::Ident(name)) => { + let n = self.var_name(name); + self.emit(Op::ForEachValue(0)); + self.emit(Op::SetVar(n)); + } + Some(Expr::Member { object, property }) => { + self.expr(*object)?; + self.emit(Op::ForEachValue(1)); + let n = self.name_key(key_of(&property.name)); + self.emit(Op::SetMember(n)); + } + Some(Expr::Index { object, index }) => { + self.expr(*object)?; + self.expr(*index)?; + self.emit(Op::ToKey); + self.emit(Op::ForEachValue(2)); + self.emit(Op::SetIndex); + } + Some(Expr::XmlScopeName(name)) => { + self.xml_scope_ref(target, *name)?; + self.emit(Op::ForEachValue(2)); + self.emit(Op::SetIndex); + } + _ => { + self.expr(target)?; + self.emit(Op::Pop); + self.emit(Op::ForEachValue(0)); + self.throw_invalid_target(); + } + } + Ok(()) + } + fn throw_invalid_target(&mut self) { let s = self.string(&"invalid assignment target".encode_utf16().collect::>()); let depth = self.depth; diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index b8213fbaf3..54fd3431a2 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -927,7 +927,7 @@ impl Interp { Op::XmlFilterNext(t) => self.op_xml_filter_next(t), Op::XmlFilterTest => self.op_xml_filter_test(), Op::XmlFilterEnd => self.op_xml_filter_end(), - Op::ForEachValue => self.op_for_each_value(), + Op::ForEachValue(n) => self.op_for_each_value(n as usize), Op::SetDefaultNamespace => self.op_set_default_namespace(), Op::Return | Op::ReturnCompletion => internal("return reached the dispatcher"), } diff --git a/crates/js/src/xml.rs b/crates/js/src/xml.rs index 9104ba83f7..8ef2e3c4c1 100644 --- a/crates/js/src/xml.rs +++ b/crates/js/src/xml.rs @@ -2895,9 +2895,9 @@ impl Interp { self.replace(1, Value::Object(out)) } - pub(crate) fn op_for_each_value(&mut self) -> JsResult<()> { - let name = self.peek(0)?; - let Value::Object(iter) = self.peek(1)? else { return Err(Abrupt::Internal("for-in iterator is not an object".into())) }; + pub(crate) fn op_for_each_value(&mut self, above: usize) -> JsResult<()> { + let name = self.peek(above)?; + let Value::Object(iter) = self.peek(above + 1)? else { return Err(Abrupt::Internal("for-in iterator is not an object".into())) }; let object = match self.heap.object(iter).map(|o| &o.class) { Some(Class::ForIn(state)) => state.object, _ => return Err(Abrupt::Internal("not a for-in iterator".into())), @@ -2909,7 +2909,7 @@ impl Interp { } None => Value::Undefined, }; - self.replace(1, v) + self.push(v) } /// The URI of a Namespace value or of its string form. diff --git a/crates/js/src/xml_tests.rs b/crates/js/src/xml_tests.rs index 96ac9c3aed..82666cd41f 100644 --- a/crates/js/src/xml_tests.rs +++ b/crates/js/src/xml_tests.rs @@ -716,6 +716,14 @@ fn bare_scope_for_targets_assign_each_iteration() { assert_eq!(thrown_name("with () { for (@k in { p: 1 }); }"), "ReferenceError"); } +#[test] +fn for_each_evaluates_its_target_before_reading_the_value() { + truth("var o = { p: 1 }; var t = {}; for each ((o.p = 2, t).v in o); t.v === 2"); + truth("var o = { p: 1 }; var t = {}; for each (t[(o.p = 3, 'w')] in o); t.w === 3"); + truth("var x = ; var o = { p: 1 }; with (x) { for each (@[(o.p = 2, 'k')] in o); } x.@k == '2'"); + truth("var o = { p: 1, q: 2 }; var s = ''; for each (var v in o) s += v; var t; for each (t in o) s += t; s === '1212' && t === 2"); +} + #[test] fn bare_scope_targets_need_e4x_option() { for src in ["@a = 1", "ns::a = 1", "@a += 1", "++@a", "@a--", "for (@a in o);", "for each (@a in o);", "\"use strict\"; @a = 1"] { From cd262fa6e805f5eab43ec8a955524c6a99ce7611 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 20:42:34 -0800 Subject: [PATCH 07/43] MCP: save, export and package replies leave out paths outside a confined client's read roots A client confined with --automation-read-root could learn paths it was not given and may not read from the replies of several commands. Each one now names such a path only when no read roots are in force or the path is inside them: - document.save and file.save, file.saveAs, file.saveCopy and file.saveAsTemplate (app.save and engine.execute) without a path: the document's own file, the files of separate artboards and linked files are left out or named by file name, and the folder a save suggests is left out. - document.exportForScreens with the folder of an exportPreset: the ZIP path is left out and files are relative to that folder. - document.exportSettings, document.exportPresets.list and document.exportPresets.export: a kept folder outside the read roots is left out of the reply and of the presets file the export writes. - file.package: warnings, missingLinks and the report name a file outside the read roots by its file name. A link's stored path, read from the document's folder when it is relative, decides, also when its file was found under another path. - document.info: an image linked to a file outside the read roots has link null, and its Linked Images row names the file alone. docs/mcp.md and the params text describe the rule. --- crates/engine/src/cmd/docinfo.rs | 21 +++- crates/engine/src/cmd/fileio.rs | 10 +- crates/engine/src/cmd/fileio/access.rs | 23 +++++ crates/engine/src/cmd/fileio/commands.rs | 8 +- crates/engine/src/cmd/fileio/save.rs | 40 +++++++- crates/engine/src/cmd/fileio/screens.rs | 49 +++++++++- crates/engine/src/cmd/fileio/tests.rs | 13 +++ crates/engine/src/cmd/fileio/tests_screens.rs | 77 +++++++++++++++ crates/engine/src/cmd/package.rs | 39 +++++--- crates/engine/src/tests/package.rs | 97 +++++++++++++++++++ crates/engine/src/tests/save.rs | 44 +++++++++ crates/ui-egui/src/menus/run.rs | 18 +++- crates/ui-egui/src/tests/automation.rs | 21 ++++ docs/mcp.md | 11 +++ docs/target-app-parity.md | 1 + 15 files changed, 435 insertions(+), 37 deletions(-) diff --git a/crates/engine/src/cmd/docinfo.rs b/crates/engine/src/cmd/docinfo.rs index 7499e14943..ff1fbe3591 100644 --- a/crates/engine/src/cmd/docinfo.rs +++ b/crates/engine/src/cmd/docinfo.rs @@ -1,7 +1,7 @@ //! Document Info panel data, its categories and the text report (Document Info › Save…, File → //! Package), and Object → Make Pixel Perfect. -use std::collections::{BTreeMap, BTreeSet, HashSet}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use serde_json::{Value, json}; use vectorcraft_color::Paint; @@ -18,7 +18,7 @@ pub fn specs() -> Vec { "Document Info", ["Window", "Document Info"], None, - "{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the Brushes panel's brushes; with selectionOnly: the brushes the selected objects' strokes use), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), patternObjects: [{id, name, patterns}] (the objects filled or stroked with a pattern, each once, those in groups and type whose drawn characters use one included, hidden fills and strokes, strokes with no width, guides, compound path members' own fills and strokes, images' fills and strokes without a shape and characters that draw nothing left out, with the patterns they use, which objects.patterns counts; with selectionOnly: those in the selection), patternNames (the patterns those objects use), gradientObjects: [{id, name, gradients}] (the objects filled or stroked with a gradient, by the same rules, with the gradients they use: a linked gradient swatch's name, else Linear gradient, Radial gradient or Freeform gradient; objects.gradients counts them), gradientNames (the gradients those objects use), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}", + "{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the Brushes panel's brushes; with selectionOnly: the brushes the selected objects' strokes use), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), patternObjects: [{id, name, patterns}] (the objects filled or stroked with a pattern, each once, those in groups and type whose drawn characters use one included, hidden fills and strokes, strokes with no width, guides, compound path members' own fills and strokes, images' fills and strokes without a shape and characters that draw nothing left out, with the patterns they use, which objects.patterns counts; with selectionOnly: those in the selection), patternNames (the patterns those objects use), gradientObjects: [{id, name, gradients}] (the objects filled or stroked with a gradient, by the same rules, with the gradients they use: a linked gradient swatch's name, else Linear gradient, Radial gradient or Freeform gradient; objects.gradients counts them), gradientNames (the gradients those objects use), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}. An image linked to a file outside the automation read roots has link null, and its Linked Images row names the file alone", has_doc, info ), @@ -358,6 +358,16 @@ fn info(s: &mut Session, p: &Value) -> Result { let mut fonts = BTreeSet::new(); let mut images: BTreeMap = BTreeMap::new(); let mut image_rows: Vec = vec![]; + // Whether the request being served may read each linked file (a relative path read from the + // document's folder), checked once per file. + let mut readable: HashMap = HashMap::new(); + let link_doc = super::links::LinkBase::of(st).doc; + let mut may_read = |stored: &str| { + *readable.entry(stored.to_string()).or_insert_with(|| { + let resolved = super::fileio::document_path(stored, link_doc).ok(); + crate::file_access::check_read(resolved.as_deref().unwrap_or(stored)).is_ok() + }) + }; let mut patterns = BTreeSet::new(); let mut symbols = BTreeSet::new(); let mut spot = BTreeSet::new(); @@ -411,12 +421,15 @@ fn info(s: &mut Session, p: &Value) -> Result { Some("textObjects") } NodeKind::Image(im) => { + // A linked file outside a confined request's read roots: `link` is null, and the row + // names the file alone. + let link = im.link.as_ref().map(|l| (l, may_read(&l.path))); images.insert( im.key.clone(), - json!({ "width": im.width, "height": im.height, "linked": im.link.is_some(), "link": im.link.as_ref().map(|l| &l.path) }), + json!({ "width": im.width, "height": im.height, "linked": im.link.is_some(), "link": link.and_then(|(l, ok)| ok.then_some(&l.path)) }), ); let name = im.link.as_ref().map_or_else(|| n.display_name(), |l| l.name().to_string()); - image_rows.push((name, im.width, im.height, im.link.as_ref().map(|l| l.path.clone()))); + image_rows.push((name, im.width, im.height, link.map(|(l, ok)| if ok { l.path.clone() } else { l.name().to_string() }))); Some("images") } NodeKind::SymbolInstance { symbol, .. } => { diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index eaa417f4ef..7eac419da9 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -90,9 +90,9 @@ pub use pdfimport::{LoadOptions, page_document}; pub(crate) use save::job_for; pub(crate) use save::save as save_document; pub use save::{ - SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, dialog_folder, export_folder, reads_option as save_reads_option, - remembered_save_options, save_filters, save_format, save_job, save_plan, save_with, stamp_save_dates, template_filters, templates_dialog_folder, - templates_folder, + SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, dialog_folder, export_folder, leave_out_unreadable_save_paths, + reads_option as save_reads_option, remembered_save_options, save_filters, save_format, save_job, save_plan, save_with, stamp_save_dates, + template_filters, templates_dialog_folder, templates_folder, }; pub(crate) use screens::{ PARAM_KEYS as SCREEN_PARAM_KEYS, ROW_KEYS as SCREEN_ROW_KEYS, UNWRITTEN_FORMATS as SCREEN_UNWRITTEN_FORMATS, @@ -124,7 +124,9 @@ pub(crate) use access::list_dir; pub use access::write_file_with; #[cfg(not(target_arch = "wasm32"))] pub(crate) use access::{Placed, copy_new, file_metadata}; -pub(crate) use access::{absolute_path, create_dir, document_path, file_created, file_stamp, read_file, read_file_head, write_file, write_new_file}; +pub(crate) use access::{ + absolute_path, create_dir, document_path, file_created, file_stamp, read_file, read_file_head, shown_link, shown_path, write_file, write_new_file, +}; #[cfg(all(test, windows))] pub(crate) use access::{document_path_on, document_path_with}; #[cfg(test)] diff --git a/crates/engine/src/cmd/fileio/access.rs b/crates/engine/src/cmd/fileio/access.rs index 4dce2023bb..7b20326106 100644 --- a/crates/engine/src/cmd/fileio/access.rs +++ b/crates/engine/src/cmd/fileio/access.rs @@ -42,6 +42,29 @@ pub(crate) fn touched(f: impl FnOnce() -> T) -> (T, Vec) { (out, seen) } +/// May a reply to the request being served name `path`, a file or folder the client did not give? +/// Always without roots in force; with them, only inside the read roots ([`crate::file_access::check_read`]). +pub(crate) fn shown(path: &str) -> bool { + crate::file_access::check_read(path).is_ok() +} + +/// `path` as a reply to the request being served names it: whole when [`shown`], else its file name, +/// after the last `/` or `\` whichever platform wrote it (empty for a path that ends in no name, such +/// as `..` or a folder ending in a separator). +pub(crate) fn shown_path(path: &str) -> std::borrow::Cow<'_, str> { + shown_link(path, None) +} + +/// A link's `stored` path as a reply to the request being served names it: whole when the file it +/// names (`resolved`, else `stored` itself) is [`shown`], else its file name as [`shown_path`] gives it. +pub(crate) fn shown_link<'a>(stored: &'a str, resolved: Option<&str>) -> std::borrow::Cow<'a, str> { + if shown(resolved.unwrap_or(stored)) { + return std::borrow::Cow::Borrowed(stored); + } + let name = stored.rsplit(vectorcraft_doc::links::path_separators(stored)).next().unwrap_or_default(); + std::borrow::Cow::Borrowed(if matches!(name, "." | "..") { "" } else { name }) +} + /// `path` checked for writing against the automation roots in force. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn may_write(path: &str) -> Result<()> { diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index 13df0d5eb1..83b6359786 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -54,7 +54,7 @@ pub fn specs() -> Vec { "Export for Screens", ["File", "Export"], None, - "{folder?, zip?: false (one .zip of every file, each deflated or stored, whichever is smaller: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboard?: index | artboards?: [index…] | range?: \"1-3\" (default all), assets?: [asset id…] (instead of artboards: each asset's art alone, cropped to its bounds and named after the asset; see assets.list), layers?: [layer id…] (instead of artboards: each layer's art alone, sublayers included, named after the layer), selection?: one|each (instead of artboards: the selected art as one file, or a file per selected object, named after it), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), includeArtboardFill?: true (Include Artboard Fill: each artboard's background color behind its art; false leaves it out), pdfs?: multiple|single (Export PDFs As: with several artboards, single writes them as the pages of one PDF per PDF row, named after the document), exportPreset?: (a preset of document.exportPresets.list: its settings, under these params), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), folders?: none|rows (as subfolders)|format (a folder per extension: png, svg, pdf…)|item (a folder per artboard, asset, layer or object)|itemFormat (item/extension) (default: rows with subfolders or a preset, else none), name?: (a file name pattern instead of the item's name, with for the document, for the artboard, asset, layer or object, from 1, #### for the counter zero-padded to the run of #, , , (else the row's suffix ends the name), YYYYMMDD, YYYY-MM-DD, YYYY, ,
, , and ; a trailing .ext is dropped, as in `COMPANY-Logo--####-YYYYMMDD`; different files it names alike get -2, -3…), counterStart?: 1, counterPer?: item (every file of an item has its number)|file, date?: YYYY-MM-DD (the date the pattern uses; default today, UTC), preset?: mobile (PNG 1x, 2x, 3x) | density (PNG 0.75x–4x in ldpi…xxxhdpi sub-folders) instead of formats (turns subfolders on), formats?: [{format: png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft, scale?: 1 | \"2x\" | \"100w\" | \"100h\" | \"72ppi\", width?: px | height?: px | ppi? (raster only; width, then height, then ppi win over scale), suffix?: (raster default: @2x, @100w, @100h, none at 1x; vector formats drop size suffixes), folder?: (its sub-folder), quality?: (jpg 0–100), …the format's options (antiAlias, background, preset (pdf)…)}], settings?: {png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft: {…options for every row of that format}} (rows' own options win), prefix?, antiAlias?: none|art|type (raster rows without their own), openLocation?: (remembered; the app shows the files after exporting)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N; a row whose file would take a name another row's file has, in any case, gets -2, -3… too, and identical rows write one file; row folders that differ only in case are one folder, spelled as the first row spells it) → {files: [path…]} (into the folder of exportPreset, outside the automation read roots: no ZIP path, and files relative to that folder); no folder → {files: [{name, dataBase64}]} (names include sub-folders: 2x/Icon@2x.png); every reply has warnings: [the encoders' warnings for the files, each once: an image that couldn't be decoded, type written in the fallback font…]. eps, ai and vectorcraft rows write a document of the item alone (its one artboard). The params (but zip and assets) are remembered in the document (document.exportSettings; not an undo step). A key it doesn't take, a key no format takes in a format row or in settings, settings for a format it doesn't write, and an unknown key inside a PDF, EPS or SVG option's object, are refused", has_doc, screens::export_for_screens ), @@ -63,7 +63,7 @@ pub fn specs() -> Vec { "Export Presets", [], None, - "{} → {presets: [{name, builtIn, settings}]} the built-in batch export presets (Logo Package, Print-Ready PDF), then the saved ones; any name works as document.exportForScreens' exportPreset", + "{} → {presets: [{name, builtIn, settings}]} the built-in batch export presets (Logo Package, Print-Ready PDF), then the saved ones (a folder outside the automation read roots is left out of settings); any name works as document.exportForScreens' exportPreset", always, screens::presets_list ), @@ -99,7 +99,7 @@ pub fn specs() -> Vec { "Export Export Presets", [], None, - "{names?: [preset names, built-in ones too] (default: every saved preset), path?} write the presets as a .vcexportpresets file (JSON); more than 1000 presets or a file larger than 4194304 bytes is an error → {path, count}; without path → {data: the file's text, count}", + "{names?: [preset names, built-in ones too] (default: every saved preset), path?} write the presets as a .vcexportpresets file (JSON; a folder outside the automation read roots is left out); more than 1000 presets or a file larger than 4194304 bytes is an error → {path, count}; without path → {data: the file's text, count}", always, screens::presets_export ), @@ -153,7 +153,7 @@ pub fn specs() -> Vec { "Export for Screens Settings", [], None, - "{} → {settings}: the document.exportForScreens params the document last exported with ({} when never; saved with the document, the dialog reopens on them)", + "{} → {settings}: the document.exportForScreens params the document last exported with ({} when never; saved with the document, the dialog reopens on them; a folder outside the automation read roots is left out)", has_doc, screens::export_settings ), diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index 9f4e5b0572..cb8d83ad61 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -1032,11 +1032,47 @@ pub(crate) fn save(s: &mut Session, mode: SaveMode, p: &Value) -> Result let plan = save_plan(s, mode, p)?; let job = save_job(s, plan)?; let uid = s.doc()?.uid; - let out = job.write_streamed(&mut super::write_file_with)?; + let mut out = job.write_streamed(&mut super::write_file_with)?; job.complete(s, uid); + leave_out_unreadable_save_paths(&mut out, str_param(p, "path")); Ok(out) } +/// The reply `out` of a save with the `path` param `given`, as the request being served may see it +/// ([`super::access::shown`]). A save without a path writes the document's own file: its `path` is +/// left out, and the `files` and `linked` written beside it are named by their file names, when they +/// are outside the read roots. The suggested `folder` of a reply without a path is left out when it +/// is outside them. Without roots in force `out` is unchanged. +pub fn leave_out_unreadable_save_paths(out: &mut Value, given: Option<&str>) { + use super::access::{shown, shown_path}; + + if crate::file_access::current().is_none() { + return; + } + let Some(o) = out.as_object_mut() else { return }; + if o.get("folder").and_then(Value::as_str).is_some_and(|f| !shown(f)) { + o.remove("folder"); + } + // Every path the reply names then is the one the client gave, or beside it. + if given.is_some_and(|g| !g.is_empty()) { + return; + } + if o.get("path").and_then(Value::as_str).is_some_and(|p| !shown(p)) { + o.remove("path"); + } + for key in ["files", "linked"] { + if let Some(Value::Array(rows)) = o.get_mut(key) { + for row in rows { + if let Value::String(p) = row + && !shown(p) + { + *row = json!(shown_path(p)); + } + } + } + } +} + fn can_revert(s: &Session) -> std::result::Result<(), String> { let st = s.active().ok_or("no document open")?; match (&st.path, st.is_dirty()) { @@ -1104,7 +1140,7 @@ pub(super) fn specs() -> Vec { "Save Document", [], None, - "{path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version, preview or precision given flat, or a key given in options), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), precision?: 0..9 (native: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact; default: exact, and a later save without it writes exactly again), separateArtboards?: false (native and .ai: also save each artboard of `range` to -. beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: true (native: also carry a PDF of every artboard; false omits it) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly, on the active artboard (the app's, else the one the document opened on; with editingData: false, the first); separateArtboards writes more files, one per artboard. EPS saves the chosen artboard (first by default), retaining its page margins and the whole editable document; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified. A key that no save format takes, beside the path or in options, and an unknown key inside a PDF option's object or svg, are refused", + "{path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version, preview or precision given flat, or a key given in options), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), precision?: 0..9 (native: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact; default: exact, and a later save without it writes exactly again), separateArtboards?: false (native and .ai: also save each artboard of `range` to -. beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: true (native: also carry a PDF of every artboard; false omits it) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly, on the active artboard (the app's, else the one the document opened on; with editingData: false, the first); separateArtboards writes more files, one per artboard. EPS saves the chosen artboard (first by default), retaining its page margins and the whole editable document; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified. Under automation read roots, a reply leaves out the document's own file outside them when no path is given (files and linked then name the files by their file names), and a suggested folder outside them. A key that no save format takes, beside the path or in options, and an unknown key inside a PDF option's object or svg, are refused", has_doc, |s, p| save(s, SaveMode::Save, p) ), diff --git a/crates/engine/src/cmd/fileio/screens.rs b/crates/engine/src/cmd/fileio/screens.rs index 56c0d808a5..3974a574b1 100644 --- a/crates/engine/src/cmd/fileio/screens.rs +++ b/crates/engine/src/cmd/fileio/screens.rs @@ -644,12 +644,47 @@ impl Output { /// File → Export for Screens: [`export`] (over the settings of `exportPreset`, when given), with /// the params remembered in the document (`document.exportSettings`). pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result { + // A folder the client gave, as against the one its `exportPreset` keeps. + let given = p.get("folder").is_some(); let p = with_preset(s, p)?; - let r = export(s, &p)?; + let mut r = export(s, &p)?; remember(s, &p); + if !given && let Some(folder) = str_param(&p, "folder").filter(|f| !f.is_empty()) { + leave_out_unreadable_folder(&mut r, folder); + } Ok(r) } +/// The reply `r` of an export into `folder`, a folder the client did not give, as the request being +/// served may see it: when `folder` is outside the read roots ([`super::access::shown`]), the ZIP's +/// `path` is left out and `files` names each file written relative to `folder` (`2x/Logo@2x.png`), +/// as the reply of an export to a ZIP names them. +fn leave_out_unreadable_folder(r: &mut Value, folder: &str) { + if super::access::shown(folder) { + return; + } + let Some(o) = r.as_object_mut() else { return }; + o.remove("path"); + let inside = format!("{folder}/"); + if let Some(Value::Array(files)) = o.get_mut("files") { + for file in files { + // A ZIP's files are named relative to it already. + if let Some(name) = file.as_str().and_then(|path| path.strip_prefix(&inside)) { + *file = json!(name); + } + } + } +} + +/// Export settings kept in the document or a preset, as the request being served may see them: a +/// `folder` outside the read roots ([`super::access::shown`]) is left out. +fn leave_out_unreadable_settings_folder(mut settings: Map) -> Map { + if settings.get("folder").and_then(Value::as_str).is_some_and(|f| !f.is_empty() && !super::access::shown(f)) { + settings.remove("folder"); + } + settings +} + // ---------- export presets ---------- /// A saved batch export: what to write and how to name and sort it, to use again @@ -727,7 +762,13 @@ pub fn preset_list(saved: &[ScreenExportPreset]) -> Value { } pub(super) fn presets_list(s: &mut Session, _: &Value) -> Result { - Ok(json!({ "presets": preset_list(&s.prefs.screen_export_presets) })) + let saved: Vec = s + .prefs + .screen_export_presets + .iter() + .map(|p| ScreenExportPreset { name: p.name.clone(), settings: leave_out_unreadable_settings_folder(p.settings.clone()) }) + .collect(); + Ok(json!({ "presets": preset_list(&saved) })) } /// The [`PRESET_KEYS`] of `given`, refused as an error of command `cmd` when an export would @@ -831,6 +872,8 @@ pub(super) fn presets_export(s: &mut Session, p: &Value) -> Result { return Err(too_many()); } let count = presets.len(); + let presets: Vec = + presets.into_iter().map(|p| ScreenExportPreset { settings: leave_out_unreadable_settings_folder(p.settings), ..p }).collect(); let text = serde_json::to_string_pretty(&PresetFile { format: PRESET_FILE_FORMAT.into(), presets }).map_err(|e| bad(EXPORT, e.to_string()))?; if text.len() > MAX_PRESET_FILE_BYTES { return Err(bad(EXPORT, format!("export presets file is larger than {MAX_PRESET_FILE_BYTES} bytes"))); @@ -1388,7 +1431,7 @@ pub(crate) fn store_settings(s: &mut Session, mut settings: Map) /// `document.exportSettings`: the Export for Screens settings the document last exported with. pub(super) fn export_settings(s: &mut Session, _: &Value) -> Result { - Ok(json!({ "settings": s.doc()?.doc.export_settings })) + Ok(json!({ "settings": leave_out_unreadable_settings_folder(s.doc()?.doc.export_settings.clone()) })) } #[cfg(test)] diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index 6236d281e2..ebe3b76da9 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -48,6 +48,19 @@ fn image_of(s: &Session) -> vectorcraft_doc::ImageObject { } } +/// A path outside a confined request's read roots shows as its file name, after the last `/` or `\` +/// on every platform; one that ends in no name shows as nothing. Without roots it shows whole. +#[test] +fn a_hidden_path_shows_as_its_file_name_on_every_platform() { + let windows = "C:\\Users\\someone\\art\\red.png"; + crate::file_access::confine(Some(&crate::file_access::AutomationRoots::none()), || { + for (path, name) in [("/home/someone/art/red.png", "red.png"), (windows, "red.png"), ("/home/someone/..", ""), ("C:\\Users\\someone\\", "")] { + assert_eq!(shown_path(path), name, "{path}"); + } + }); + assert_eq!(shown_path(windows), windows); +} + #[test] fn opens_every_readable_format() { let mut s = session(200.0, 100.0, 1); diff --git a/crates/engine/src/cmd/fileio/tests_screens.rs b/crates/engine/src/cmd/fileio/tests_screens.rs index b0dcd368ec..b3e0642245 100644 --- a/crates/engine/src/cmd/fileio/tests_screens.rs +++ b/crates/engine/src/cmd/fileio/tests_screens.rs @@ -667,6 +667,83 @@ fn a_numbered_file_is_written_into_the_first_rows_folder() { assert_eq!(std::fs::read_dir(dir).unwrap().count(), 1, "one sub-folder"); } +/// An export into the folder an export preset keeps, for a client whose read roots don't hold that +/// folder: the reply leaves out the ZIP's path and names the files relative to the folder. A folder +/// inside the read roots, and one the client gives, stay whole. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn a_presets_folder_outside_the_read_root_is_left_out_of_the_reply() { + use crate::file_access::{AutomationRoots, confine}; + + let mut s = session(1); + let tmp = Folder::new("preset-roots"); + let (inside, out) = (tmp.0.join("in"), tmp.0.join("out")); + for f in [&inside, &out] { + std::fs::create_dir_all(f).unwrap(); + } + let folder = out.to_string_lossy().replace('\\', "/"); + let settings = json!({"formats": [{"format": "png", "folder": "1x"}], "subfolders": true, "folder": folder}); + s.execute("document.exportPresets.save", &json!({"name": "Kept", "settings": settings})).unwrap(); + let write_only = AutomationRoots::new(&[inside.as_path()], &[out.as_path()]).unwrap(); + let both = AutomationRoots::new(&[inside.as_path(), out.as_path()], &[out.as_path()]).unwrap(); + let run = |s: &mut Session, roots: &Option>, p: Value| confine(roots.as_ref(), || export(s, p)); + + let r = run(&mut s, &write_only, json!({"exportPreset": "Kept"})); + assert_eq!(r["files"], json!(["1x/Artboard-1.png"]), "{r}"); + assert!(out.join("1x/Artboard-1.png").is_file()); + let r = run(&mut s, &write_only, json!({"exportPreset": "Kept", "zip": true})); + assert_eq!((r.get("path"), &r["files"]), (None, &json!(["1x/Artboard-1.png"])), "{r}"); + assert!(!r.to_string().contains(&folder), "{r}"); + // Inside the read roots, or given by the client: whole. + assert_eq!(run(&mut s, &both, json!({"exportPreset": "Kept"}))["files"], json!([format!("{folder}/1x/Artboard-1.png")])); + let r = run(&mut s, &write_only, json!({"exportPreset": "Kept", "folder": folder, "zip": true})); + assert!(r["path"].as_str().is_some_and(|p| p.starts_with(&format!("{folder}/")) && p.ends_with(".zip")), "{r}"); +} + +/// The export settings the document keeps and the saved presets, read by a client whose read roots +/// don't hold their folder: `document.exportSettings`, `document.exportPresets.list` and +/// `document.exportPresets.export` (as data and as a file) leave out that folder and keep the rest. +/// Inside the read roots the folder stays. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn kept_settings_leave_out_a_folder_outside_the_read_root() { + use crate::file_access::{AutomationRoots, confine}; + + let mut s = session(1); + let tmp = Folder::new("settings-roots"); + let (inside, out) = (tmp.0.join("in"), tmp.0.join("out")); + for f in [&inside, &out] { + std::fs::create_dir_all(f).unwrap(); + } + let folder = out.to_string_lossy().replace('\\', "/"); + let settings = json!({"formats": [{"format": "svg"}], "folder": folder}); + s.execute("document.exportPresets.save", &json!({"name": "Kept", "settings": settings})).unwrap(); + export(&mut s, json!({"exportPreset": "Kept"})); + let confined = AutomationRoots::new(&[inside.as_path()], &[inside.as_path()]).unwrap(); + let both = AutomationRoots::new(&[inside.as_path(), out.as_path()], &[inside.as_path()]).unwrap(); + let ask = |s: &mut Session, roots: &Option>, cmd: &str, p: Value| { + confine(roots.as_ref(), || s.execute(cmd, &p).unwrap()) + }; + let saved = |list: &Value| list["presets"].as_array().unwrap().iter().find(|p| p["name"] == "Kept").unwrap()["settings"].clone(); + + let r = ask(&mut s, &confined, "document.exportSettings", json!({})); + assert_eq!((r["settings"].get("folder"), &r["settings"]["formats"]), (None, &json!([{"format": "svg"}])), "{r}"); + let r = ask(&mut s, &confined, "document.exportPresets.list", json!({})); + assert_eq!(saved(&r), json!({"formats": [{"format": "svg"}]}), "{r}"); + let r = ask(&mut s, &confined, "document.exportPresets.export", json!({"names": ["Kept"]})); + let data = r["data"].as_str().unwrap(); + assert!(!data.contains(&folder) && data.contains("svg"), "{data}"); + let file = inside.join("kept.vcexportpresets"); + let path = file.to_string_lossy().replace('\\', "/"); + ask(&mut s, &confined, "document.exportPresets.export", json!({"names": ["Kept"], "path": path})); + assert!(!std::fs::read_to_string(&file).unwrap().contains(&folder)); + + // Inside the read roots: whole. + assert_eq!(ask(&mut s, &both, "document.exportSettings", json!({}))["settings"]["folder"], json!(folder)); + assert_eq!(saved(&ask(&mut s, &both, "document.exportPresets.list", json!({})))["folder"], json!(folder)); + assert!(ask(&mut s, &both, "document.exportPresets.export", json!({"names": ["Kept"]}))["data"].as_str().unwrap().contains(&folder)); +} + #[test] fn format_settings_apply_to_their_rows_and_rows_win() { let mut s = session(1); diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs index ee6d7e3c89..8d6f53d55b 100644 --- a/crates/engine/src/cmd/package.rs +++ b/crates/engine/src/cmd/package.rs @@ -12,7 +12,7 @@ use vectorcraft_doc::{Document, LinkInfo}; use serde_json::{Value, json}; -use super::fileio::{create_dir, write_file}; +use super::fileio::{create_dir, document_path, shown_link, shown_path, write_file}; use super::links::LinkBase; use super::{CommandSpec, Result, Session, bad, cmd, has_doc, switch_or, text_param, unique_name}; @@ -24,7 +24,7 @@ pub fn specs() -> Vec { "Package", [], None, - "{folder?, name?: (default: \" Folder\"), copyLinks?: true, linksFolder?: true (the linked files go in Links/, else next to the document), relink?: true (the packaged document links to the copies), copyFonts?: true (the fonts its type uses go in Fonts/; fonts whose licence doesn't allow embedding are left out), report?: true ( Report.txt)} copy the saved document as it is now into folder/name as .vectorcraft with its linked files and fonts; the open document doesn't change → {folder, files: [paths relative to folder/name], links, fonts (counts copied), missingLinks: [file names not found], skippedFonts: [{font, reason}], warnings: [nested files copied unchanged or cycles/depth stopped]}; no folder → the same with {name: \".zip\", dataBase64} (a zip of the files in /) instead of folder", + "{folder?, name?: (default: \" Folder\"), copyLinks?: true, linksFolder?: true (the linked files go in Links/, else next to the document), relink?: true (the packaged document links to the copies), copyFonts?: true (the fonts its type uses go in Fonts/; fonts whose licence doesn't allow embedding are left out), report?: true ( Report.txt)} copy the saved document as it is now into folder/name as .vectorcraft with its linked files and fonts; the open document doesn't change → {folder, files: [paths relative to folder/name], links, fonts (counts copied), missingLinks: [file names not found], skippedFonts: [{font, reason}], warnings: [nested files copied unchanged or cycles/depth stopped]}; no folder → the same with {name: \".zip\", dataBase64} (a zip of the files in /) instead of folder. The warnings and the report name a file outside the automation read roots by its file name", has_doc, package )] @@ -178,8 +178,10 @@ impl<'a> Collector<'a> { /// Warns when a file that placed `.ai` file `source` links (`name`) was copied as `copy`: the /// copy of `source` finds it only by its name, beside it. + /// `path` is the link's path as the warning names it. fn renamed(&mut self, lines: &mut Vec, source: &str, path: &str, name: &str, copy: &str) { if Path::new(copy).file_name().and_then(std::ffi::OsStr::to_str) != Some(name) { + let source = shown_path(source); let message = format!( "{path}: copied as {copy}; the copy of {source} looks for it as {name} and shows the file of that name, if any, else its preview" ); @@ -207,11 +209,13 @@ impl<'a> Collector<'a> { let section = self.sections.len(); self.sections.push(vec![]); let mut lines = vec![]; - // The packaged document's sections read as before; a placed document's name its file. + // The packaged document's sections read as before; a placed document's name its file. The + // report and the warnings name a file outside a confined request's read roots by its name. + let shown_source = shown_path(source); let heading = |lines: &mut Vec, title: &str| { lines.push(title.into()); if depth > 0 { - lines.push(format!("Document: {source}")); + lines.push(format!("Document: {shown_source}")); } }; // A file's saved path is only meaningful inside its own document. @@ -243,17 +247,21 @@ impl<'a> Collector<'a> { heading(&mut lines, "LINKED FILES"); for file in linked_files(doc) { let found_path = file.found_path.as_deref().unwrap_or(&file.path); + // The stored path, whole when the place it names (read from this document's folder) is + // inside the read roots. A file found elsewhere does not show a stored path outside them. + let resolved = document_path(&file.path, Some(source)).ok(); + let shown = shown_link(&file.path, resolved.as_deref()).into_owned(); if let Some(ancestor) = self.visiting.get(found_path).cloned() { // The ancestor is already included (possibly the root document). A // cyclic reference cannot have a final content hash until both files // have been written, so leave its size/hash unspecified, not stale. - self.warn(&mut lines, format!("{}: circular placed-document link to {ancestor}; stopped following the cycle", file.path)); + self.warn(&mut lines, format!("{shown}: circular placed-document link to {ancestor}; stopped following the cycle")); local.insert(file.path.clone(), Copied { name: ancestor, size: None, hash: None }); continue; } if let Some(existing) = self.copies.get(found_path).cloned() { if by_name { - self.renamed(&mut lines, source, &file.path, &file.name, &existing.name); + self.renamed(&mut lines, source, &shown, &file.name, &existing.name); } // Another document may place other pages of a PDF copied already. if self.opts.copy_fonts @@ -265,8 +273,8 @@ impl<'a> Collector<'a> { continue; } let Some(mut bytes) = file.bytes else { - lines.push(format!("{}: not found ({}), not copied", file.name, file.path)); - self.missing.push(if depth == 0 { file.name.clone() } else { format!("{} ({source})", file.name) }); + lines.push(format!("{}: not found ({shown}), not copied", file.name)); + self.missing.push(if depth == 0 { file.name.clone() } else { format!("{} ({shown_source})", file.name) }); continue; }; if self.copies.len() >= 10_000 { @@ -277,7 +285,7 @@ impl<'a> Collector<'a> { let unique = free_name(&name, if self.opts.links_folder { &mut self.link_names } else { &mut self.taken }); let destination_file = format!("{dir}{unique}"); if by_name { - self.renamed(&mut lines, source, &file.path, &file.name, &destination_file); + self.renamed(&mut lines, source, &shown, &file.name, &destination_file); } // A placed PDF or .ai file is copied as it is, as an image is; the files a .ai file // read from its editing data links are copied beside it. @@ -285,7 +293,7 @@ impl<'a> Collector<'a> { let placed_file = pages.is_some(); if placed_file && let Some(mut art) = super::place::document::editing_data(&file.name, &bytes) { if depth >= vectorcraft_doc::placed_document::MAX_DEPTH { - self.warn(&mut lines, format!("{}: nesting limit reached; copied unchanged without collecting its own links", file.path)); + self.warn(&mut lines, format!("{shown}: nesting limit reached; copied unchanged without collecting its own links")); } else { self.collect(&mut art, found_path, &destination_file, depth + 1, true, None)?; } @@ -296,7 +304,7 @@ impl<'a> Collector<'a> { } if placed_file && super::place::document::is_native(&file.name, &bytes) { if depth >= vectorcraft_doc::placed_document::MAX_DEPTH { - self.warn(&mut lines, format!("{}: nesting limit reached; copied unchanged without collecting its own links", file.path)); + self.warn(&mut lines, format!("{shown}: nesting limit reached; copied unchanged without collecting its own links")); } else { match vectorcraft_format::load_file(&bytes) { Ok(saved) => { @@ -320,8 +328,7 @@ impl<'a> Collector<'a> { Err(e) => self.warn( &mut lines, format!( - "{}: could not rewrite linked document ({e}); copied unchanged, its own links were not relinked", - file.path + "{shown}: could not rewrite linked document ({e}); copied unchanged, its own links were not relinked" ), ), } @@ -329,7 +336,7 @@ impl<'a> Collector<'a> { } Err(e) => self.warn( &mut lines, - format!("{}: unreadable linked document ({e}); copied unchanged, its own links were not collected", file.path), + format!("{shown}: unreadable linked document ({e}); copied unchanged, its own links were not collected"), ), } } @@ -337,7 +344,7 @@ impl<'a> Collector<'a> { let copy = Copied { name: destination_file.clone(), size: Some(bytes.len() as u64), hash: Some(hash_bytes(&bytes)) }; self.copies.insert(found_path.to_string(), copy.clone()); local.insert(file.path.clone(), copy); - lines.push(format!("{} → {destination_file}", file.path)); + lines.push(format!("{shown} → {destination_file}")); self.entries.push((destination_file, bytes)); } lines.push(String::new()); @@ -518,7 +525,7 @@ fn collect_and_write(package: Package, write: &mut dyn FnMut(&str, &[u8]) -> Res // The report. if let Some(info) = info { let report_name = free_name(&format!("{stem} Report.txt"), &mut taken); - let mut text = format!("Package: {doc_name}\nFrom: {doc_path}\n\n"); + let mut text = format!("Package: {doc_name}\nFrom: {}\n\n", shown_path(&doc_path)); lines.iter().for_each(|l| text.push_str(&format!("{l}\n"))); text.push_str(&info); entries.push((report_name, text.into_bytes())); diff --git a/crates/engine/src/tests/package.rs b/crates/engine/src/tests/package.rs index 01550b5ad1..c1da365740 100644 --- a/crates/engine/src/tests/package.rs +++ b/crates/engine/src/tests/package.rs @@ -280,6 +280,103 @@ fn a_missing_link_is_reported_not_copied() { assert!(report.contains("blue.png: not found"), "{report}"); } +/// Packaged for a client whose read root holds neither the document's file nor its links: the report, +/// its Document Info part included, names them by their file names, and `document.info` gives no +/// link. The person's report names them whole. +#[test] +fn a_confined_package_names_files_outside_the_read_root_by_name() { + let dir = Folder::new("package-roots"); + let mut s = poster(&dir); + std::fs::create_dir_all(dir.file("in")).unwrap(); + std::fs::remove_file(dir.file("art/blue.png")).unwrap(); + open(&mut s, &dir.file("poster.vectorcraft")); + let report = |r: &Value| { + let zip = unzip(&vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap()); + String::from_utf8(zip["poster Folder/poster Report.txt"].clone()).unwrap() + }; + let person = report(&run(&mut s, "file.package", json!({}))); + assert!(person.contains(&format!("From: {}\n", dir.file("poster.vectorcraft"))), "{person}"); + assert!(person.contains(&format!("{} → Links/red.png", dir.file("art/red.png"))), "{person}"); + assert!(person.contains(&format!("blue.png: not found ({})", dir.file("art/blue.png"))), "{person}"); + + let in_root = dir.0.join("in"); + let roots = crate::file_access::AutomationRoots::new(&[in_root.as_path()], &[]).unwrap(); + let client = crate::file_access::confine(roots.as_ref(), || run(&mut s, "file.package", json!({}))); + let text = report(&client); + assert!(text.contains("From: poster.vectorcraft\n"), "{text}"); + assert!(text.contains("\nred.png → Links/red.png\n") && text.contains("\nblue.png: not found (blue.png), not copied\n"), "{text}"); + let base = dir.0.to_string_lossy().into_owned(); + assert!(!text.contains(&base), "{text}"); + for key in ["missingLinks", "warnings", "files"] { + assert!(!client[key].to_string().contains(&base), "{key}: {client}"); + } + // Document Info, whose report the package holds, gives no link either. + for p in [json!({}), json!({"format": "text"})] { + let info = crate::file_access::confine(roots.as_ref(), || run(&mut s, "document.info", p.clone())); + assert!(!info.to_string().contains(&base), "{p}: {info}"); + } + let info = run(&mut s, "document.info", json!({"format": "text"})); + assert!(info.to_string().contains(&dir.file("art/red.png")), "the person's: {info}"); +} + +/// A missing link stored as a path relative to the document names a file inside a confined +/// request's read root: the package report and Document Info give its stored path. +#[test] +fn a_relative_link_inside_the_read_root_keeps_its_path() { + let dir = Folder::new("package-roots-relative"); + write(&dir.file("art/near.png"), &png(8, 8, RED)); + let mut s = session(); + let id = place(&mut s, &dir.file("art/near.png")); + let mut doc = (*s.doc().unwrap().doc).clone(); + let Some(vectorcraft_doc::NodeKind::Image(im)) = doc.node_mut(id).map(|n| &mut n.kind) else { panic!("not an image") }; + im.link = Some(vectorcraft_doc::LinkInfo::new("art/near.png")); + write(&dir.file("near.vectorcraft"), &vectorcraft_format::save(&doc, false)); + // Missing, the link keeps its stored path when the document opens (the package copies the + // image the document holds). + std::fs::remove_file(dir.file("art/near.png")).unwrap(); + open(&mut s, &dir.file("near.vectorcraft")); + let roots = crate::file_access::AutomationRoots::new(&[dir.0.as_path()], &[]).unwrap(); + let r = crate::file_access::confine(roots.as_ref(), || run(&mut s, "file.package", json!({}))); + let zip = unzip(&vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap()); + let text = String::from_utf8(zip["near Folder/near Report.txt"].clone()).unwrap(); + assert!(text.contains("\nart/near.png → Links/near.png\n"), "{text}"); + let info = crate::file_access::confine(roots.as_ref(), || run(&mut s, "document.info", json!({}))); + let links: Vec<&Value> = info["images"].as_object().unwrap().values().map(|i| &i["link"]).collect(); + assert_eq!(links, [&json!("art/near.png")], "{info}"); + let text = crate::file_access::confine(roots.as_ref(), || run(&mut s, "document.info", json!({"format": "text"}))); + assert!(text.to_string().contains("art/near.png"), "{text}"); +} + +/// A link whose stored path names a place outside a confined request's read root, with its file +/// found by name beside the document inside the root: the package report and reply give the file +/// name, not the stored path. +#[test] +fn a_stored_path_outside_the_read_root_is_left_out_when_its_file_is_found_inside() { + let dir = Folder::new("package-roots-stale"); + write(&dir.file("in/near.png"), &png(8, 8, RED)); + let mut s = session(); + let id = place(&mut s, &dir.file("in/near.png")); + let mut doc = (*s.doc().unwrap().doc).clone(); + let Some(vectorcraft_doc::NodeKind::Image(im)) = doc.node_mut(id).map(|n| &mut n.kind) else { panic!("not an image") }; + let stale = dir.file("out/near.png"); + im.link = Some(vectorcraft_doc::LinkInfo::new(&stale)); + write(&dir.file("in/near.vectorcraft"), &vectorcraft_format::save(&doc, false)); + // Missing when the document opens, the link keeps its stored path; the file then appears beside it. + std::fs::remove_file(dir.file("in/near.png")).unwrap(); + open(&mut s, &dir.file("in/near.vectorcraft")); + write(&dir.file("in/near.png"), &png(8, 8, RED)); + let in_root = dir.0.join("in"); + let roots = crate::file_access::AutomationRoots::new(&[in_root.as_path()], &[]).unwrap(); + let r = crate::file_access::confine(roots.as_ref(), || run(&mut s, "file.package", json!({}))); + let zip = unzip(&vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap()); + let text = String::from_utf8(zip["near Folder/near Report.txt"].clone()).unwrap(); + assert!(text.contains("\nnear.png → Links/near.png\n"), "{text}"); + assert!(!text.contains(&stale), "{text}"); + for key in ["missingLinks", "warnings", "files"] { + assert!(!r[key].to_string().contains(&stale), "{key}: {r}"); + } +} + /// A file missing from a document that moved keeps the place its relative path names from the /// document's folder: the packaged document writes its relative path from there. #[test] diff --git a/crates/engine/src/tests/save.rs b/crates/engine/src/tests/save.rs index d92365a356..228d09d315 100644 --- a/crates/engine/src/tests/save.rs +++ b/crates/engine/src/tests/save.rs @@ -40,6 +40,50 @@ fn objects(s: &Session) -> usize { s.doc().unwrap().doc.node_count() } +/// A client confined by automation roots: a save without a path writes the document's own file, and +/// when that file is outside the read roots the reply leaves out its `path` and names the files +/// written beside it by their file names. A reply without a path leaves out a suggested `folder` +/// outside them. A path inside the read roots, and one the client gives, stay whole. +#[test] +fn a_confined_save_leaves_out_paths_outside_the_read_root() { + use crate::file_access::{AutomationRoots, confine}; + + let d = dir("roots"); + let (inside, outside) = (d.join("in"), d.join("out")); + for f in [&inside, &outside] { + std::fs::create_dir_all(f).unwrap(); + } + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 2})).unwrap(); + rect(&mut s); + let own = path(&outside, "doc.vectorcraft"); + s.execute("document.save", &json!({"path": own})).unwrap(); + let person = s.execute("document.save", &json!({"separateArtboards": true})).unwrap(); + let files: Vec = person["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap().to_string()).collect(); + assert_eq!((files.len(), files[0].as_str()), (3, own.as_str()), "{person}"); + + let write_only = AutomationRoots::new(&[inside.as_path()], &[outside.as_path()]).unwrap(); + let both = AutomationRoots::new(&[inside.as_path(), outside.as_path()], &[outside.as_path()]).unwrap(); + let run = |s: &mut Session, roots: &Option>, id: &str, p: Value| { + confine(roots.as_ref(), || s.execute(id, &p)).unwrap_or_else(|e| panic!("{id} {p}: {e}")) + }; + let hidden = outside.to_string_lossy().into_owned(); + let r = run(&mut s, &write_only, "document.save", json!({})); + assert!(r.get("path").is_none() && r["bytes"].as_u64().is_some_and(|n| n > 0), "{r}"); + assert!(!r.to_string().contains(&hidden), "{r}"); + let r = run(&mut s, &write_only, "document.save", json!({"separateArtboards": true})); + let names: Vec = files.iter().map(|f| json!(std::path::Path::new(f).file_name().unwrap().to_string_lossy())).collect(); + assert_eq!((r.get("path"), r["files"].as_array()), (None, Some(&names)), "{r}"); + // Inside the read roots, or given by the client: whole. + assert_eq!(run(&mut s, &both, "document.save", json!({}))["path"], json!(own)); + assert_eq!(run(&mut s, &write_only, "document.save", json!({"path": own}))["path"], json!(own)); + // A save without a path known suggests the document's folder. + let r = run(&mut s, &write_only, "file.saveCopy", json!({})); + assert!(r.get("folder").is_none() && r["name"] == "doc copy.vectorcraft" && r.get("dataBase64").is_some(), "{r}"); + assert_eq!(run(&mut s, &both, "file.saveCopy", json!({}))["folder"], json!(hidden)); + let _ = std::fs::remove_dir_all(d); +} + #[test] fn an_svg_path_writes_svg_and_save_writes_it_back() { let d = dir("svg"); diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index 3030ef2c61..1c2fca8230 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -122,10 +122,20 @@ fn run_ui_part1(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option io::open_with(app, p), // Saves write through the app (save panel, download); with no path known the panel and the // format's options dialog ask first. - "file.save" => io::save(app, SaveMode::Save, p, true), - "file.saveAs" => io::save(app, SaveMode::SaveAs, p, true), - "file.saveCopy" => io::save(app, SaveMode::Copy, p, true), - "file.saveAsTemplate" => io::save(app, SaveMode::Template, p, true), + // A confined request sees the document's own file, and the folder a save suggests, only + // inside its read roots. + "file.save" | "file.saveAs" | "file.saveCopy" | "file.saveAsTemplate" => { + let mode = match id { + "file.save" => SaveMode::Save, + "file.saveAs" => SaveMode::SaveAs, + "file.saveCopy" => SaveMode::Copy, + _ => SaveMode::Template, + }; + io::save(app, mode, p, true).map(|mut r| { + vectorcraft_engine::cmd::fileio::leave_out_unreadable_save_paths(&mut r, p.get("path").and_then(Value::as_str)); + r + }) + } "document.exportSelection" => { io::save_command_output(app, id, "png", if p.is_object() { p.clone() } else { json!({}) }).map(|p| json!({"path": p})) } diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index ee876b2e71..a459c39d55 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -107,6 +107,27 @@ fn control_saves_take_the_native_save_options() { let _ = std::fs::remove_dir_all(base); } +/// A save without a path writes the document's own file in the write root: `app.save` and +/// `file.save` leave its path out of the reply, since it is outside the read root, and keep a path +/// the client gives. +#[test] +fn control_saves_leave_out_the_documents_file_outside_the_read_root() { + let (mut app, tx, base) = confined("save-own"); + app.run("file.new", json!({"width": 100, "height": 50})).unwrap(); + let own = s(base.join("out/doc.vectorcraft")); + app.run("file.saveAs", json!({"path": own})).unwrap(); + for (method, params) in [("app.save", json!({})), ("engine.execute", json!({"command": "file.save", "params": {}}))] { + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + let r = request(&mut app, &tx, method, params); + assert_eq!(r["ok"], true, "{method}: {r}"); + assert!(r["result"].get("path").is_none() && !r.to_string().contains(&s(base.join("out"))), "{method}: {r}"); + assert_eq!(app.session.active().map(|d| d.is_dirty()), Some(false), "{method}: saved"); + } + let r = request(&mut app, &tx, "app.save", json!({"path": own})); + assert_eq!(r["result"]["path"], json!(own), "{r}"); + let _ = std::fs::remove_dir_all(base); +} + /// With Background Save and Background Export on, `app.save` and `app.export` with `wait: true` /// (MCP's `save_file` in the app) reply with the write root's refusal, and write a file inside it /// before they reply. Without `wait` the refusal reaches only the status bar. diff --git a/docs/mcp.md b/docs/mcp.md index da260b6823..5f02c87f2e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -123,6 +123,17 @@ the suite) keep the agent to the folders you give it: - Every file a command writes must lie inside a **write root**: `save_file`, `export`, the save and export commands, Export for Screens, Package, Export for Web, print to file, `screenshot {path}`, library and preset saves to a path, the copies `links.relink {copy}` makes. +- A path the agent gives stays in a save or export reply, with the files written beside it. Paths it did not + give are left out when they are outside the read roots. `save_file`, `document.save` and `file.save` without + a `path` write the document's own file: the reply then has no `path`, and `files` and `linked` name the files + written beside it by their file names. A save without a path known (`file.saveAs` and `file.saveCopy` without + one, a document never saved) leaves out the suggested `folder`. `document.exportForScreens` into the `folder` + of its `exportPreset` leaves out the ZIP's `path` and lists `files` relative to that folder. + `document.exportSettings`, `document.exportPresets.list` and `document.exportPresets.export` (as data and as + a file) leave out a kept `folder` outside the read roots. `file.package` + names the document's file and the linked files outside the read roots by their file names in `warnings`, in + `missingLinks` and in its report. `document.info` gives `link: null` for an image linked to a file outside them, + and its Linked Images rows and text report name that file alone. `action.batch` returns the replies of its steps as these commands give them. - `file.list {path}` (through `run_command`) lists a folder's files and folders, held to the read roots like any read, so an agent can see what it was given (#1163). - `file.recentFiles` and the control channel's `ui.menu.list` leave out the recent files outside the read roots. The other File › Open Recent Files rows keep their ids (`file.openRecent3` still opens the third file), and diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 18d50447ff..56b960dedb 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -749,6 +749,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Scripts: `#strict on` (and `//@strict on`) runs the script with the checks the [`$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/) help page gives for `$.strict`: an assignment to a read-only property throws a `TypeError`, and so does a new property on an object that takes none. VectorCraft's own rules: a getter-only property counts as read-only, the objects that take no new properties are the non-extensible ones (`Object.preventExtensions`, `seal`, `freeze`, and the frozen `vc` and `$` objects), the last `#strict` line of the script and its includes sets the whole run, a file `$.evalFile` runs keeps its own line's checks for its code, and the rest of ES5 strict mode stays with `'use strict'`. Without the line, runs are unchanged. `crates/engine/src/tests/script_include.rs` (`strict_on_*`, `strict_mode_in_eval_file_is_scoped`), `crates/js/src/interp_tests.rs` (`strict_writes_are_separate_from_es5_strict`). - Scripts folder: File › Scripts lists the `.js` and `.jsx` files of the `Scripts` folder next to the preferences, each named after its file without the extension, with each subfolder as a submenu, above Check Script… and Other Script… (https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/install-and-run-scripts.html). VectorCraft's own rules: the folder is read again when the window becomes active and when the File menu opens (in the macOS menu bar, when any of its menus opens, and the new listing shows the next time the menu opens), links are not followed, and one read stops at 2,048 entries, 512 scripts, 128 subfolders, 4 levels or 250 ms. `script.listInstalled` returns the listing. `crates/engine/src/tests/script_folder.rs`, `crates/ui-egui/src/tests/scripts_menu.rs`. - Open Recent Files under `--automation-read-root`: the control channel's `ui.menu.list` and `file.recentFiles` leave out the recent files outside the read roots, the other rows keep their ids, and `file.openRecentN` for one outside them answers `no such recent file`, as an empty slot does. `crates/ui-egui/src/tests/automation.rs`. + - Saves, Export for Screens and Package under `--automation-read-root`: a save without a path leaves the document's own file out of its reply, and a save without a path known leaves out the suggested folder, when they are outside the read roots; Export for Screens into an export preset's folder outside them lists its files relative to that folder, and the export settings and saved presets leave out a folder outside them when read or exported; Package's report and warnings, and Document Info's linked images, name a file outside them by its file name. A path the client gives stays in the reply. `crates/engine/src/tests/save.rs`, `crates/engine/src/cmd/fileio/tests_screens.rs`, `crates/engine/src/tests/package.rs`, `crates/ui-egui/src/tests/automation.rs`. - `ui.inspect` under `--automation-read-root`: the documents still opening, the recent files, library panels' files and folder preferences (saved, and in an open Preferences dialog) outside the read roots are left out of the reply, a library loaded from outside them shows by its file name in `ui.inspect` and `ui.docking`, and the app's state is not changed. `crates/ui-egui/src/tests/automation.rs` (`ui_inspect_leaves_out_paths_outside_the_read_root`). - Swatch, brush, symbol and graphic style library lists under `--automation-read-root`: library files and the user library folder outside the read roots are left out, a library loaded from outside them is listed and found by its file name, and the app's own list is not changed. `crates/engine/src/tests/library_roots.rs`. - Scripts: `alert`, `confirm` and `prompt` open dialogs during a run, each with a Stop button that ends the run and puts every document back. In the app the run waits at the call for the person's answer; a run over MCP or the command line takes its answers in order from `script.run`'s `answers`, and the report lists each dialog with its answer. `crates/engine/src/tests/script_dialogs.rs`, `crates/ui-egui/src/tests/script_dialogs.rs`. From 35be7952c8a6cabb3d08540f4bd446bd5c8b7cc2 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 20:26:11 -0800 Subject: [PATCH 08/43] Save and Export: the status bar's hover lists every note A save or an export with several notes named only the first of them in the status bar, with the count, and the others could not be seen. The status bar now keeps every note of a save or an export for the status message's hover, as it does for an open's notes since #6752: resting the pointer on the message lists them in the order the reply gives them (a save's notes start with what the format loses), and screen readers receive the list after the message. The status text is unchanged. This covers Save, Save As, Save a Copy, Save as Template, Save a Timestamped Copy, Save as PDF, Export As, Export Selection, Export for Screens, Asset Export, Save for Web, Preview in Browser, Save for Office Documents, Text Export Options, a print to a file and the CSS Properties panel's export. The open, the saves and the exports read a reply's notes through one helper. Docs: docs/mcp.md and the css.exportFile params text, docs/control-protocol.md, docs/target-app-parity.md. --- crates/ui-egui/src/chrome/status_bar.rs | 5 +-- crates/ui-egui/src/dialogs/save_for_web.rs | 10 +++--- crates/ui-egui/src/dialogs/tests_screens.rs | 29 ++++++++++++++++ crates/ui-egui/src/io.rs | 34 +++++++++++++++++-- crates/ui-egui/src/io/export.rs | 20 ++++++----- crates/ui-egui/src/io/open.rs | 8 ++--- crates/ui-egui/src/io/save.rs | 22 +++++++++--- crates/ui-egui/src/menus/ui_commands.rs | 2 +- crates/ui-egui/src/panels/css_properties.rs | 11 +++--- .../src/panels/tests_css_properties.rs | 20 +++++++++++ crates/ui-egui/src/state.rs | 5 +-- docs/control-protocol.md | 8 ++--- docs/mcp.md | 3 +- docs/target-app-parity.md | 1 + 14 files changed, 139 insertions(+), 39 deletions(-) diff --git a/crates/ui-egui/src/chrome/status_bar.rs b/crates/ui-egui/src/chrome/status_bar.rs index 9501d9f83b..1e946b56da 100644 --- a/crates/ui-egui/src/chrome/status_bar.rs +++ b/crates/ui-egui/src/chrome/status_bar.rs @@ -248,8 +248,9 @@ pub(super) fn status_readouts(app: &VectorcraftApp, ui: &mut Ui, t: &Tokens) { /// Most notes the status message's hover and access information list. const MAX_LISTED_NOTES: usize = 30; -/// The notes behind the status message (the open's, [`crate::state::UiState::status_notes`]) when -/// there are several and the status bar still shows the message they came with, else none. +/// The notes behind the status message (an open's, a save's or an export's, +/// [`crate::state::UiState::status_notes`]) when there are several and the status bar still shows +/// the message they came with, else none. fn status_notes(ui: &crate::state::UiState) -> &[String] { let notes = ui.status_notes.as_ref().filter(|(status, notes)| *status == ui.status && notes.len() > 1); notes.map(|(_, notes)| notes.as_slice()).unwrap_or_default() diff --git a/crates/ui-egui/src/dialogs/save_for_web.rs b/crates/ui-egui/src/dialogs/save_for_web.rs index 924f8c4078..64dc2fae8c 100644 --- a/crates/ui-egui/src/dialogs/save_for_web.rs +++ b/crates/ui-egui/src/dialogs/save_for_web.rs @@ -133,8 +133,8 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { } /// `file.saveForWeb` with settings: write the files (`path`, else a picked file: the page with HTML -/// output, else the image); the web downloads them. The status bar shows the export's first -/// warning. +/// output, else the image); the web downloads them. The status bar shows the export's warnings as +/// [`io::report_saved`] shows a save's notes. pub(crate) fn save(app: &mut VectorcraftApp, mut p: Value) -> Result { // Bad settings never open a save panel. let s = app.session.web_settings(&p)?; @@ -170,7 +170,7 @@ pub(crate) fn save(app: &mut VectorcraftApp, mut p: Value) -> Result bool { r["warnings"].as_array().is_some_and(|w| !w.is_empty()) } @@ -183,8 +183,8 @@ pub(super) fn preview_name(stem: &str) -> &str { } /// `file.saveForWeb.browser`: the HTML page and its images in a new temporary folder, opened in the -/// default browser (desktop). When the export has warnings, the status bar shows the first in -/// place of "Opened …". +/// default browser (desktop). When the export has warnings, the status bar shows them as +/// [`io::report_saved`] shows a save's notes. pub(crate) fn browser_preview(app: &mut VectorcraftApp, p: &Value) -> Result { if io::is_web(app) { return Err("Preview in Browser needs the desktop app (the web downloads files instead)".into()); diff --git a/crates/ui-egui/src/dialogs/tests_screens.rs b/crates/ui-egui/src/dialogs/tests_screens.rs index 140623f209..97e2b16ee7 100644 --- a/crates/ui-egui/src/dialogs/tests_screens.rs +++ b/crates/ui-egui/src/dialogs/tests_screens.rs @@ -508,6 +508,35 @@ fn the_status_bar_says_when_an_image_could_not_be_decoded() { let _ = std::fs::remove_dir_all(dir); } +/// An export into a folder with several notes, from Export for Screens and from the Asset Export +/// panel: the status bar names the first, counts them, and keeps every one of them for its hover. +#[test] +fn an_export_into_a_folder_keeps_every_note_for_the_hover() { + let mut png = Vec::new(); + image::RgbaImage::from_pixel(64, 32, image::Rgba([200, 30, 30, 255])) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .unwrap(); + let dir = temp_folder("notes"); + let folder = dir.to_string_lossy().replace('\\', "/"); + let rows = json!([{"format": "png", "scale": "1x", "suffix": ""}]); + let (mut app, _) = app(1, false); + vectorcraft_testkit::fixtures::place_cut_png(&mut app.session, &png, json!({"at": [10, 10]})); + app.run("text.create", json!({"x": 5, "y": 20, "text": "Ab", "font": "Nonexistent Sans Probe"})).unwrap(); + let r = app.run("file.exportForScreens", json!({"folder": folder, "formats": rows})).unwrap(); + let notes = crate::io::notes_of(&r); + assert_eq!(notes.len(), 2, "{r}"); + assert!(app.ui.status.starts_with(&format!("Saved {folder} with 2 notes: {}", notes[0])), "{}", app.ui.status); + assert_eq!(app.ui.status_notes, Some((app.ui.status.clone(), notes))); + app.ui.status_notes = None; + app.run("select.all", json!({})).unwrap(); + app.run("assets.add", json!({"multiple": false})).unwrap(); + let r = crate::io::export_files(&mut app, "assets.export", json!({"folder": folder, "formats": rows})).unwrap(); + let notes = crate::io::notes_of(&r); + assert_eq!(notes.len(), 2, "{r}"); + assert_eq!(app.ui.status_notes, Some((app.ui.status.clone(), notes)), "assets.export"); + let _ = std::fs::remove_dir_all(dir); +} + #[test] fn a_preset_export_reopens_on_its_rows() { let (mut app, _) = app(1, true); diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index 93abdfd31c..dff4fa0dd2 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -30,7 +30,7 @@ pub use open::{ #[cfg(test)] pub(crate) use open::{open_file, opened_with_notes}; pub use save::{ask_format_options, ask_pdf_options, remember_active_artboard, remember_view, save, save_pdf}; -pub(crate) use save::{export_path, is_web, report_saved, target_path, write_named, write_reporting, write_to}; +pub(crate) use save::{export_path, is_web, notes_of, report_saved, show_with_notes, target_path, write_named, write_reporting, write_to}; #[cfg(test)] pub(crate) use save::{report_write_error, saved_status}; @@ -90,7 +90,7 @@ mod tests { use vectorcraft_testkit::ai; let (mut app, _) = app(); let damaged = b"%AI12_CompressedData not zlib"; - let notes = |r: &Value| -> Vec { r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).map(str::to_string).collect() }; + let notes = notes_of; let pdf = ai::pdf(damaged, "0 g 150 20 100 60 re f"); let r = open_bytes(&mut app, "damaged.pdf", &pdf, None).unwrap(); let [page, layers] = ¬es(&r)[..] else { panic!("two notes: {r}") }; @@ -125,6 +125,36 @@ mod tests { assert_eq!((app.ui.status.clone(), app.ui.status_notes.clone()), shown); } + /// A save and an export with several notes: the status bar names the first, counts them, and + /// keeps every one of them, in the reply's order, for its hover. A save without notes keeps + /// none. + #[test] + fn a_save_and_an_export_keep_every_note_for_the_hover() { + let (mut app, _) = app(); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + let r = app.run("file.save", json!({"path": "/out/a.svg", "compress": true})).unwrap(); + let notes = notes_of(&r); + assert_eq!(notes.len(), 2, "{r}"); + assert!(notes[0].starts_with("SVG keeps the artwork but not everything"), "{r}"); + assert_eq!(app.ui.status, saved_status("/out/a.svg", &r)); + assert!(app.ui.status.contains("with 2 notes"), "{}", app.ui.status); + assert_eq!(app.ui.status_notes, Some((app.ui.status.clone(), notes))); + + app.run("file.save", json!({"path": "/out/b.vectorcraft"})).unwrap(); + assert_eq!((app.ui.status.as_str(), &app.ui.status_notes), ("Saved /out/b.vectorcraft", &None)); + + // A PDF export of a knockout group and of type in a font that isn't installed. + app.run("text.create", json!({"x": 5, "y": 20, "text": "Ab", "font": "Nonexistent Sans Probe"})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap(); + app.run("object.group", json!({})).unwrap(); + app.run("transparency.set", json!({"knockout": "on"})).unwrap(); + let r = export(&mut app, Some("pdf"), Some("/out/c.pdf".into()), &json!({})).unwrap(); + let notes = notes_of(&r); + assert_eq!(notes.len(), 2, "{r}"); + assert_eq!(app.ui.status, exported_with_notes("/out/c.pdf", notes.len(), ¬es[0])); + assert_eq!(app.ui.status_notes, Some((app.ui.status.clone(), notes))); + } + /// #1685: a save or an export that can't write its file says so in a dialog that names the /// file and the reason and stays until it is closed; the document stays modified and keeps /// the file it had. An agent's request only gets the error, and a message that arrives while diff --git a/crates/ui-egui/src/io/export.rs b/crates/ui-egui/src/io/export.rs index 562dc0e00a..246511669e 100644 --- a/crates/ui-egui/src/io/export.rs +++ b/crates/ui-egui/src/io/export.rs @@ -1,6 +1,6 @@ use super::save::{ - ask_before_losing, export_path, is_web, remember_active_artboard, report_saved, report_write_error, saved_status, target_path, - target_path_keeping, view_file, write_encoded, write_named, write_reporting, + ask_before_losing, export_path, is_web, notes_of, remember_active_artboard, report_saved, report_write_error, saved_status, show_with_notes, + target_path, target_path_keeping, view_file, write_encoded, write_named, write_reporting, }; use super::{EngineError, Progress, Value, VectorcraftApp, Writer, background, cancel, fileio, json, progress}; @@ -94,11 +94,12 @@ pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option format!("{} files", files.len()), None => r["path"].as_str().unwrap_or_default().to_string(), }; - let warnings: Vec<&str> = r["warnings"].as_array().map(|w| w.iter().filter_map(Value::as_str).collect()).unwrap_or_default(); - app.status(match warnings.first() { + let warnings = notes_of(&r); + let status = match warnings.first() { Some(first) => exported_with_notes(&what, warnings.len(), first), None => format!("Exported {what}"), - }); + }; + show_with_notes(app, status, warnings); Ok(r) })?; if r.get("path").is_none() { @@ -115,7 +116,7 @@ pub(crate) fn exported_with_notes(what: &str, n: usize, first: &str) -> String { /// Run an engine command that returns `{dataBase64}` and write the bytes to a picked path /// (Export Selection, Save for Office Documents, Text Export Options and the presets dialogs' -/// Export…), reported with the first of the command's warnings ([`report_saved`]). +/// Export…), reported with the command's warnings ([`report_saved`]). pub fn save_command_output(app: &mut VectorcraftApp, id: &str, ext: &str, params: Value) -> Result { let (path, v) = run_to_file(app, id, ext, params)?; report_saved(app, &path, &v); @@ -241,8 +242,9 @@ pub(crate) fn open_original(app: &mut VectorcraftApp, path: &str) -> Result<(), /// File → Export for Screens with `document.exportForScreens` params: the files go into `folder`; /// without one the web downloads them (the ZIP, or each file) and the other frontends get them /// back as base64. With `openLocation`, the file manager shows the first file written. When an -/// export into a folder or a download has warnings, the status bar shows the first as -/// [`report_saved`] shows a save's notes: "Saved with n notes: …". +/// export into a folder or a download has warnings, the status bar shows them as [`report_saved`] +/// shows a save's notes: "Saved with n notes: …", and hovering the +/// message lists them all. pub fn export_for_screens(app: &mut VectorcraftApp, params: Value) -> Result { export_files(app, "document.exportForScreens", params) } @@ -312,7 +314,7 @@ fn report_exported(app: &mut VectorcraftApp, r: Value, folder: &str, open_locati { status = format!("{status} ({e})"); } - app.status(status); + show_with_notes(app, status, notes_of(&r)); } else if let Some(download) = app.services.download.as_mut() { // A browser download has no folders: a file from a sub-folder (`2x/Logo@2x.png`) is named alone. let returned: Vec<(&str, &str)> = returned.iter().map(|(name, data)| (name.rsplit('/').next().unwrap_or(name), *data)).collect(); diff --git a/crates/ui-egui/src/io/open.rs b/crates/ui-egui/src/io/open.rs index 3ca34cdd77..0328d2b07b 100644 --- a/crates/ui-egui/src/io/open.rs +++ b/crates/ui-egui/src/io/open.rs @@ -1,7 +1,7 @@ use vectorcraft_engine::cmd::plugin::InstallApproval; use vectorcraft_plugins::permissions; -use super::save::suggested; +use super::save::{notes_of, show_with_notes, suggested}; use super::{Dialog, FilePick, Format, Value, VectorcraftApp, dialogs, fileio, json}; /// Template extensions New from Template's open dialog lists first. @@ -144,8 +144,8 @@ pub(crate) fn opened(app: &mut VectorcraftApp, r: &Value) { app.sync_views(); let notes = open_notes(app, r); if let Some(first) = notes.first() { - app.status(opened_with_notes(notes.len(), first)); - app.ui.status_notes = Some((app.ui.status.clone(), notes)); + let status = opened_with_notes(notes.len(), first); + show_with_notes(app, status, notes); } crate::dialogs::missing_links::after_open(app, r); } @@ -153,7 +153,7 @@ pub(crate) fn opened(app: &mut VectorcraftApp, r: &Value) { /// The notes of the open `r`, those of what reading the file left out (its document's /// `import_losses`) first, each group in the order the import gave them. fn open_notes(app: &VectorcraftApp, r: &Value) -> Vec { - let notes: Vec = r["warnings"].as_array().into_iter().flatten().filter_map(Value::as_str).map(str::to_string).collect(); + let notes = notes_of(r); let index = r["index"].as_u64().and_then(|i| usize::try_from(i).ok()); let losses = index.and_then(|i| app.session.documents().get(i)).map(|st| st.import_losses.as_slice()).unwrap_or_default(); let (mut first, rest): (Vec, Vec) = notes.into_iter().partition(|n| losses.contains(n)); diff --git a/crates/ui-egui/src/io/save.rs b/crates/ui-egui/src/io/save.rs index cfbdaaadba..708557fc4a 100644 --- a/crates/ui-egui/src/io/save.rs +++ b/crates/ui-egui/src/io/save.rs @@ -394,20 +394,34 @@ pub(super) fn view_file(app: &mut VectorcraftApp, path: &str) { } } -/// "Saved ", with the first of the result's warnings. +/// "Saved ", with the first of the result's warnings and how many there are; hovering the +/// message lists them all ([`show_with_notes`]). pub(crate) fn report_saved(app: &mut VectorcraftApp, path: &str, r: &Value) { - app.status(saved_status(path, r)); + show_with_notes(app, saved_status(path, r), notes_of(r)); +} + +/// The warnings of a reply `r`, in the reply's order (a save's notes start with what the format +/// loses). +pub(crate) fn notes_of(r: &Value) -> Vec { + r["warnings"].as_array().into_iter().flatten().filter_map(Value::as_str).map(str::to_string).collect() +} + +/// Show `status`, which names the first of `notes`, in the status bar and keep `notes` for its +/// hover ([`crate::state::UiState::status_notes`]). Without notes nothing is kept. +pub(crate) fn show_with_notes(app: &mut VectorcraftApp, status: String, notes: Vec) { + app.status(status); + app.ui.status_notes = (!notes.is_empty()).then(|| (app.ui.status.clone(), notes)); } /// [`report_saved`]'s message. pub(crate) fn saved_status(path: &str, r: &Value) -> String { - let warnings: Vec<&str> = r["warnings"].as_array().map(|w| w.iter().filter_map(Value::as_str).collect()).unwrap_or_default(); + let warnings = notes_of(r); match warnings.first() { Some(first) => crate::i18n::msg_n( warnings.len() as u64, "Saved {path} with {n} note: {first}", "Saved {path} with {n} notes: {first}", - &[("path", path), ("first", first)], + &[("path", path), ("first", first.as_str())], ), None => format!("Saved {path}"), } diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 19a6a1da67..6c842814e1 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -1487,7 +1487,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "css.exportFile", "Export CSS…", "", - "{path?, scope?: selection|all (default selection), …css.selection options} css.export written to path, else a picked file (the web downloads it), with the pictures of rasterized art next to it (CSS Properties panel: Export Selected CSS…, Export All…) → {path, rules, images: [path…], warnings (css.export's; the status bar shows the first)}", + "{path?, scope?: selection|all (default selection), …css.selection options} css.export written to path, else a picked file (the web downloads it), with the pictures of rasterized art next to it (CSS Properties panel: Export Selected CSS…, Export All…) → {path, rules, images: [path…], warnings (css.export's; the status bar shows the first and how many there are, and hovering the message lists them all)}", ), ( "file.print", diff --git a/crates/ui-egui/src/panels/css_properties.rs b/crates/ui-egui/src/panels/css_properties.rs index bc362fb576..eae559dff5 100644 --- a/crates/ui-egui/src/panels/css_properties.rs +++ b/crates/ui-egui/src/panels/css_properties.rs @@ -86,7 +86,7 @@ fn shown(app: &mut VectorcraftApp, ui: &Ui, opts: &CssOptions, all: bool) -> Opt let (id, scope) = if all { ("css.generate", "all") } else { ("css.selection", "selection") }; let out = app.session.execute(id, ¶ms(opts, scope)).unwrap_or_default(); let rules: Vec<&str> = out["rules"].as_array().into_iter().flatten().filter_map(|r| r["css"].as_str()).collect(); - let warnings = out["warnings"].as_array().into_iter().flatten().filter_map(Value::as_str).map(str::to_string).collect(); + let warnings = io::notes_of(&out); let job = (!rules.is_empty()) .then(|| Arc::new(highlight(rules.get(..MAX_SHOWN).unwrap_or(&rules), &t, egui::TextStyle::Monospace.resolve(ui.style())))); let skipped = out["skipped"].as_u64().unwrap_or(0); @@ -204,13 +204,14 @@ pub(crate) fn export_file(app: &mut VectorcraftApp, p: &Value) -> Result = r["warnings"].as_array().map(|w| w.iter().filter_map(Value::as_str).collect()).unwrap_or_default(); + // The status bar shows the warnings as Export As shows them. + let warnings = io::notes_of(&r); let what = &path; - app.status(match warnings.first() { + let status = match warnings.first() { Some(first) => io::exported_with_notes(what, warnings.len(), first), None => format!("Exported {path}"), - }); + }; + io::show_with_notes(app, status, warnings); Ok(json!({ "path": path, "rules": r["rules"], "images": images, "warnings": r["warnings"] })) } diff --git a/crates/ui-egui/src/panels/tests_css_properties.rs b/crates/ui-egui/src/panels/tests_css_properties.rs index 619be4d70b..e03fc9a59c 100644 --- a/crates/ui-egui/src/panels/tests_css_properties.rs +++ b/crates/ui-egui/src/panels/tests_css_properties.rs @@ -146,3 +146,23 @@ fn export_says_in_the_status_bar_when_an_image_could_not_be_decoded() { assert_eq!(app.ui.status, format!("Exported /tmp/site/site.css with 1 note: {WARNING}")); assert_eq!(log.borrow().len(), 3, "the sheet and the image's picture are still written"); } + +/// A CSS export with several notes: the status bar names the first, counts them, and keeps every +/// one of them for its hover. +#[test] +fn export_keeps_every_note_for_the_hover() { + let (mut app, _) = app(); + let mut png = Vec::new(); + image::RgbaImage::from_pixel(64, 32, image::Rgba([200, 30, 30, 255])) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .unwrap(); + vectorcraft_testkit::fixtures::place_cut_png(&mut app.session, &png, json!({})); + // Type painted with a gradient is rasterized, and its picture is drawn in the fallback font. + app.run("text.create", json!({"x": 5, "y": 20, "text": "Ab", "font": "Nonexistent Sans Probe"})).unwrap(); + app.run("paint.setFill", json!({"gradient": {"stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]}})).unwrap(); + let r = app.run("css.exportFile", json!({"path": "/tmp/site/site.css", "scope": "all", "rasterize": true})).unwrap(); + let notes = crate::io::notes_of(&r); + assert_eq!(notes.len(), 2, "{r}"); + assert_eq!(app.ui.status, format!("Exported /tmp/site/site.css with 2 notes: {}", notes[0])); + assert_eq!(app.ui.status_notes, Some((app.ui.status.clone(), notes))); +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 52e492d4d3..0059c176f4 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -537,8 +537,9 @@ pub struct UiState { /// The Replace Font request behind the status bar's warning ([`ReplaceFontWarning`]). Not saved. #[serde(skip)] pub replace_font_warning: Option, - /// The notes of the last open that gave any, with the status text that names the first of - /// them: hovering that text lists them when there are several. Not saved. + /// The notes of the last open that gave any, or of the last save or export, with the status + /// text that names the first of them: hovering that text lists them when there are several. + /// Not saved. #[serde(skip)] pub status_notes: Option<(String, Vec)>, pub palette_open: bool, diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 704ec14d6f..1341440a41 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -107,7 +107,7 @@ the two can still win that race. | `ui.render` | `{path?, scale?, artboard?, data?, traceViews?}` | render an artboard headlessly (PNG with the pixels of `app.export {format: "png", background: "white"}` at the same scale; `artboard`: its 0-based index, default the first; `traceViews: true`: Image Trace objects draw in their View, as on the canvas, instead of their tracing result): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` | | `ui.render` | `{path?, scale?, artboard?, data?}` | render an artboard headlessly (PNG with the pixels of `app.export {format: "png", background: "white"}` at the same scale; `artboard`: its 0-based index, default the first): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` | | `ui.resize` / `ui.focus` | | | -| `app.open` / `app.save` / `app.export` / `app.quit` | `{path, importer?, params?}` / `{path?}` / `{path?, format?, artboard?, range?, scale?, …}` | `app.open` reads every format `document.open` reads (see `document.formats`) and returns its result (`{index, title, format, warnings, …}`; `warnings` say what didn't come in as it was, such as an EPS shown as its preview image and why its PostScript couldn't be read), or null when a dialog asks first or a library loads. The status bar (`ui.inspect` → `ui.status`) counts the `warnings` and names the first that says reading the file left something out (the notes that make Save ask before writing over it), else the first; with several, hovering it lists them in that order, as many as fit in the window (at most 30), then how many more. With `importer` (an importer plug-in's format id, `plugin.`) it runs `file.openWith`: the file opens with that importer, also when a built-in format reads its extension, with the importer's parameters `params` (their defaults when omitted; no dialog asks for them). Without `importer`, `params` go to the importer that opens the file, and a built-in format does not read them. `app.export` encodes through the engine's `document.export` (same options; the document keeps its path) and writes `path` through the host; without `path` it returns `{dataBase64, format, bytes}`, as headless mode does. It refuses a key that no format takes, and an unknown key inside a PDF or EPS option object, before it writes anything (`docs/mcp.md`, Parameter keys). `app.save` refuses a key that no save format takes, beside the path or in `options`, the same way. `app.quit`, `file.close` and `file.closeAll` first open a `saveChanges` dialog for each modified document (they return `{"pending": "saveChanges"}`): `ui.dialog.confirm` saves, `ui.dialog.set {field: "discard", value: true}` then confirm discards, `ui.dialog.cancel` cancels the whole close or quit | +| `app.open` / `app.save` / `app.export` / `app.quit` | `{path, importer?, params?}` / `{path?}` / `{path?, format?, artboard?, range?, scale?, …}` | `app.open` reads every format `document.open` reads (see `document.formats`) and returns its result (`{index, title, format, warnings, …}`; `warnings` say what didn't come in as it was, such as an EPS shown as its preview image and why its PostScript couldn't be read), or null when a dialog asks first or a library loads. The status bar (`ui.inspect` → `ui.status`) counts the `warnings` and names the first that says reading the file left something out (the notes that make Save ask before writing over it), else the first; with several, hovering it lists them in that order, as many as fit in the window (at most 30), then how many more. After `app.save`, and `app.export` with a `path`, the status bar counts the reply's `warnings` and names the first, and with several, hovering it lists them in the reply's order (a save's notes start with what the format loses). With `importer` (an importer plug-in's format id, `plugin.`) it runs `file.openWith`: the file opens with that importer, also when a built-in format reads its extension, with the importer's parameters `params` (their defaults when omitted; no dialog asks for them). Without `importer`, `params` go to the importer that opens the file, and a built-in format does not read them. `app.export` encodes through the engine's `document.export` (same options; the document keeps its path) and writes `path` through the host; without `path` it returns `{dataBase64, format, bytes}`, as headless mode does. It refuses a key that no format takes, and an unknown key inside a PDF or EPS option object, before it writes anything (`docs/mcp.md`, Parameter keys). `app.save` refuses a key that no save format takes, beside the path or in `options`, the same way. `app.quit`, `file.close` and `file.closeAll` first open a `saveChanges` dialog for each modified document (they return `{"pending": "saveChanges"}`): `ui.dialog.confirm` saves, `ui.dialog.set {field: "discard", value: true}` then confirm discards, `ui.dialog.cancel` cancels the whole close or quit | | `app.export` with `useArtboards` | `{path, useArtboards: true, range? \| artboards?, …}` | Export As: PNG, JPEG and WebP write one file per chosen artboard (default all; SVG does so for a `range`), `-.`, and return `{path, files: [path…]}`; a PDF keeps them as pages of `path`. `useArtboards: false` covers the bounds of the visible art. The menu's Export As… is `file.exportAs`: the `exportAs` dialog (`format`, `useArtboards`, `all`, `range`), then a save dialog and the format's options: `pngOptions` / `jpgOptions` / `webpOptions`, `svgOptions`, or `savePdf` for a PDF with Use Artboards | | `ui.dialog.*` on `gradientStop` | `{field: "color" \| "opacity" \| "location", value}` | double-clicking a stop on the Gradient tool's annotator opens its popover (fields `index`, `x`, `y`, `tab`); set `color` (hex), `opacity` or `location` (percentages) and `ui.dialog.confirm` to apply them to the selected stop. `gradient.selectStop {index}` picks the stop the annotator, the panels and Delete/←/→ (`ui.key`) act on | | `engine.execute` `ui.savePdfDialog` | `{path?, preset?, range?, …document.exportPdf options}` | opens the Save PDF dialog (`file.export.pdf` with no params opens it too). Its fields are the `document.exportPdf` options: set `compatibility` with `ui.dialog.set {field: "compatibility", value: "1.5"}`, a whole section with `{field: "compression", value: {…}}`, the artboards with `__allArtboards: false` and `range`, the visible section with `__section` (General, Compression, Marks and Bleeds, Output, Advanced, Security, Summary). `ui.dialog.confirm` writes `path` (else asks for one), returns `{path, bytes, warnings}` and opens the file when `viewAfterSaving` is set | @@ -118,13 +118,13 @@ the two can still win that race. | `app.export` JPEG options | `{path?, format: jpg, quality?: 0–100, colorModel?: rgb \| cmyk \| gray, method?: baseline \| optimized \| progressive, scans?: 3–5, embedIcc?, imageMap?: none \| client \| server}` | `cmyk` writes ink amounts in the working CMYK space (CMYK colours keep their inks; RGB ones are separated with the colour settings); `embedIcc` (default true) embeds sRGB, the working CMYK profile or a grey profile, generated by the CMS; `imageMap` also writes `.html` (client-side ``) or `.map` (NCSA) beside the image for the objects with a URL and an Image Map shape (`attributes.set`), returned in `linked`. The `jpgOptions` dialog has these fields too (`quality` is shown 0–10; a CMYK document defaults to `cmyk`) | | `app.export` PNG-8 and GIF | `{path?, format: png8 \| gif, colors?: 2–256, reduction?: perceptual \| selective \| adaptive \| web \| blackWhite \| gray, dither?: none \| diffusion \| pattern \| noise, ditherAmount?: 0–100, transparency?, matte?, interlaced?, …raster options}` | a palette of at most `colors` entries (art with that many colours or fewer keeps them exactly); pixels under half opacity become one transparent entry unless `transparency` is false, partly transparent edges are blended over `matte` (`none` keeps their colour). `png8` writes an indexed `.png` (1–8 bits per pixel); Export for Screens rows take both. The `png8Options` / `gifOptions` dialogs have these fields | | `app.export` Text and WebP | `{path?, format: txt, encoding?: utf8 \| utf16, lineEndings?: lf \| crlf, selectionOnly?}` / `{format: webp, lossless?, quality?}` | Text writes the stories in stacking order (back to front; a thread once; hidden objects and template layers left out), each line ended by `lineEndings`; `utf16` is little-endian with a byte order mark. Export As → Text opens `txtOptions` (fields `path`, `encoding`, `lineEndings`, `selectionOnly`; OK writes the file). WebP is always lossless: `lossless: false` adds a warning | -| `engine.execute` `document.exportForOffice` | `{}` / `{path?, ppi?: 72 \| 150 \| 300, transparent?, artboard?}` | File → Save for Office Documents…: without params opens `saveForOffice` (fields `ppi`, `transparent`, `artboard`; OK picks the file); with params writes the artboard as a PNG (`path`, else a picked file) → `{path}`, and the status bar shows the first of the export's `warnings` as Save As shows a save's notes. Headless it returns `{dataBase64, width, height, warnings}` | +| `engine.execute` `document.exportForOffice` | `{}` / `{path?, ppi?: 72 \| 150 \| 300, transparent?, artboard?}` | File → Save for Office Documents…: without params opens `saveForOffice` (fields `ppi`, `transparent`, `artboard`; OK picks the file); with params writes the artboard as a PNG (`path`, else a picked file) → `{path}`, and the status bar shows the export's `warnings` as Save As shows a save's notes. Headless it returns `{dataBase64, width, height, warnings}` | | Native file dialogs | any command with no `path` that needs one (`file.open`, `file.saveAs`, `file.place`, exports…) | on macOS (a sheet on the window) and Linux the dialog doesn't hold the window: the call returns `"cancelled"` at once, `ui.inspect` reports `fileDialog`, and when the person at the computer picks a file the command runs again on the document it was asked for. While it is open another dialog is refused: that call returns `"cancelled"` too, and the status bar (`ui.inspect` → `ui.status`) says `"A file dialog is open: choose there first"`; on macOS `app.quit`, `file.close` and `file.closeAll` return that message and the menu bar does nothing (#592, #867). On Windows the dialog is shown in line: the call returns once it closes. Pass `path` to skip the dialog | | Background Save / Export | `app.save`, `app.export {path}`, `file.save`, `file.saveAs`, `file.saveCopy`, `file.exportForScreens {folder, …}` | with the `backgroundSave` / `backgroundExport` preferences on (the default) the desktop app writes the file from a snapshot on a worker thread: the call returns `{path, background: true, status}` at once (`file.exportForScreens`: `{background: true, status}`, and the files are written into the folder from a snapshot taken at the call), `ui.inspect` lists the jobs still running in `background` (labels such as `"Saving a.vectorcraft"`), and the document counts as saved (as of the snapshot) once its file is written. Quit and Close wait for them. A job that fails reports the error in the status bar only (`ui.inspect` → `ui.status`), a path outside `--automation-write-root` included. With the preferences off (`prefs.set`), saves return once written. `app.save {path?, wait: true}` and `app.export {path, wait: true}` wait for the jobs already running, write the file before they reply whatever the preferences are set to, and return its result or the error. When the save asks first (the question before replacing a file that opened with losses, or on macOS and Linux the file dialog for an untitled document without `path`), the reply is `{pending}` or `"cancelled"` as before, and the save that runs after the answer uses the preferences. A `wait` that is not `true`, `false` or `null` is refused with "`wait` must be true or false", and nothing is written | | `engine.execute` `file.exportForScreens` | `{}` / `{…document.exportForScreens params}` | without params opens `exportForScreens` on the settings the document last exported with (`document.exportSettings`). While the Artboard tool is in use, the artboards it has selected (its `selected` option, the rows the Artboards panel highlights) are checked instead, and `select` is `range`, or `all` when every artboard is selected. Fields: `tab` (`artboards` \| `assets` \| `layers` \| `selection`; OK reads Export Layer or Export Selection on those), `layers` ([layer id…] checked), `selectionMode` (`one` \| `each`), `name` (the file name pattern of `document.exportForScreens`), `counterStart`, `folders` (`none` \| `rows` \| `format` \| `item` \| `itemFormat`), `exportPreset` (the preset applied last), `__savePreset` (the name typed to save one), `select` (`all` \| `range` \| `full`), `range` ("1-3, 5"), `boards` (a bool per artboard; checking them sets `select` and `range`), `includeBleed`, `folder` (desktop; default the Desktop, else home), `openLocation`, `subfolders`, `prefix`, `preset` (`""` \| `mobile` \| `density`), `formats` (rows `{format, scale: "2x" \| "100w" \| "100h" \| "72ppi", suffix, quality?, folder?}`), `settings` (`{png \| png8 \| jpg \| webp \| gif \| svg \| pdf: {…options}}`, the gear's Format Settings; `__settings` names the format shown). `ui.dialog.confirm` exports (a bad range or no folder keeps it open); on the web OK reads "Download" and the files download (several as one `.zip`). With params it runs `document.exportForScreens`: no `folder` returns the files (or with `zip: true` one zip) as `dataBase64`; with `openLocation` the first file is shown in the file manager. When `warnings` is not empty, an export into a folder or a download shows the first in the status bar as Save As shows a save's notes | | Export for Screens' Assets tab, Asset Export panel | `ui.dialog.set` `tab: "assets"`, `assets: [asset id…]`; `window.panel {panel: "assetExport"}` | on the Assets tab of `exportForScreens` the `assets` field lists the checked asset ids (all of them when it opens); `ui.dialog.confirm` runs `document.exportForScreens {assets}` (nothing checked keeps it open) and OK reads "Export Asset". The Asset Export panel acts through `assets.*` and shares the format rows with the dialog (`assets.settings.set`). The dialog has the format presets menu (☰). In the dialog, `exportPreset` names the preset applied last (`ui.dialog.set` of it applies the preset it names, as picking it does; a name that is not a preset's is an error and leaves the dialog as it was), `__exportPresets` lists the presets, and `__savePreset` holds the name being typed after Save Preset…. The panel menu applies presets through `assets.settings.set {exportPreset}` and holds Save As Preset…, Save Changes, Delete Preset, Import Preset… and Export Preset…. Import and Export use picked files. | | `engine.execute` `file.exportSelection` | `{multiple?}` | File › Export Selection…: collects the selected objects as one asset (`assets.add`; one per object with `multiple: true`) and opens `exportForScreens` on its Assets tab with just them checked → `{assets}`. `document.exportSelection` still exports the selection in one go, without the dialog | -| CSS Properties panel | `window.panel {panel: "cssProperties"}`; `engine.execute` `css.copy` / `css.exportFile` | the panel shows `css.selection` of the selection (after Generate CSS, while nothing is selected, `css.generate`); its menu holds the options the commands take (`units`, `position`, `dimensions`, `unnamed`, `rasterize`). `css.copy {scope?, …options}` copies the CSS to the clipboard → `{css, rules}`; `css.exportFile {path?, scope?, …options}` writes `css.export` to `path` (else a picked file; the web downloads it) with the pictures of rasterized art next to it → `{path, rules, images, warnings}`, and the status bar shows the first of `warnings` as Export As shows an export's notes | +| CSS Properties panel | `window.panel {panel: "cssProperties"}`; `engine.execute` `css.copy` / `css.exportFile` | the panel shows `css.selection` of the selection (after Generate CSS, while nothing is selected, `css.generate`); its menu holds the options the commands take (`units`, `position`, `dimensions`, `unnamed`, `rasterize`). `css.copy {scope?, …options}` copies the CSS to the clipboard → `{css, rules}`; `css.exportFile {path?, scope?, …options}` writes `css.export` to `path` (else a picked file; the web downloads it) with the pictures of rasterized art next to it → `{path, rules, images, warnings}`, and the status bar shows `warnings` as Export As shows an export's notes | | `ui.dialog.*` on `importPdf` | `{field: "page" \| "allPages" \| "range" \| "cropTo" \| "password", value}` | opening (`app.open`, `file.open`) a PDF, `.ai` or `.ait` with a password, or one with several pages unless it is a `.ai` or `.ait` with its editing data or carries its VectorCraft document (those open whole), or placing (`file.place` without `page`) any of them with several pages or a password, opens the Import PDF dialog (`mode`: `open` or `place`; `pageCount`). While `__locked` is true, set `password` and `ui.dialog.confirm` to unlock it (a one-page file then opens or is placed). Then set `allPages: false` and `range` ("2-3, 5") to open some pages, or `page` to place one, and `cropTo` (`bounding`, `art`, `crop`, `trim`, `bleed`, `media`); `ui.dialog.confirm` opens or places them (placing keeps the other `file.place` params, such as `link`, `template` and `at`, in `__place`). `document.open {pages, cropTo, password}` and `file.place {page, crop, password}` do the same without the dialog | @@ -813,7 +813,7 @@ a preset) plus `__view` (`original`, `optimized`, `2up`, `4up`), `__zoom` (`fit` the image, or the page with HTML output; the web downloads the files); with `discard: true` it is Done: it remembers the settings and closes. `file.saveForWeb` with settings saves without the dialog, and `file.saveForWeb.browser` writes the HTML page to a temporary folder and opens it in the default browser (desktop). Both return the export's -`warnings`, and the status bar shows the first one as Save As shows a save's notes. +`warnings`, and the status bar shows them as Save As shows a save's notes. PSD Options: Export As… with the PSD format (`file.exportAs {format: "psd"}`, then the save dialog) opens `psdOptions`: the raster rows of PNG Options (`ppi`, `background`, `antiAlias`), then `colorModel` (`rgb`, `cmyk`, `gray`; a CMYK diff --git a/docs/mcp.md b/docs/mcp.md index da260b6823..4905ee30f7 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6385,7 +6385,8 @@ point, as in SVG export), `pt`, `mm`, `cm` or `in`; `unnamed: false` leaves out writes the `.css` file with the PNGs of rasterized art next to it (no path: `data` and `images` as base64). Its `warnings` add the PNGs' notes to the CSS's, each once (an image that couldn't be decoded, type written in the fallback font…). In the app, `css.copy` copies the CSS and `css.exportFile` writes it through a save panel (the web -downloads it) and shows the first note in the status bar. +downloads it) and shows the first note in the status bar, with how many there are; resting the pointer on the +message lists them all. ```json {"name":"run_command","arguments":{"command":"css.selection","params":{"units":"px","position":true}}} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 18d50447ff..4dc4fcf315 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -577,6 +577,7 @@ since 2026-10-01. - **Stable status-bar readouts:** The status message keeps its left anchor and clipping allocation as live values, canvas hover and background-job state change. Coordinate and spinner slots stay reserved; narrow windows hide optional diagnostics, then coordinates, to preserve message space. Widths are measured from the active font, translated labels and fixed numeric samples, never current readings. Coordinates through +/-9999.99 selected units, timings through 9999.9 ms and FPS through 9999 remain readable. Extreme values are elided with full tooltip/accessibility text; only elided fields show a tooltip. Coordinate inspection retains the last point per document and clears on PointerGone. Regressions cover message origin/clip/bounds/elision invariance, all registered languages and ten units, responsive layouts, coordinate inspection and document switching, and accessible busy transitions. The contributor rebuilt and confirmed the original status-bar issue is fixed with their own artwork on 2026-10-19; the subsequent upstream rebase is checked by regressions and full CI. The artwork stays local. Scores and estimates are unchanged. Sample widths use a bounded single-entry UI cache keyed by language, unit and pixels per point; two regressions verify reuse during live updates and invalidation after each display-setting change. - **Clipped bottom-bar text (#6622):** A Help Bar hint wider than the window, such as the Dimension tool's hint in an 800-point window, ends in an ellipsis, as the status message does. In a narrow status bar the tool name ends in an ellipsis and leaves the status message its room. Resting the pointer on a clipped hint, tool name or message shows the full text in a tooltip that wraps within the window. Text that fits shows no tooltip, and screen readers receive the full text. Scores and estimates are unchanged. - **Notes of an open in the status bar:** After File › Open the status message names the first note that says reading the file left something out (the notes that make Save ask before writing over the file), else the first note, and counts them all. With several notes, resting the pointer on the message lists them in that order, as many as fit in the window and at most 30, then how many more. Screen readers receive the list after the message. Scores and estimates are unchanged. +- **Notes of a save or an export in the status bar:** After Save, Save As, Save a Copy, Save as Template, Save a Timestamped Copy, Save as PDF, Export As, Export Selection, Export for Screens, Asset Export, Save for Web, Preview in Browser, Save for Office Documents, Text Export Options, a print to a file or the CSS Properties panel's export, the status message names the first note and counts them all. With several notes, resting the pointer on the message lists them in the order the reply gives them (a save's notes start with what the format loses), as it lists an open's notes. Scores and estimates are unchanged. - **Canvas review follow-up:** Cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission. Controlled-worker regression checks oversized width and height, high DPI and the render transform without allocating oversized images. Scores and estimates are unchanged. - **Cached canvas frames during viewport changes:** Cached screen textures use their own raster dimensions and pixels-per-point when reprojected. `canvas::tests::cached_frames_keep_their_scale_during_resize_rotation_and_dpi_changes` holds the worker and checks every UV corner against the cached render transform, including isolated viewport-only resize at zero/30-degree rotation, fractional raster dimensions, zoom/pan, changed rotation, monitor-DPI changes in both directions, Pixel Preview and replacement-frame delivery. The contributor rebuilt and confirmed the original resize issue is fixed with their own artwork on 2026-10-19; the subsequent upstream rebase is checked by regressions and full CI. The artwork stays local. Scores and estimates are unchanged. From e83c36785ed4286ad5d9421919c034e269ac5edb Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 21:04:01 -0800 Subject: [PATCH 09/43] Commands: pointer events refuse pen values and spaces of the wrong type ui.pointer and the headless pointer call read a pointer event's pressure, holdMs, tiltX, tiltY and rotation with a default for any value that was not a number, and treated any space other than screen as document space. Each event is now checked before the packet runs: those values must be numbers and space must be doc or screen, with null keeping the default. docs/mcp.md says so. --- crates/mcp/src/headless.rs | 1 + crates/mcp/src/tests.rs | 5 +++++ crates/tools/src/input.rs | 34 +++++++++++++++++++++++++++++ crates/ui-egui/src/control.rs | 1 + crates/ui-egui/src/control/tests.rs | 10 +++++++-- docs/mcp.md | 5 +++-- 6 files changed, 52 insertions(+), 4 deletions(-) diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index cf150623ef..69c7b5ba0e 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -239,6 +239,7 @@ impl Headless { let mods = packet_mods(e)?.unwrap_or(base); 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)"))?; + PointerEvent::json_check(e)?; if s(e, "space") == Some("screen") { return Err("screen-space pointer events need the desktop app; use document coordinates".to_string()); } diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 86cc6228a0..9a64c688b9 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -1474,6 +1474,11 @@ fn headless_methods_refuse_params_of_the_wrong_type() { // A whole rectangle comes before the malformed event, and none of it is drawn. ("ui.pointer", after_rect(json!({"kind": "move", "x": "bad", "y": 0})), "x"), ("ui.pointer", after_rect(json!({"kind": "wiggle", "x": 0, "y": 0})), "wiggle"), + ("ui.pointer", after_rect(json!({"kind": "move", "x": 1, "y": 1, "pressure": "bad"})), "pressure"), + ("ui.pointer", after_rect(json!({"kind": "move", "x": 1, "y": 1, "holdMs": true})), "holdMs"), + ("ui.pointer", after_rect(json!({"kind": "move", "x": 1, "y": 1, "tiltX": "5"})), "tiltX"), + ("ui.pointer", after_rect(json!({"kind": "move", "x": 1, "y": 1, "rotation": [0]})), "rotation"), + ("ui.pointer", after_rect(json!({"kind": "move", "x": 1, "y": 1, "space": "screeen"})), "space"), ] { let e = h.call(method, params.clone()).unwrap_err(); assert!(e.contains(&format!("`{key}`")), "{method} {params}: {e}"); diff --git a/crates/tools/src/input.rs b/crates/tools/src/input.rs index ece5231bd2..9b53d2404c 100644 --- a/crates/tools/src/input.rs +++ b/crates/tools/src/input.rs @@ -109,6 +109,19 @@ impl PointerEvent { pub fn json_backquote(v: &Value, default: bool) -> Result { v.get("backquote").map_or(Ok(default), |b| b.as_bool().ok_or_else(|| "`backquote` must be a boolean".to_owned())) } + /// Checks the optional keys of a JSON pointer event (control channel, MCP): `pressure`, + /// `holdMs`, `tiltX`, `tiltY` and `rotation` must be numbers, and `space` must be `doc` or + /// `screen`. A key that is absent or null keeps its default. + pub fn json_check(e: &Value) -> Result<(), String> { + let given = |k: &str| e.get(k).filter(|v| !v.is_null()); + if let Some(k) = ["pressure", "holdMs", "tiltX", "tiltY", "rotation"].into_iter().find(|k| given(k).is_some_and(|v| v.as_f64().is_none())) { + return Err(format!("`{k}` must be a number")); + } + if given("space").is_some_and(|v| !matches!(v.as_str(), Some("doc" | "screen"))) { + return Err("`space` must be doc or screen".to_owned()); + } + Ok(()) + } } #[cfg(test)] @@ -135,6 +148,27 @@ mod tests { assert!(PointerEvent::json_backquote(&json!({"backquote": bad}), false).is_err(), "{bad}"); } } + + #[test] + fn json_check_refuses_pen_values_and_spaces_of_the_wrong_type() { + use serde_json::json; + for ok in [ + json!({}), + json!({"pressure": 0.5, "holdMs": 10, "tiltX": -5, "tiltY": 5, "rotation": 400, "space": "screen"}), + json!({"pressure": null, "space": null}), + json!({"space": "doc"}), + ] { + assert_eq!(PointerEvent::json_check(&ok), Ok(()), "{ok}"); + } + for k in ["pressure", "holdMs", "tiltX", "tiltY", "rotation"] { + for bad in [json!("1"), json!(true), json!([1])] { + assert_eq!(PointerEvent::json_check(&json!({k: bad})), Err(format!("`{k}` must be a number")), "{k}: {bad}"); + } + } + for bad in [json!("screeen"), json!(true), json!(1)] { + assert_eq!(PointerEvent::json_check(&json!({"space": bad})), Err("`space` must be doc or screen".to_owned()), "{bad}"); + } + } } /// Keys tools care about. diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 34eaa7944e..65489be0dd 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -984,6 +984,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}`")), }; + PointerEvent::json_check(e)?; let at = |k: &str| crate::menus::opt_number(e, k).map(|n| n.unwrap_or(0.0)); Ok((kind, at("x")?, at("y")?, packet_mods(e.get("mods"))?.unwrap_or(base))) }) diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index 021365d817..1763fd679a 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -457,8 +457,8 @@ fn synthetic_pointer_rejects_a_backquote_that_is_not_a_boolean() { } } -/// `ui.pointer` refuses an unknown event kind, or a screen-space event with no canvas, before any -/// of the packet's events runs. +/// `ui.pointer` refuses an unknown event kind, a pen value or `space` of the wrong type, or a +/// screen-space event with no canvas, before any of the packet's events runs. #[test] fn synthetic_pointer_rejects_an_unknown_kind_before_any_event_runs() { let mut app = VectorcraftApp::new(Session::new(), Services::default()); @@ -471,6 +471,12 @@ fn synthetic_pointer_rejects_an_unknown_kind_before_any_event_runs() { for (last, error) in [ (json!({"kind": "wiggle", "x": 145, "y": 125}), "unknown pointer kind `wiggle`"), (json!({"kind": "move", "x": 145, "y": 125, "space": "screen"}), "no canvas yet"), + (json!({"kind": "move", "x": 145, "y": 125, "pressure": "bad"}), "`pressure` must be a number"), + (json!({"kind": "move", "x": 145, "y": 125, "holdMs": "800"}), "`holdMs` must be a number"), + (json!({"kind": "move", "x": 145, "y": 125, "tiltY": true}), "`tiltY` must be a number"), + (json!({"kind": "move", "x": 145, "y": 125, "rotation": "90"}), "`rotation` must be a number"), + (json!({"kind": "move", "x": 145, "y": 125, "space": "screeen"}), "`space` must be doc or screen"), + (json!({"kind": "move", "x": 145, "y": 125, "space": true}), "`space` must be doc or screen"), ] { let events = moves.iter().cloned().chain([last]).collect::>(); let r = input_request(&mut app, "ui.pointer", json!({"events": events})); diff --git a/docs/mcp.md b/docs/mcp.md index 078488e9d4..bce8a5efc8 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1272,8 +1272,9 @@ type and change nothing: `view.setZoom` (`zoom`, `center`), `view.screenMode` (` 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, with two exceptions: `ui.click`'s `count` and `ui.drag`'s -`count` and `steps` refuse null, and a headless pointer event needs its `x` and `y`. A pointer packet with one -malformed event sends none of its events: +`count` and `steps` refuse null, and a headless pointer event needs its `x` and `y`. A pointer event's `pressure`, +`holdMs`, `tiltX`, `tiltY` and `rotation` must be numbers and its `space` must be `doc` or `screen`. A pointer packet +with one malformed event sends none of its events: ```json {"id": "view.setZoom", "params": {"zoom": "200"}} From f1251af6e44f2316cfa0551ae798b7c0e3c846af Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 21:10:30 -0800 Subject: [PATCH 10/43] DXF: export options leave placed images and fill hatches out, and CNC / CAM turns both off DXF Options gets Include Placed Images and Include Fill Hatches, and document.exportDxf and document.export {format: dxf} take includeImages and includeHatches. Both are VectorCraft's own options and default to true, so Custom exports write what they wrote before. - includeImages: false writes no IMAGE entity, IMAGEDEF, reactor or linked file for a placed image, and does not decode its pixels. - includeHatches: false writes each filled area's outlines without the solid HATCH after them. - Each adds one note to warnings when it left something out. R12, which has neither, keeps its own notes. - The CNC / CAM preset sets both to false, so its drawing holds the cutting paths alone; the options given still override it, and changing either checkbox shows Custom. Docs: docs/mcp.md, docs/control-protocol.md, docs/target-app-parity.md. --- crates/cad/src/lib.rs | 13 ++- crates/cad/src/scene.rs | 18 +++- crates/cad/src/tests.rs | 117 ++++++++++++++++++++++ crates/engine/src/cmd/fileio/dxf.rs | 20 +++- crates/engine/src/cmd/fileio/tests_dxf.rs | 47 +++++++++ crates/ui-egui/src/dialogs/dxf_options.rs | 13 ++- crates/ui-egui/src/dialogs/tests_dxf.rs | 55 ++++++++-- crates/ui-egui/src/i18n/bg.tsv | 2 + crates/ui-egui/src/i18n/bs.tsv | 2 + crates/ui-egui/src/i18n/ca.tsv | 2 + crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/da.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/el.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/fi.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/hi.tsv | 2 + crates/ui-egui/src/i18n/hu.tsv | 2 + crates/ui-egui/src/i18n/id.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/ka.tsv | 2 + crates/ui-egui/src/i18n/ko.tsv | 2 + crates/ui-egui/src/i18n/nl.tsv | 2 + crates/ui-egui/src/i18n/pl.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/sv.tsv | 2 + crates/ui-egui/src/i18n/tr.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/vi.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + docs/control-protocol.md | 5 +- docs/mcp.md | 8 +- docs/target-app-parity.md | 3 +- 37 files changed, 332 insertions(+), 21 deletions(-) diff --git a/crates/cad/src/lib.rs b/crates/cad/src/lib.rs index 525e9b6734..8125d02c93 100644 --- a/crates/cad/src/lib.rs +++ b/crates/cad/src/lib.rs @@ -281,6 +281,10 @@ pub struct DxfOptions { pub scale_lineweights: bool, pub colors: ColorDepth, pub raster: RasterFormat, + /// Placed images are written as IMAGE entities with their files (else left out, undecoded). + pub include_images: bool, + /// Filled areas get a solid HATCH after their outlines (else the outlines alone). + pub include_hatches: bool, pub preserve: Preserve, /// Alter Paths for Appearance: a stroke a CAD line can't draw (one [`Preserve::Appearance`] /// outlines, one wider than the widest lineweight, or any stroke in R13 and R14, which have @@ -303,6 +307,8 @@ impl Default for DxfOptions { scale_lineweights: false, colors: ColorDepth::default(), raster: RasterFormat::default(), + include_images: true, + include_hatches: true, preserve: Preserve::default(), alter_paths: false, outline_text: false, @@ -322,7 +328,8 @@ pub enum DxfPreset { /// For CNC and CAM cutting software: version 2000 (the oldest version that has both lightweight /// polylines and lineweights), 1 mm = 1 unit, 256 indexed colors (all version 2000 holds), Maximize /// Editability with Alter Paths off (every stroke a center line), Outline Text (type as paths), - /// and placed images as PNG. + /// and placed images and fill hatches left out (fills are their outlines), with PNG as the + /// format images take when they are put back in. Cnc, } @@ -360,6 +367,8 @@ impl DxfPreset { scale_lineweights: false, colors: ColorDepth::Aci256, raster: RasterFormat::Png, + include_images: false, + include_hatches: false, preserve: Preserve::Editability, alter_paths: false, outline_text: true, @@ -377,6 +386,8 @@ impl DxfPreset { && o.scale_lineweights == c.scale_lineweights && o.colors == c.colors && o.raster == c.raster + && o.include_images == c.include_images + && o.include_hatches == c.include_hatches && o.preserve == c.preserve && o.alter_paths == c.alter_paths && o.outline_text == c.outline_text; diff --git a/crates/cad/src/scene.rs b/crates/cad/src/scene.rs index 3faf94423f..300407eed6 100644 --- a/crates/cad/src/scene.rs +++ b/crates/cad/src/scene.rs @@ -1,5 +1,6 @@ //! The document walk: each visible object becomes DXF entities on its layer's DXF layer, in paint -//! order. Fills are their outlines then solid hatches (the outlines alone in R12), strokes +//! order. Fills are their outlines then solid hatches (the outlines alone in R12 or with +//! hatches left out), placed images are linked image files unless left out, strokes //! polylines (with bulges for circular arcs) and cubic splines with a lineweight and dashes, or //! filled outlines where the look needs them. @@ -28,6 +29,8 @@ const NO_TRUE_COLOR: &str = "true colour needs DXF 2004 or later: colours are wr const NO_TRANSPARENCY: &str = "transparency needs DXF 2004 or later: transparent objects are written opaque"; const NO_FILLS: &str = "DXF R12 has no fills: filled areas are written as their outlines"; const NO_IMAGES: &str = "DXF R12 has no images: placed images are left out"; +const IMAGES_OFF: &str = "placed images are left out (Include Placed Images is off)"; +const HATCHES_OFF: &str = "fill hatches are left out: filled areas are written as their outlines (Include Fill Hatches is off)"; const WIDE_LINES: &str = "lines wider than 2.11 mm (the widest lineweight) are written at 2.11 mm: Alter Paths for Appearance writes their true width"; const STROKE_EXTRAS: &str = "stroke alignment, width profiles, arrowheads and brushes are left out (Maximize Editability writes strokes as lines)"; @@ -878,8 +881,8 @@ impl<'a> Scene<'a> { Some(vectorcraft_brush::stroke_pieces(doc, b, bp, st)) } - /// A filled area as its outlines followed by a solid hatch (R12: the outlines alone). Laser - /// and cutter software reads the outlines and skips hatches. `clean`: the path has no + /// A filled area as its outlines followed by a solid hatch (R12, or with hatches left out: the + /// outlines alone). Laser and cutter software reads the outlines and skips hatches. `clean`: the path has no /// overlaps, so the hatch's even-odd rule reads it as it is. fn area(&mut self, bp: &BezPath, rule: FillRule, style: &EntityStyle, clean: bool) { if !self.o.version.handles() { @@ -893,11 +896,16 @@ impl<'a> Scene<'a> { .flatten() .map(|p| p.to_bezpath()); let outline = normalized.as_ref().unwrap_or(bp); + // A fill with no loop of three points or more encloses no area: no outline and no hatch. let loops: Vec> = polygons(outline).into_iter().filter(|p| p.len() >= 3).collect(); if loops.is_empty() { return; } self.lines(outline, style, 0.0); + if !self.o.include_hatches { + self.warn(HATCHES_OFF); + return; + } let h = self.handle(); let layer = self.layer.clone(); self.e.entity("HATCH", h, fixed::MODEL_RECORD, &layer, style, "AcDbHatch"); @@ -1285,6 +1293,10 @@ impl<'a> Scene<'a> { self.warn(NO_IMAGES); return; } + if !self.o.include_images { + self.warn(IMAGES_OFF); + return; + } let Some(i) = self.image_def(im) else { return }; let Some((dw, dh)) = self.images.get(i).map(|d| (f64::from(d.size.0), f64::from(d.size.1))) else { return }; let (w, h) = (f64::from(im.width.max(1)), f64::from(im.height.max(1))); diff --git a/crates/cad/src/tests.rs b/crates/cad/src/tests.rs index 90e4cbc2c6..2ffa594f44 100644 --- a/crates/cad/src/tests.rs +++ b/crates/cad/src/tests.rs @@ -843,10 +843,127 @@ fn the_cnc_preset_writes_version_2000_in_millimeters_with_type_as_paths_and_stro assert!(out.images.is_empty()); assert_eq!(DxfPreset::of(&o), DxfPreset::Cnc); assert_eq!(DxfPreset::of(&DxfOptions { colors: ColorDepth::True, ..o.clone() }), DxfPreset::Custom); + assert_eq!(DxfPreset::of(&DxfOptions { include_images: true, ..o.clone() }), DxfPreset::Custom); + assert_eq!(DxfPreset::of(&DxfOptions { include_hatches: true, ..o.clone() }), DxfPreset::Custom); assert_eq!(DxfPreset::of(&DxfOptions::default()), DxfPreset::Custom); assert_eq!(DxfPreset::from_id(" CNC "), Some(DxfPreset::Cnc)); } +/// A 4 × 2 px PNG placed at (10, 20) under key `raster-1`, and before it in paint order +/// `fill`'s rectangle. +fn image_and_fill_doc(fill: Paint) -> Document { + let mut png = vec![]; + image::RgbaImage::from_pixel(4, 2, image::Rgba([0, 0, 255, 255])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let mut d = doc_with(|d| { + let im = ImageObject { + key: "raster-1".into(), + width: 4, + height: 2, + xf: Affine::translate((10.0, 20.0)) * Affine::scale(5.0), + link: None, + placement: Default::default(), + pixels: None, + }; + vec![ + path(d, shapes::rectangle(Rect::new(50.0, 10.0, 150.0, 60.0)), fill, Paint::None, 0.0), + Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))), + ] + }); + d.images.insert("raster-1".into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(png), proxy: None }); + d +} + +const IMAGES_OFF: &str = "placed images are left out (Include Placed Images is off)"; +const HATCHES_OFF: &str = "fill hatches are left out: filled areas are written as their outlines (Include Fill Hatches is off)"; + +#[test] +fn fills_without_hatches_keep_their_outlines() { + let d = shapes_doc(); + let out = export(&d, &DxfOptions { include_hatches: false, ..opts(&d) }).unwrap(); + assert_eq!(kinds(&out), ["LWPOLYLINE", "LWPOLYLINE", "CIRCLE"], "the fill's outline, the stroke, the circle"); + assert_eq!(out.warnings, [HATCHES_OFF]); + let e = entities(&out); + assert_eq!((get(&e[0], 70), get(&e[0], 370)), (Some("1"), None), "the closed outline, without a lineweight"); + // Hatches stay by default, with no note. + let on = export(&d, &opts(&d)).unwrap(); + assert!(kinds(&on).iter().any(|k| k == "HATCH") && !on.warnings.iter().any(|w| w == HATCHES_OFF)); + // The outlines are the ones written beside hatches, also for overlapping subpaths, which are + // merged first; a fill with no area (a flat rectangle, a diagonal line) writes nothing either way. + let compound = doc_with(|d| { + let mut bp = shapes::rectangle(Rect::new(10.0, 10.0, 60.0, 60.0)).to_bezpath(); + bp.extend(shapes::rectangle(Rect::new(40.0, 40.0, 90.0, 90.0)).to_bezpath().elements().iter().copied()); + let p = PathData::from_bezpath(&bp); + vec![ + path(d, p, Paint::solid(Color::BLACK), Paint::None, 0.0), + path(d, shapes::rectangle(Rect::new(10.0, 70.0, 90.0, 70.0)), Paint::solid(Color::BLACK), Paint::None, 0.0), + path(d, shapes::line(Point::new(100.0, 10.0), Point::new(150.0, 60.0)), Paint::solid(Color::BLACK), Paint::None, 0.0), + ] + }); + let without_hatches = |o: &DxfOutput| { + let mut e = entities(o); + e.retain(|x| kind(x) != "HATCH"); + e.into_iter().map(|x| x.into_iter().filter(|p| p.0 != 5).collect::()).collect::>() + }; + let (c_on, c_off) = + (export(&compound, &opts(&compound)).unwrap(), export(&compound, &DxfOptions { include_hatches: false, ..opts(&compound) }).unwrap()); + assert_eq!(kinds(&c_on), ["LWPOLYLINE", "HATCH"], "one merged outline"); + assert_eq!(without_hatches(&c_off), without_hatches(&c_on)); + assert_eq!(without_hatches(&out), without_hatches(&on)); + // Strokes outlined for their look are areas too: their outlines stay. + let altered = + export(&d, &DxfOptions { include_hatches: false, alter_paths: true, preserve: Preserve::Editability, version: DxfVersion::R14, ..opts(&d) }) + .unwrap(); + assert!(!kinds(&altered).iter().any(|k| k == "HATCH") && !kinds(&altered).is_empty(), "{:?}", kinds(&altered)); + // Nothing filled: no note. + let lines = doc_with(|d| vec![path(d, shapes::ellipse(Rect::new(120.0, 20.0, 180.0, 80.0)), Paint::None, Paint::solid(Color::BLACK), 1.0)]); + assert!(export(&lines, &DxfOptions { include_hatches: false, ..opts(&lines) }).unwrap().warnings.is_empty()); + // R12 has no hatches anyway: the same outlines, and its own note alone. + let r12 = |include_hatches| export(&d, &DxfOptions { version: DxfVersion::R12, include_hatches, ..opts(&d) }).unwrap(); + let (off, on) = (r12(false), r12(true)); + assert_eq!(off.bytes, on.bytes); + assert_eq!(off.warnings, on.warnings); + assert!(!off.warnings.iter().any(|w| w == HATCHES_OFF)); +} + +#[test] +fn images_can_be_left_out_with_their_files() { + let d = image_and_fill_doc(Paint::None); + let out = export(&d, &DxfOptions { include_images: false, ..opts(&d) }).unwrap(); + assert!(entities(&out).is_empty() && out.images.is_empty(), "{:?}", kinds(&out)); + let p = pairs(&out.bytes); + assert!(!section(&p, "OBJECTS").iter().any(|o| matches!(kind(o), "IMAGEDEF" | "IMAGEDEF_REACTOR" | "RASTERVARIABLES"))); + assert!(!section(&p, "CLASSES").iter().any(|o| get(o, 1) == Some("IMAGE"))); + assert_eq!(out.warnings, [IMAGES_OFF]); + // An image that can't be decoded is not read when images are left out. + let mut broken = d.clone(); + broken.images.insert("raster-1".into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(b"not a png".to_vec()), proxy: None }); + assert_eq!(export(&broken, &DxfOptions { include_images: false, ..opts(&broken) }).unwrap().warnings, [IMAGES_OFF]); + assert!(export(&broken, &opts(&broken)).unwrap().warnings.iter().any(|w| w.contains("couldn't be decoded"))); + // R12 has no images anyway: its own note alone. + let r12 = export(&d, &DxfOptions { version: DxfVersion::R12, include_images: false, ..opts(&d) }).unwrap(); + assert!(r12.warnings.iter().any(|w| w.contains("no images")) && !r12.warnings.iter().any(|w| w == IMAGES_OFF)); +} + +#[test] +fn images_and_hatches_are_left_out_independently_and_the_cnc_preset_leaves_out_both() { + let d = image_and_fill_doc(Paint::solid(Color::rgb(1.0, 0.0, 0.0))); + let run = |o: DxfOptions| export(&d, &DxfOptions { region: d.artboards[0].rect, ..o }).unwrap(); + let both = run(DxfOptions::default()); + assert_eq!(kinds(&both), ["LWPOLYLINE", "HATCH", "IMAGE"]); + assert_eq!(both.images.len(), 1); + let no_images = run(DxfOptions { include_images: false, ..DxfOptions::default() }); + assert_eq!((kinds(&no_images), no_images.images.len()), (vec!["LWPOLYLINE".to_string(), "HATCH".into()], 0)); + let no_hatches = run(DxfOptions { include_hatches: false, ..DxfOptions::default() }); + assert_eq!((kinds(&no_hatches), no_hatches.images.len()), (vec!["LWPOLYLINE".to_string(), "IMAGE".into()], 1)); + // CNC / CAM: the cutting path alone, with no image file and both notes. + let cnc = run(DxfPreset::Cnc.options()); + assert_eq!(kinds(&cnc), ["LWPOLYLINE"]); + assert!(cnc.images.is_empty()); + assert!(cnc.warnings.iter().any(|w| w == IMAGES_OFF) && cnc.warnings.iter().any(|w| w == HATCHES_OFF), "{:?}", cnc.warnings); + let custom = DxfPreset::Custom.options(); + assert!(custom.include_images && custom.include_hatches, "Custom keeps both"); +} + #[test] fn layers_become_dxf_layers() { let mut d = shapes_doc(); diff --git a/crates/engine/src/cmd/fileio/dxf.rs b/crates/engine/src/cmd/fileio/dxf.rs index b63447e228..16cf3b2631 100644 --- a/crates/engine/src/cmd/fileio/dxf.rs +++ b/crates/engine/src/cmd/fileio/dxf.rs @@ -21,7 +21,7 @@ pub fn specs() -> Vec { "Export DXF", [], None, - "{path?, dxfPreset?: custom (default)|cnc (CNC / CAM: version 2000, the oldest version that has both lightweight polylines and lineweights; unit mm, scale 1; colors 256, all 2000 holds; preserve editability and alterPaths false, so strokes stay center lines; outlineText true, so type is paths; rasterFormat png; the options given override it), version?: R12|R13|R14|2000|2004|2007|2010|2013|2018 (default), unit?: mm (default; any unit name: pt, in, cm…), scale?: 1 (drawing units per unit), scaleLineweights?: false, colors?: 8|16|256|true (default; true colour needs 2004+, else 256), rasterFormat?: png (default)|jpeg (placed images, written next to the file), preserve?: appearance (default: type as outlines; strokes a CAD line can't draw as filled outlines; brush art)|editability (type as text; every stroke a line with its lineweight and dashes), alterPaths?: false (strokes a CAD line can't draw as their filled outlines, with editability too; also strokes wider than 2.11 mm, and every stroke in R13 and R14), outlineText?: false, selectedOnly?: false (the selected objects, in their layers), artboard?: 0 (the origin is its bottom-left corner), useArtboards?: true (one drawing per chosen artboard, artboards?/range?, holding the art over it: {stem}-{artboard}.dxf) | false (the origin at the art's bounds)} → {path, bytes, warnings, files?, linked?: [image files]}; no path → {dataBase64, …, linked?: [{name, dataBase64}]}. Layers become DXF layers (hidden: off; locked; non-printing: not plotted); paths become polylines and splines, fills their outlines (which laser and cutter software reads) and solid hatches. Same as document.export {format: dxf}. A key DXF doesn't take is refused", + "{path?, dxfPreset?: custom (default)|cnc (CNC / CAM: version 2000, the oldest version that has both lightweight polylines and lineweights; unit mm, scale 1; colors 256, all 2000 holds; preserve editability and alterPaths false, so strokes stay center lines; outlineText true, so type is paths; includeImages and includeHatches false, so fills are their outlines and no image files are written; rasterFormat png; the options given override it), version?: R12|R13|R14|2000|2004|2007|2010|2013|2018 (default), unit?: mm (default; any unit name: pt, in, cm…), scale?: 1 (drawing units per unit), scaleLineweights?: false, colors?: 8|16|256|true (default; true colour needs 2004+, else 256), rasterFormat?: png (default)|jpeg (placed images, written next to the file), includeImages?: true (false: placed images and their files are left out), includeHatches?: true (false: fills are their outlines, without solid hatches), preserve?: appearance (default: type as outlines; strokes a CAD line can't draw as filled outlines; brush art)|editability (type as text; every stroke a line with its lineweight and dashes), alterPaths?: false (strokes a CAD line can't draw as their filled outlines, with editability too; also strokes wider than 2.11 mm, and every stroke in R13 and R14), outlineText?: false, selectedOnly?: false (the selected objects, in their layers), artboard?: 0 (the origin is its bottom-left corner), useArtboards?: true (one drawing per chosen artboard, artboards?/range?, holding the art over it: {stem}-{artboard}.dxf) | false (the origin at the art's bounds)} → {path, bytes, warnings, files?, linked?: [image files]}; no path → {dataBase64, …, linked?: [{name, dataBase64}]}. Layers become DXF layers (hidden: off; locked; non-printing: not plotted); paths become polylines and splines, fills their outlines (which laser and cutter software reads) and solid hatches. Same as document.export {format: dxf}. A key DXF doesn't take is refused", has_doc, export_dxf )] @@ -37,7 +37,7 @@ pub(crate) const OPTIONS: &[FormatOption] = &[ name: "dxfPreset", ty: "string", default: "\"custom\"", - description: "custom, or cnc (CNC / CAM: version 2000, mm, 256 colors, editability, type as outlines, PNG images); the options given override it", + description: "custom, or cnc (CNC / CAM: version 2000, mm, 256 colors, editability, type as outlines, no images, no fill hatches); the options given override it", }, FormatOption { name: "version", ty: "string", default: "\"2018\"", description: "R12, R13, R14, 2000, 2004, 2007, 2010, 2013 or 2018" }, FormatOption { @@ -60,6 +60,18 @@ pub(crate) const OPTIONS: &[FormatOption] = &[ default: "\"png\"", description: "png or jpeg: the files placed images are written to, next to the drawing", }, + FormatOption { + name: "includeImages", + ty: "boolean", + default: "true", + description: "placed images are written, linked to their files; false leaves them and their files out", + }, + FormatOption { + name: "includeHatches", + ty: "boolean", + default: "true", + description: "filled areas get a solid hatch after their outlines; false writes the outlines alone", + }, FormatOption { name: "preserve", ty: "string", @@ -133,6 +145,8 @@ struct Params { scale_lineweights: Option, colors: Option, raster_format: Option, + include_images: Option, + include_hatches: Option, preserve: Option, alter_paths: Option, outline_text: Option, @@ -176,6 +190,8 @@ fn parse(p: &Value) -> Result<(DxfOptions, ArtboardPick)> { scale_lineweights: q.scale_lineweights.unwrap_or(d.scale_lineweights), colors: choice("colors", q.colors.as_ref().map(text), ColorDepth::from_id, d.colors, "8, 16, 256 or true")?, raster: choice("rasterFormat", q.raster_format, RasterFormat::from_id, d.raster, "png or jpeg")?, + include_images: q.include_images.unwrap_or(d.include_images), + include_hatches: q.include_hatches.unwrap_or(d.include_hatches), preserve: choice("preserve", q.preserve, Preserve::from_id, d.preserve, "appearance or editability")?, alter_paths: q.alter_paths.unwrap_or(d.alter_paths), outline_text: q.outline_text.unwrap_or(d.outline_text), diff --git a/crates/engine/src/cmd/fileio/tests_dxf.rs b/crates/engine/src/cmd/fileio/tests_dxf.rs index 8497676836..54839edda4 100644 --- a/crates/engine/src/cmd/fileio/tests_dxf.rs +++ b/crates/engine/src/cmd/fileio/tests_dxf.rs @@ -84,6 +84,11 @@ fn the_cnc_preset_sets_the_options_that_are_not_given() { true ) ); + assert_eq!((o.include_images, o.include_hatches), (false, false), "CNC / CAM leaves out images and fill hatches"); + let given = dxf::options(&json!({"dxfPreset": "cnc", "includeImages": true, "includeHatches": true})).unwrap(); + assert_eq!((given.include_images, given.include_hatches), (true, true), "the options given override it"); + let custom = dxf::options(&json!({})).unwrap(); + assert_eq!((custom.include_images, custom.include_hatches), (true, true), "Custom keeps both"); assert_eq!(dxf::options(&json!({})).unwrap().version, vectorcraft_cad::DxfVersion::R2018, "custom by default"); let e = s.execute("document.exportDxf", &json!({"dxfPreset": "laser"})).unwrap_err().to_string(); assert!(e.contains("custom or cnc"), "{e}"); @@ -151,6 +156,44 @@ fn placed_images_come_back_as_linked_files() { let _ = std::fs::remove_dir_all(dir); } +/// `includeImages` and `includeHatches` leave out placed images (with their files) and fill +/// hatches, each on its own, through both commands, with and without a path; CNC / CAM leaves +/// out both, and the options given override it. +#[test] +fn placed_images_and_fill_hatches_can_be_left_out() { + let mut s = super::tests_svg::image_session(); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + s.execute("paint.setStroke", &json!({"none": true})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 1, "height": 1})).unwrap(); + let counts = |r: &Value| { + let e = entities(&text(r)); + let n = |k: &str| e.iter().filter(|x| *x == k).count(); + (n("LWPOLYLINE"), n("HATCH"), n("IMAGE"), r.get("linked").and_then(Value::as_array).map_or(0, Vec::len)) + }; + let warned = |r: &Value, w: &str| r["warnings"].as_array().unwrap().iter().any(|x| x.as_str().is_some_and(|x| x.starts_with(w))); + let all = s.execute("document.export", &json!({"format": "dxf"})).unwrap(); + assert_eq!(counts(&all), (1, 1, 1, 1)); + let r = s.execute("document.export", &json!({"format": "dxf", "includeImages": false})).unwrap(); + assert_eq!(counts(&r), (1, 1, 0, 0)); + assert!(warned(&r, "placed images are left out") && !warned(&r, "fill hatches"), "{r}"); + let r = s.execute("document.exportDxf", &json!({"includeHatches": false})).unwrap(); + assert_eq!(counts(&r), (1, 0, 1, 1)); + assert!(warned(&r, "fill hatches are left out") && !warned(&r, "placed images"), "{r}"); + let r = s.execute("document.exportDxf", &json!({"dxfPreset": "cnc"})).unwrap(); + assert_eq!(counts(&r), (1, 0, 0, 0)); + let r = s.execute("document.export", &json!({"format": "dxf", "dxfPreset": "cnc", "includeImages": true})).unwrap(); + assert_eq!(counts(&r), (1, 0, 1, 1)); + // With a path, no image file is written next to the drawing. + let dir = std::env::temp_dir().join(format!("vc-dxf-out-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("cut.dxf").to_string_lossy().to_string(); + let r = s.execute("document.exportDxf", &json!({"path": path, "dxfPreset": "cnc"})).unwrap(); + assert!(r.get("linked").is_none(), "{r}"); + let files: Vec = std::fs::read_dir(&dir).unwrap().filter_map(|e| e.ok()).map(|e| e.file_name().to_string_lossy().to_string()).collect(); + assert_eq!(files, ["cut.dxf"]); + let _ = std::fs::remove_dir_all(dir); +} + /// Two placed images whose pixel data can't be decoded are left out of the drawing and get no /// linked file, and `warnings` holds the note raster exports give, once, without naming either /// image's key. @@ -181,6 +224,8 @@ fn bad_dxf_options_are_refused() { json!({"rasterFormat": "gif"}), json!({"preserve": "both"}), json!({"alterPaths": "yes"}), + json!({"includeImages": "no"}), + json!({"includeHatches": 0}), ] { let e = s.execute("document.exportDxf", &p).unwrap_err().to_string(); assert!(e.contains("DXF"), "{p}: {e}"); @@ -203,6 +248,8 @@ fn formats_list_dxf_and_what_can_not_be_written() { "scaleLineweights", "colors", "rasterFormat", + "includeImages", + "includeHatches", "preserve", "alterPaths", "outlineText", diff --git a/crates/ui-egui/src/dialogs/dxf_options.rs b/crates/ui-egui/src/dialogs/dxf_options.rs index cc52623931..498d854fe9 100644 --- a/crates/ui-egui/src/dialogs/dxf_options.rs +++ b/crates/ui-egui/src/dialogs/dxf_options.rs @@ -1,8 +1,8 @@ //! DXF Options (Export As → DXF): a preset (Custom, or CNC / CAM, which sets the options below for //! cutting software and shows Custom again once one is changed), version, scale (1 unit of the -//! art = N drawing units) and lineweights, number of colours, the format of placed images, -//! Preserve Appearance or Maximize Editability, and Export Selected Art Only, Alter Paths for -//! Appearance and Outline Text. +//! art = N drawing units) and lineweights, number of colours, the format of placed images and +//! Include Placed Images, Preserve Appearance or Maximize Editability, and Export Selected Art +//! Only, Alter Paths for Appearance, Outline Text and Include Fill Hatches. //! //! Fields: `document.export` params (`format: dxf`, `path?`, the artboard choice) and the DXF //! options of `document.exportDxf`. OK remembers the options for next time and writes the file @@ -58,6 +58,8 @@ fn preset_fields(preset: DxfPreset) -> Map { ("scaleLineweights", json!(o.scale_lineweights)), ("colors", json!(o.colors.id())), ("rasterFormat", json!(o.raster.id())), + ("includeImages", json!(o.include_images)), + ("includeHatches", json!(o.include_hatches)), ("preserve", json!(o.preserve.id())), ("alterPaths", json!(o.alter_paths)), ("outlineText", json!(o.outline_text)), @@ -140,6 +142,10 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { } let rasters: Vec<(&str, &str)> = RasterFormat::ALL.iter().map(|r| (r.id(), r.label())).collect(); choice(ui, d, "rasterFormat", tl!("Raster File Format:"), &rasters); + ui.horizontal(|ui| { + ui.add_space(LABEL + 8.0); + check(ui, d, "includeImages", tl!("Include Placed Images")); + }); ui.add_space(10.0); widgets::subheader(ui, tl!("Export Option")); let preserve = Preserve::from_id(&d.str("preserve")).unwrap_or_default(); @@ -162,6 +168,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { if widgets::check(ui, tl!("Outline Text"), appearance || d.bool("outlineText"), !appearance) { d.fields.insert("outlineText".into(), json!(!d.bool("outlineText"))); } + check(ui, d, "includeHatches", tl!("Include Fill Hatches")); ui.add_space(8.0); super::form::caption( ui, diff --git a/crates/ui-egui/src/dialogs/tests_dxf.rs b/crates/ui-egui/src/dialogs/tests_dxf.rs index 22429b1649..775562fa8f 100644 --- a/crates/ui-egui/src/dialogs/tests_dxf.rs +++ b/crates/ui-egui/src/dialogs/tests_dxf.rs @@ -50,13 +50,15 @@ fn the_cnc_cam_preset_sets_the_fields_and_a_change_shows_custom() { frame(&mut app); let d = app.ui.dialog.as_ref().unwrap(); let fields = |d: &crate::state::Dialog| { - ["dxfPreset", "version", "unit", "scale", "colors", "preserve", "alterPaths", "outlineText"].map(|k| d.str(k)).join(" ") + ["dxfPreset", "version", "unit", "scale", "colors", "preserve", "alterPaths", "outlineText", "includeImages", "includeHatches"] + .map(|k| d.str(k)) + .join(" ") }; - assert_eq!(fields(d), "cnc 2000 Millimeters 1.0 256 editability false true"); + assert_eq!(fields(d), "cnc 2000 Millimeters 1.0 256 editability false true false false"); // Any change to a field the preset sets shows Custom, and keeps the other fields. set(&mut app, "colors", json!("true")); frame(&mut app); - assert_eq!(fields(app.ui.dialog.as_ref().unwrap()), "custom 2000 Millimeters 1.0 true editability false true"); + assert_eq!(fields(app.ui.dialog.as_ref().unwrap()), "custom 2000 Millimeters 1.0 true editability false true false false"); set(&mut app, "colors", json!("256")); frame(&mut app); assert_eq!(app.ui.dialog.as_ref().unwrap().str("dxfPreset"), "cnc", "matching every field again"); @@ -70,12 +72,12 @@ fn the_cnc_cam_preset_sets_the_fields_and_a_change_shows_custom() { assert_eq!(app.ui.dialog.as_ref().unwrap().str("dxfPreset"), "cnc"); set(&mut app, "dxfPreset", json!("custom")); frame(&mut app); - assert_eq!(fields(app.ui.dialog.as_ref().unwrap()), "custom 2018 Millimeters 1.0 true appearance false false"); + assert_eq!(fields(app.ui.dialog.as_ref().unwrap()), "custom 2018 Millimeters 1.0 true appearance false false true true"); // A preset the command gives sets the fields it does not give. app.ui.dialog = None; app.run("ui.dxfOptionsDialog", json!({"dxfPreset": "cnc", "unit": "Inches"})).unwrap(); let d = app.ui.dialog.as_ref().unwrap(); - assert_eq!(fields(d), "custom 2000 Inches 1.0 256 editability false true"); + assert_eq!(fields(d), "custom 2000 Inches 1.0 256 editability false true false false"); // A preset set and confirmed without drawing (the control channel) sets its fields too. set(&mut app, "dxfPreset", json!("cnc")); confirm(&mut app).unwrap(); @@ -84,7 +86,7 @@ fn the_cnc_cam_preset_sets_the_fields_and_a_change_shows_custom() { app.run("ui.dxfOptionsDialog", json!({"dxfPreset": "custom"})).unwrap(); let d = app.ui.dialog.as_mut().unwrap(); set_field(d, "dxfPreset".into(), json!("cnc")); - assert_eq!(fields(d), "cnc 2000 Millimeters 1.0 256 editability false true"); + assert_eq!(fields(d), "cnc 2000 Millimeters 1.0 256 editability false true false false"); set_field(d, "unit".into(), json!("Inches")); assert_eq!(d.str("dxfPreset"), "custom"); // Setting the preset again sets its fields again. @@ -94,12 +96,51 @@ fn the_cnc_cam_preset_sets_the_fields_and_a_change_shows_custom() { // Setting Custom while Custom is shown still puts back the defaults. let mut reset = d.clone(); set_field(&mut reset, "dxfPreset".into(), json!("custom")); - assert_eq!(fields(&reset), "custom 2018 Millimeters 1.0 true appearance false false"); + assert_eq!(fields(&reset), "custom 2018 Millimeters 1.0 true appearance false false true true"); confirm(&mut app).unwrap(); let remembered = ["dxfPreset", "version", "unit"].map(|k| app.ui.dxf_options[k].as_str()); assert_eq!(remembered, [Some("custom"), Some("2000"), Some("Inches")]); } +#[test] +fn include_placed_images_and_fill_hatches_follow_the_preset_and_a_change_shows_custom() { + let (mut app, written) = app(1); + let shown = |app: &VectorcraftApp| { + let d = app.ui.dialog.as_ref().unwrap(); + (d.str("dxfPreset"), d.bool("includeImages"), d.bool("includeHatches")) + }; + app.run("ui.dxfOptionsDialog", json!({"path": "/out/a.dxf"})).unwrap(); + frame(&mut app); + assert_eq!(shown(&app), ("custom".into(), true, true)); + confirm(&mut app).unwrap(); + assert!(text(&written.borrow()[0].1).contains("\nHATCH\n"), "Custom keeps the fill's hatch"); + app.run("ui.dxfOptionsDialog", json!({"path": "/out/b.dxf"})).unwrap(); + set(&mut app, "dxfPreset", json!("cnc")); + frame(&mut app); + assert_eq!(shown(&app), ("cnc".into(), false, false)); + // Either checkbox changed shows Custom; both back as the preset sets them show CNC / CAM. + set(&mut app, "includeHatches", json!(true)); + frame(&mut app); + assert_eq!(shown(&app), ("custom".into(), false, true)); + set(&mut app, "includeHatches", json!(false)); + set(&mut app, "includeImages", json!(true)); + frame(&mut app); + assert_eq!(shown(&app), ("custom".into(), true, false)); + set(&mut app, "includeImages", json!(false)); + frame(&mut app); + assert_eq!(shown(&app).0, "cnc"); + confirm(&mut app).unwrap(); + assert!(!text(&written.borrow()[1].1).contains("\nHATCH\n"), "CNC / CAM writes the fill's outline alone"); + let remembered = ["includeImages", "includeHatches"].map(|k| app.ui.dxf_options[k].as_bool()); + assert_eq!(remembered, [Some(false), Some(false)]); + // The next DXF Options start from them, and Custom turns both back on. + crate::menus::invoke(&mut app, "ui.dxfOptionsDialog", json!({})); + assert_eq!(shown(&app), ("cnc".into(), false, false)); + set(&mut app, "dxfPreset", json!("custom")); + frame(&mut app); + assert_eq!(shown(&app), ("custom".into(), true, true)); +} + #[test] fn bad_dxf_options_keep_the_dialog_open() { let (mut app, written) = app(1); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index bc67d8fd87..b427dfb030 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2561,6 +2561,8 @@ digits Malayalam Малаялам Range: Диапазон: Raster Effects Растерен ефект Raster File Format: Растерен файлов формат: + Include Placed Images Включване на поставените изображения + Include Fill Hatches Включване на щриховките на запълванията Raster Sample Size: Растерен размер на пробата: Raster/Vector Balance: Баланс растер/вектор: Rasterize Растеризиране diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 6454cffcc9..5e35852a8c 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -2346,6 +2346,8 @@ digits Malayalam Malajalam Export Selected Art Only Izvezi samo odabrani crtež Number of Colors: Broj boja: Raster File Format: Format rasterske datoteke: + Include Placed Images Uključi postavljene slike + Include Fill Hatches Uključi šrafure ispuna True colour needs version 2004 or later: the nearest of 256 colours is written. Prava boja zahtijeva verziju 2004 ili noviju: zapisuje se najbliža od 256 boja. Type becomes outlines, and strokes a CAD line can't draw become filled shapes. Tekst postaje obrisi, a potezi koje CAD linija ne može nacrtati postaju ispunjeni oblici. Type stays text, and every stroke is a line with its lineweight and dashes. Tekst ostaje tekst, a svaki potez je linija sa svojom debljinom i crticama. diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 1567e0e8b5..077eb0a783 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -3303,6 +3303,8 @@ digits Malayalam Malaiàlam Range: Interval: Raster Effects Efectes de ràster Raster File Format: Format de fitxer de ràster: + Include Placed Images Inclou les imatges col·locades + Include Fill Hatches Inclou les trames dels emplenaments Raster Sample Size: Mida de mostra de ràster: Raster/Vector Balance: Equilibri ràster/vector: Rasterize Rasteritza diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index e9df0812f2..834415475c 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2374,6 +2374,8 @@ digits Malayalam Malajálamské Export Selected Art Only Exportovat jen vybranou kresbu Number of Colors: Počet barev: Raster File Format: Formát rastrového souboru: + Include Placed Images Zahrnout umístěné obrazy + Include Fill Hatches Zahrnout šrafy výplní True colour needs version 2004 or later: the nearest of 256 colours is written. Plné barvy vyžadují verzi 2004 nebo novější: zapíše se nejbližší z 256 barev. Type becomes outlines, and strokes a CAD line can't draw become filled shapes. Text se převede na křivky a tahy, které čára CAD nedokáže vykreslit, se stanou vyplněnými tvary. Type stays text, and every stroke is a line with its lineweight and dashes. Text zůstane textem a každý tah bude čárou s vlastní tloušťkou a čárkováním. diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index e259ecba84..ad32fecc4d 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -2178,6 +2178,8 @@ digits Malayalam Malayalam Export Selected Art Only Eksportér kun markeret grafik Number of Colors: Antal farver: Raster File Format: Rasterfilformat: + Include Placed Images Medtag placerede billeder + Include Fill Hatches Medtag skraveringer af udfyldninger True colour needs version 2004 or later: the nearest of 256 colours is written. Ægte farve kræver version 2004 eller senere: den nærmeste af 256 farver skrives. Type becomes outlines, and strokes a CAD line can't draw become filled shapes. Tekst bliver til omrids, og konturer, som en CAD-linje ikke kan tegne, bliver til udfyldte figurer. Type stays text, and every stroke is a line with its lineweight and dashes. Tekst forbliver tekst, og hver kontur er en linje med sin linjevægt og streger. diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index abfaaa13a7..308e180554 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2592,6 +2592,8 @@ digits Malayalam Malayalam Range: Bereich: Raster Effects Rastereffekte Raster File Format: Pixelbild-Dateiformat: + Include Placed Images Platzierte Bilder einschließen + Include Fill Hatches Füllschraffuren einschließen Raster Sample Size: Aufnahmebereich in Pixelbildern: Raster/Vector Balance: Pixelbild-Vektor-Abgleich: Rasterize In Pixelbild umwandeln diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 4611904ff0..9d62d9579f 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3279,6 +3279,8 @@ digits Malayalam Μαλαγιάλαμ Randomly change saturation and brightness Τυχαία αλλαγή κορεσμού και φωτεινότητας Raster Effects Εφέ ράστερ Raster File Format: Μορφή αρχείου ράστερ: + Include Placed Images Συμπερίληψη τοποθετημένων εικόνων + Include Fill Hatches Συμπερίληψη διαγραμμίσεων γεμισμάτων Raster Sample Size: Μέγεθος δείγματος ράστερ: Raster/Vector Balance: Ισορροπία ράστερ/διανυσμάτων: Rasterize Unsupported Art Μετατροπή μη υποστηριζόμενου σχεδίου σε ράστερ diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index b91d89b8c0..7a3578e5ea 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2634,6 +2634,8 @@ digits Malayalam Malayálam Range: Rango: Raster Effects Efectos de rasterización Raster File Format: Formato de archivo ráster: + Include Placed Images Incluir imágenes colocadas + Include Fill Hatches Incluir sombreados de relleno Raster Sample Size: Tamaño de muestra ráster: Raster/Vector Balance: Equilibrio ráster/vectorial: Rasterize Rasterizar diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index e296459895..c462b9b62a 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -2333,6 +2333,8 @@ digits Malayalam Malajalam Export Selected Art Only Vie vain valittu kuvitus Number of Colors: Värien määrä: Raster File Format: Rasteritiedoston muoto: + Include Placed Images Sisällytä sijoitetut kuvat + Include Fill Hatches Sisällytä täyttöjen viivoitukset True colour needs version 2004 or later: the nearest of 256 colours is written. Täysväri vaatii version 2004 tai uudemman: käytetään lähintä 256 väristä. Type becomes outlines, and strokes a CAD line can't draw become filled shapes. Teksti muunnetaan ääriviivoiksi, ja viivat, joita CAD-viiva ei pysty piirtämään, muuttuvat täytetyiksi muodoiksi. Type stays text, and every stroke is a line with its lineweight and dashes. Teksti säilyy tekstinä, ja jokainen viiva on viiva, jolla on oma viivanpaksuutensa ja katkoviivansa. diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index ff206b9317..687e68c539 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2626,6 +2626,8 @@ digits Malayalam Malayalam Range: Plage : Raster Effects Effets de pixellisation Raster File Format: Format du fichier pixellisé : + Include Placed Images Inclure les images importées + Include Fill Hatches Inclure les hachures des fonds Raster Sample Size: Taille de l’échantillon pixellisé : Raster/Vector Balance: Équilibre pixellisé/vectoriel : Rasterize Pixelliser diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 6698f10a4b..ed44bea5aa 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -3600,6 +3600,8 @@ antiAlias Sharp शार्प Range: रेंज: Raster Effects रीस्टर इफ़ेक्ट Raster File Format: रास्टर फ़ाइल फ़ॉर्मैट: + Include Placed Images रखी गई छवियाँ शामिल करें + Include Fill Hatches फ़िल हैच शामिल करें Raster Sample Size: रास्टर सैंपल साइज़: Raster/Vector Balance: रास्टर/वेक्टर बैलेंस: Rasterize रास्टराइज़ करें diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index a8d02ca4c4..6928f4e079 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3186,6 +3186,8 @@ mark-style Japanese Japán Range: Tartomány: Raster Effects Raszteres effektusok Raster File Format: Raszteres fájlformátum: + Include Placed Images Elhelyezett képek hozzáadása + Include Fill Hatches Kitöltések sraffozásainak hozzáadása Raster Sample Size: Raszteres mintaméret: Raster/Vector Balance: Raszter–vektor arány: Rasterize Raszterizálás diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 04581cd727..d3e8d471fc 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2178,6 +2178,8 @@ digits Malayalam Malayalam Export Selected Art Only Ekspor hanya karya seni terpilih Number of Colors: Jumlah warna: Raster File Format: Format file raster: + Include Placed Images Sertakan gambar yang ditempatkan + Include Fill Hatches Sertakan arsiran isian True colour needs version 2004 or later: the nearest of 256 colours is written. Warna sejati memerlukan versi 2004 atau lebih baru: yang ditulis adalah warna terdekat dari 256 warna. Type becomes outlines, and strokes a CAD line can't draw become filled shapes. Teks menjadi garis luar, dan stroke yang tidak dapat digambar oleh garis CAD menjadi bentuk terisi. Type stays text, and every stroke is a line with its lineweight and dashes. Teks tetap berupa teks, dan setiap stroke menjadi garis dengan ketebalan dan pola putus-putusnya. diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 82827964fe..efd2191f2c 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2668,6 +2668,8 @@ digits Malayalam Malayalam Range: Intervallo: Raster Effects Effetti raster Raster File Format: Formato file raster: + Include Placed Images Includi immagini inserite + Include Fill Hatches Includi tratteggi dei riempimenti Raster Sample Size: Dimensione campione raster: Raster/Vector Balance: Bilanciamento raster/vettoriale: Rasterize Rasterizza diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 42a4aa3cf2..7555d7e258 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2703,6 +2703,8 @@ mark-style Japanese 日本式 Randomly change saturation and brightness 彩度と明度をランダムに変更 Range: 範囲: Raster File Format: ラスターファイル形式: + Include Placed Images 配置画像を含める + Include Fill Hatches 塗りのハッチングを含める Raster Sample Size: ラスターのサンプルサイズ: Raster/Vector Balance: ラスター/ベクターのバランス: Rasterize Unsupported Art 非対応のアートをラスタライズ diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 30b0abd6bf..a87c2c79df 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -2100,6 +2100,8 @@ digits Malayalam მალაიალამური Export Selected Art Only მხოლოდ მონიშნული ნახატის ექსპორტირება Number of Colors: ფერების რაოდენობა: Raster File Format: რასტრული ფაილის ფორმატი: + Include Placed Images განთავსებული გამოსახულებების ჩართვა + Include Fill Hatches შევსებების დაშტრიხვის ჩართვა True colour needs version 2004 or later: the nearest of 256 colours is written. სრულ ფერს სჭირდება 2004 ან უფრო ახალი ვერსია: იწერება 256 ფერიდან უახლოესი. Type becomes outlines, and strokes a CAD line can't draw become filled shapes. ტექსტი კონტურებად იქცევა, ხოლო ის კონტურები, რომლებსაც CAD-ხაზი ვერ ხატავს, შევსებულ ფიგურებად. Type stays text, and every stroke is a line with its lineweight and dashes. ტექსტი ტექსტად რჩება, ყოველი კონტური კი ხაზია თავისი სისქითა და ტირეებით. diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 3bc8360b49..01416bc746 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2693,6 +2693,8 @@ mark-style Japanese 일본식 Randomly change saturation and brightness 채도와 명도를 무작위로 변경 Range: 범위: Raster File Format: 래스터 파일 형식: + Include Placed Images 배치된 이미지 포함 + Include Fill Hatches 칠 해치 포함 Raster Sample Size: 래스터 샘플 크기: Raster/Vector Balance: 래스터/벡터 균형: Rasterize Unsupported Art 지원되지 않는 아트웍 래스터화 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index c6f37b432a..f897feba98 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -2192,6 +2192,8 @@ digits Malayalam Malayalam Export Selected Art Only Alleen geselecteerde artwork exporteren Number of Colors: Aantal kleuren: Raster File Format: Rasterbestandsindeling: + Include Placed Images Geplaatste afbeeldingen opnemen + Include Fill Hatches Arceringen van vullingen opnemen True colour needs version 2004 or later: the nearest of 256 colours is written. Echte kleur vereist versie 2004 of later: de dichtstbijzijnde van 256 kleuren wordt weggeschreven. Type becomes outlines, and strokes a CAD line can't draw become filled shapes. Tekst wordt omlijningen, en lijnen die een CAD-lijn niet kan tekenen, worden gevulde vormen. Type stays text, and every stroke is a line with its lineweight and dashes. Tekst blijft tekst en elke lijn wordt een lijn met zijn eigen lijndikte en streepjes. diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 3647b56f54..ce556135dc 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2289,6 +2289,8 @@ digits Malayalam Malajalam Export Selected Art Only Eksportuj tylko zaznaczoną kompozycję Number of Colors: Liczba kolorów: Raster File Format: Format pliku rastrowego: + Include Placed Images Uwzględnij umieszczone obrazy + Include Fill Hatches Uwzględnij kreskowania wypełnień True colour needs version 2004 or later: the nearest of 256 colours is written. Pełny kolor wymaga wersji 2004 lub nowszej: zapisywany jest najbliższy z 256 kolorów. Type becomes outlines, and strokes a CAD line can't draw become filled shapes. Tekst staje się konturami, a obrysy, których linia CAD nie narysuje, stają się wypełnionymi kształtami. Type stays text, and every stroke is a line with its lineweight and dashes. Tekst pozostaje tekstem, a każdy obrys jest linią z grubością i kreskami. diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 530a62719a..933279cd53 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2196,6 +2196,8 @@ digits Malayalam Malaiala Range: Intervalo: Raster Effects Efeitos de Raster Raster File Format: Formato de Arquivo Raster: + Include Placed Images Incluir Imagens Inseridas + Include Fill Hatches Incluir Hachuras dos Preenchimentos Raster Sample Size: Tamanho da Amostra Raster: Raster/Vector Balance: Equilíbrio Raster/Vetor: Rasterize Unsupported Art Rasterizar Arte Não Suportada diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 341dc196f4..8d3cca069a 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2663,6 +2663,8 @@ digits Malayalam Малаялам Range: Диапазон: Raster Effects Растровые эффекты Raster File Format: Формат растрового файла: + Include Placed Images Включать размещенные изображения + Include Fill Hatches Включать штриховку заливок Raster Sample Size: Размер растрового образца: Raster/Vector Balance: Баланс растровых/векторных: Rasterize Растеризовать diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index ed59d98113..6abad1337c 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -2571,6 +2571,8 @@ digits Malayalam Malayalam Range: Omfång: Raster Effects Rastereffekter Raster File Format: Rasterfilformat: + Include Placed Images Inkludera placerade bilder + Include Fill Hatches Inkludera skrafferingar av fyllningar Raster Sample Size: Samplingsstorlek för raster: Raster/Vector Balance: Raster-/vektorbalans: Rasterize Rastrera diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index dc89f33802..fc4489090e 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2443,6 +2443,8 @@ digits Malayalam Malayalam Range: Aralık: Raster Effects Raster Efektleri Raster File Format: Raster Dosya Formatı: + Include Placed Images Yerleştirilen Görüntüleri Dahil Et + Include Fill Hatches Dolgu Taramalarını Dahil Et Raster Sample Size: Raster Örnek Boyutu: Raster/Vector Balance: Raster/Vektör Dengesi: Rasterize Rasterleştir diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 8d67846ecd..76ea1fd4f9 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2660,6 +2660,8 @@ digits Malayalam Малаялам Range: Діапазон: Raster Effects Растрові ефекти Raster File Format: Формат растрового файлу: + Include Placed Images Включати розміщені зображення + Include Fill Hatches Включати штрихування заливок Raster Sample Size: Розмір растрового зразка: Raster/Vector Balance: Баланс растрових/векторних: Rasterize Растеризувати diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 8f1ea434fd..62b044c576 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2966,6 +2966,8 @@ mark-style Japanese Kiểu Nhật Range: Phạm vi: Raster Effects Hiệu ứng raster Raster File Format: Định dạng tệp raster: + Include Placed Images Bao gồm ảnh đã đặt + Include Fill Hatches Bao gồm mẫu gạch của vùng tô Raster Sample Size: Kích thước mẫu raster: Raster/Vector Balance: Cân bằng raster/vector: Rasterize Raster hóa diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 05c6539bf7..156c535a0f 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2536,6 +2536,8 @@ digits Malayalam 马拉雅拉姆数字 Range: 范围: Raster Effects 栅格效果 Raster File Format: 栅格文件格式: + Include Placed Images 包含置入的图像 + Include Fill Hatches 包含填色的图案填充 Raster Sample Size: 栅格取样大小: Raster/Vector Balance: 栅格/矢量平衡: Rasterize 栅格化 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index cb8a61362e..96388df2a4 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3630,6 +3630,8 @@ digits Malayalam 馬來亞拉姆數字 Range: 範圍: Raster Effects 點陣效果 Raster File Format: 點陣檔案格式: + Include Placed Images 包含置入的影像 + Include Fill Hatches 包含填色的圖案填充 Raster Sample Size: 點陣取樣大小: Raster/Vector Balance: 點陣/向量平衡: Rasterize 點陣化 diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 704ec14d6f..68a040eab3 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -707,8 +707,9 @@ with. DXF Options: Export As… with the DXF format (`file.exportAs {format: "dxf"}`, then the save dialog), or `ui.dxfOptionsDialog {path?, …document.exportDxf options}` for agents, opens the `dxfOptions` dialog. Its fields are `document.export` params: `dxfPreset` (`custom`, `cnc`), `version`, `unit` (a unit name such as `Millimeters`), `scale`, -`scaleLineweights`, `colors` (`8`, `16`, `256`, `true`), `rasterFormat` (`png`, `jpeg`), `preserve` (`appearance`, -`editability`), `selectedOnly`, `alterPaths`, `outlineText`, plus `path` and the artboard choice. Setting `dxfPreset` to +`scaleLineweights`, `colors` (`8`, `16`, `256`, `true`), `rasterFormat` (`png`, `jpeg`), `includeImages` (Include +Placed Images), `preserve` (`appearance`, `editability`), `selectedOnly`, `alterPaths`, `outlineText`, `includeHatches` +(Include Fill Hatches), plus `path` and the artboard choice. Setting `dxfPreset` to `cnc` sets the fields it sets (see `document.exportDxf`), and `dxfPreset` reads `custom` again once one of them is changed; setting it to `custom` puts back the defaults. `ui.dialog.confirm` checks them (a bad value, or Export Selected Art Only with nothing selected, keeps the dialog open), remembers them for the next DXF Options and diff --git a/docs/mcp.md b/docs/mcp.md index 7f6df79837..ac3e1feec1 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5863,7 +5863,10 @@ straight paths become polylines (an open path of one straight segment becomes a before 2000), curved ones cubic splines through every anchor, fills their outlines followed by solid hatches (laser and cutter software reads the outlines and skips hatches; R12 has no hatches: the outlines alone), strokes lines with their lineweight and a linetype per dash pattern, and placed images image entities linked -to PNG or JPEG files (`rasterFormat`) written next to the drawing (`linked` in the result). Coordinates are y up +to PNG or JPEG files (`rasterFormat`) written next to the drawing (`linked` in the result). `includeImages: false` +leaves placed images out, with no image entity, no linked file and no decoding of their pixels, and `includeHatches: +false` writes each filled area as its outlines alone, without its solid hatch (both default to `true`; these two +options are VectorCraft's own). Either one, when it leaves something out, adds one note to `warnings`. Coordinates are y up from the bottom-left corner of the first artboard (or `artboard`), in drawing units: `scale` units per `unit` (default 1 mm = 1 unit, which sets `$INSUNITS`); `scaleLineweights` scales the lineweights with them. `colors` is `8`, `16` or `256` indexed colours, or `true` (default; true colour with the nearest index, DXF 2004 and later). `preserve: @@ -5876,7 +5879,8 @@ selected objects in their layers (every export takes it), and `useArtboards: tru artboard holding the art over it. `dxfPreset: "cnc"` (CNC / CAM; default `"custom"`) sets the options for cutting software that the call does not give: version `2000` (the oldest version that has both lightweight polylines and lineweights), `unit: "mm"` and `scale: 1`, `colors: 256` (all version 2000 holds), `preserve: "editability"` with `alterPaths: false` (every -stroke a center line), `outlineText: true` (type as paths), and `rasterFormat: "png"`. DXF Options has the same choice as its Preset menu, +stroke a center line), `outlineText: true` (type as paths), `includeImages: false` and `includeHatches: false` (the +cutting paths alone: fills as their outlines, no image files), and `rasterFormat: "png"`. DXF Options has the same choice as its Preset menu, which shows Custom again once a field it sets is changed. What DXF can't hold (gradients and patterns as one colour, blending, opacity masks, raster effects, clipping) comes back in `warnings`. In R13 and later, an image that can't be decoded is left out with no linked file, and `warnings` carries "an image couldn't be decoded and is missing from the picture" once for all such diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 908895ad64..9b7d84a030 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1500,7 +1500,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - EPS and PostScript import keeps a CMYK image's ink amounts, as PDF import does: the samples of `image` and `colorimage` in a CMYK color space, and of a CMYK JPEG read through `DCTDecode`, come in as a CMYK TIFF, read through the image's Decode array, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask (`crates/eps/src/import/tests_images.rs`). Such images came in as RGB PNGs. - EPS and PostScript import reads image data through `CCITTFaxDecode`: Group 4 (ITU-T T.6) and Group 3 one-dimensional and mixed (ITU-T T.4) data, with the filter's `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1` parameters, decodes to 1-bit rows for `image` and `imagemask`. Damaged data keeps the rows decoded before it (damage in the first row is a PostScript `ioerror`), and parameters out of range or past the 256 MiB limit on decoded data are refused (`crates/eps/src/import/tests_images.rs`). Reading such a file stopped at an error naming the filter, and the file opened from a fallback, such as its preview or the art drawn before the image, or did not open. - EPS and PostScript `.ai` import undoes the TIFF predictor (`/Predictor 2`) of `FlateDecode` and `LZWDecode` data at 1, 2, 4 and 16 bits a component as well as 8: each component adds the same component of the pixel to its left within its bit width, 16-bit components add as big-endian numbers, and each row starts again (ISO 32000-1 §7.4.4.4; `crates/eps/src/import/tests_images.rs`, `crates/engine/src/cmd/fileio/tests_epsimport.rs`). At those depths the image drew the stored differences instead of its samples. - - DXF control-point SPLINE import honors its closed flag (#1299 follow-up after #1320 fixed export), including rational curves; open curves whose endpoints meet stay open. Own-fixture regressions cover controls/knots, fit-point fallback and all export versions (`crates/cad/src/import/tests.rs`, `crates/cad/src/tests.rs`); native save/read checked. Circular cubic paths export as native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE, and a non-circular ellipse keeps the existing fallback (`crates/cad/src/tests.rs`); an open path of one straight segment exports as a LINE (R12 to R14 strokes with a weight stay polylines, which carry the width) and reads back as the same segment (`crates/cad/src/tests.rs`, `crates/cad/src/import/tests.rs`); DXF Options' Preset menu and `document.exportDxf`'s `dxfPreset: "cnc"` set the CNC / CAM options (version 2000, 1 mm = 1 unit, 256 colors, Maximize Editability with Alter Paths off, Outline Text, PNG placed images), and the menu shows Custom once a field changes (`the_cnc_preset_writes_version_2000_in_millimeters_with_type_as_paths_and_strokes_as_lines`, `the_cnc_preset_sets_the_options_that_are_not_given`, `the_cnc_cam_preset_sets_the_fields_and_a_change_shows_custom`); VCarve QA remains pending. + - DXF control-point SPLINE import honors its closed flag (#1299 follow-up after #1320 fixed export), including rational curves; open curves whose endpoints meet stay open. Own-fixture regressions cover controls/knots, fit-point fallback and all export versions (`crates/cad/src/import/tests.rs`, `crates/cad/src/tests.rs`); native save/read checked. Circular cubic paths export as native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE, and a non-circular ellipse keeps the existing fallback (`crates/cad/src/tests.rs`); an open path of one straight segment exports as a LINE (R12 to R14 strokes with a weight stay polylines, which carry the width) and reads back as the same segment (`crates/cad/src/tests.rs`, `crates/cad/src/import/tests.rs`); DXF Options' Preset menu and `document.exportDxf`'s `dxfPreset: "cnc"` set the CNC / CAM options (version 2000, 1 mm = 1 unit, 256 colors, Maximize Editability with Alter Paths off, Outline Text, placed images and fill hatches left out), and the menu shows Custom once a field changes (`the_cnc_preset_writes_version_2000_in_millimeters_with_type_as_paths_and_strokes_as_lines`, `the_cnc_preset_sets_the_options_that_are_not_given`, `the_cnc_cam_preset_sets_the_fields_and_a_change_shows_custom`); VCarve QA remains pending. + - DXF export's Include Placed Images and Include Fill Hatches (`includeImages`, `includeHatches`; VectorCraft's own options, on by default and off in CNC / CAM) leave out placed images with their linked files, without decoding them, and the solid hatch after each filled area's outlines, each on its own, with one note in `warnings` when something was left out; R12 keeps its own notes (`fills_without_hatches_keep_their_outlines`, `images_can_be_left_out_with_their_files`, `images_and_hatches_are_left_out_independently_and_the_cnc_preset_leaves_out_both`, `placed_images_and_fill_hatches_can_be_left_out`, `include_placed_images_and_fill_hatches_follow_the_preset_and_a_change_shows_custom`). - DXF export writes a path of straight segments and circular arcs (a slot, a D shape, a rounded rectangle, a pie) as one polyline whose vertices carry a bulge (group 42) for each arc: an LWPOLYLINE in R14 and later, a POLYLINE with VERTEX entities in R12 and R13, closed when the path is closed, and it reads back as the same outline (#1299, `crates/cad/src/tests.rs`). Such a path was written as one SPLINE (a flattened polyline in R12); a path with any other curve still is. A shallow curve becomes an ARC or a bulge only when it lies on the circle within 5e-4 of its own extent as well as of the radius, and the drawing extents (`$EXTMIN`, `$EXTMAX`) reach the furthest points of every ARC and bulge. - Affinity documents in (AF.1–AF.4): `.af` (Affinity 3) and `.afdesign`/`.afphoto`/`.afpub` (Affinity 1 and 2, container versions 8–12) open and place as editable art: layers and sublayers, groups (pass-through or isolated), artboards (with their names and backgrounds) and Publisher pages side by side, curves with live corners, parametric shapes (rectangles with corner radii, ellipses, polygons, stars, pies, triangles, trapezoids), compound shapes, solid/linear/elliptical gradient fills and strokes (weight, dashes, alignment, scale-with-object), clipping by nesting, vector and pixel masks, artistic and frame text with fonts, sizes, tracking, leading and alignment, placed images (their original JPEG/PNG) and pixel layers (8/16-bit RGB, CMYK), opacity, visibility, locking and blend modes, at the document's resolution in points. Effects, adjustments, brushes and pressure profiles, transparency gradients, master pages, symbol instances (imported as their cached picture), special shapes (cloud, heart, cog…, imported as ellipses) and Affinity-only blend modes are named in the import warning. A file whose native data can't be read opens as its embedded preview with the reason, and that preview can't be placed. Save never writes back over the Affinity file and there is no Affinity export. AF.5 (from DesignCraft's import, storytold/designcraft#291) adds master pages (on a locked layer behind the pages that show them), tables (area type per cell and a line per edge), each paragraph's alignment, indents, spacing and leading, horizontal text scale, outlined text, frame text sized by its text scale, drop shadows and outer glows as live effects (inner shadows as inner glows), outline effects as strokes, colour and gradient overlays baked into the paint, embedded Affinity documents as editable content (their artboards don't become artboards), pixel layers over 64 MP at a reduced resolution and HEIC photos from their stored pixels; automatic leading (size plus the font's line gap) fitted on thumbnails; underlines and strikethrough; strokes whose style is None (every shape stores one) left out, which also brings 6 public files closer to their thumbnails; line breaks inside a paragraph; table cell text centred as Affinity centres it, also where it is taller than its cell; CMYK pixel layers converted through the document's embedded CMYK profile (relative colorimetric with black-point compensation, as each layer's colour settings name), or kept as CMYK images when there is none; outline effects on stroked shapes around their stroke; artistic text whose paragraphs align differently aligned within its longest line, a frame's first baseline at cap height, and frames whose last lines' descenders hang out made tall enough to show them (all measured on a local document's own PDF export: baselines match to 0.1 pt); a local 56-artboard document's mean thumbnail difference went from 16.2 to 11.4, and its pages' mean difference from the owner's PDF export from 12.9 to 9.3. AF.4 adds Affinity 3 artboard recognition and actual-outline clipping, source-backed JPEG/PNG raster tiles, Unicode text-run recovery, stored fallback/exact installed fonts, OpenType feature flags and compound-geometry limits. 51 public CC0/MIT/Apache-2.0 files (33 current `.af`), pinned and sha256-verified (`cargo xtask corpus --affinity`), test native retention and every-board native/SVG/PDF/PSD output for current files; selected files compare their saved thumbnails. No Affinity reopen or independent paired PSD oracle was available; [source audit and complex-font gaps](affinity-validation.md); [scope, provenance and limits](../crates/affinity/README.md). - Placed documents: File › Place of a `.vectorcraft` file links to it by default (`file.place`'s `link`): one locked object showing an artboard of the document (or its art's bounds), read the way placing it without Link reads it, its symbols, patterns and images kept apart from the document's; without Link (or from bytes, not a file) it is an editable copy as before. It stays vectors in every output (SVG, PDF, EPS, EMF, DXF), and so do placed documents inside it, read from their own files up to 8 levels deep (documents that place each other stop there). On screen they draw from bitmaps cached per file and zoom step, shared by every copy and made in the background (a placeholder until ready), so a sheet of them redraws in about a millisecond; exports and prints draw them exactly. Like linked images, a save keeps only a preview of each (JPEG when opaque) unless Include Linked Files; output, and zooming past the preview, read the file again, falling back to the preview with a warning when it is gone or changed. Saves for older versions write them as plain art. Links check, update, relink, Update Links on open, Link Info (the artboard's size), Placement Options, Break Link (also Embed, Object › Expand: the editable copy placing without Link gives, its resources joining the document), Edit Original (the document in a new tab) and the Links panel cover them; saving a document updates the open documents that place it. The native format (v4) keeps data of 8 KB or more after the JSON instead of base64 in it. From c4ace05dbfe9ae1e6e10475bc0251894791aaa78 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 21:11:09 -0800 Subject: [PATCH 11/43] Graphic Styles: a built-in Painted and Sketched library adds watercolor, charcoal and ink styles The Graphic Styles library menu lists nine libraries. Painted and Sketched follows Hand-Drawn and holds Washed Blue, Bristle Ochre, Pastel Heather, Charcoal Shade, Ink Contour, Wet Ink and Fine Pen. Each style is a solid fill, two with a 1 pt stroke, under one watercolor, dry brush, rough pastels, charcoal, ink outlines, sumi-e or graphic pen effect with every setting given. The library and style names have rows in all 27 catalogs, and docs/mcp.md describes the library. --- crates/doc/src/style_libs.rs | 84 +++++++++++++++++++ crates/engine/src/tests/stylelib.rs | 81 ++++++++++++++++++ crates/ui-egui/src/i18n/bg.tsv | 8 ++ crates/ui-egui/src/i18n/bs.tsv | 8 ++ crates/ui-egui/src/i18n/ca.tsv | 8 ++ crates/ui-egui/src/i18n/cs.tsv | 8 ++ crates/ui-egui/src/i18n/da.tsv | 8 ++ crates/ui-egui/src/i18n/de.tsv | 8 ++ crates/ui-egui/src/i18n/el.tsv | 8 ++ crates/ui-egui/src/i18n/es.tsv | 8 ++ crates/ui-egui/src/i18n/fi.tsv | 8 ++ crates/ui-egui/src/i18n/fr.tsv | 8 ++ crates/ui-egui/src/i18n/hi.tsv | 8 ++ crates/ui-egui/src/i18n/hu.tsv | 8 ++ crates/ui-egui/src/i18n/id.tsv | 8 ++ crates/ui-egui/src/i18n/it.tsv | 8 ++ crates/ui-egui/src/i18n/ja.tsv | 8 ++ crates/ui-egui/src/i18n/ka.tsv | 8 ++ crates/ui-egui/src/i18n/ko.tsv | 8 ++ crates/ui-egui/src/i18n/nl.tsv | 8 ++ crates/ui-egui/src/i18n/pl.tsv | 8 ++ crates/ui-egui/src/i18n/pt-br.tsv | 8 ++ crates/ui-egui/src/i18n/ru.tsv | 8 ++ crates/ui-egui/src/i18n/sv.tsv | 8 ++ crates/ui-egui/src/i18n/tests.rs | 14 ++++ crates/ui-egui/src/i18n/tr.tsv | 8 ++ crates/ui-egui/src/i18n/uk.tsv | 8 ++ crates/ui-egui/src/i18n/vi.tsv | 8 ++ crates/ui-egui/src/i18n/zh-hans.tsv | 8 ++ crates/ui-egui/src/i18n/zh-hant.tsv | 8 ++ .../src/panels/tests_style_libraries.rs | 24 ++++++ docs/gaps.md | 9 +- docs/mcp.md | 9 +- docs/target-app-parity.md | 2 +- 34 files changed, 433 insertions(+), 6 deletions(-) diff --git a/crates/doc/src/style_libs.rs b/crates/doc/src/style_libs.rs index f64bdb4300..19bc888c16 100644 --- a/crates/doc/src/style_libs.rs +++ b/crates/doc/src/style_libs.rs @@ -155,6 +155,7 @@ pub const STYLE_LIBRARIES: &[BuiltinStyleLibrary] = &[ BuiltinStyleLibrary { id: "shadows-glows", name: "Shadows and Glows", make: shadows_glows }, BuiltinStyleLibrary { id: "outlines-rules", name: "Outlines and Rules", make: outlines_rules }, BuiltinStyleLibrary { id: "hand-drawn", name: "Hand-Drawn", make: hand_drawn }, + BuiltinStyleLibrary { id: "painted-sketched", name: "Painted and Sketched", make: painted_sketched }, BuiltinStyleLibrary { id: "gradient-finishes", name: "Gradient Finishes", make: gradient_finishes }, BuiltinStyleLibrary { id: "shape-effects", name: "Shape Effects", make: shape_effects }, BuiltinStyleLibrary { id: "blends-transparency", name: "Blends and Transparency", make: blends_transparency }, @@ -322,6 +323,59 @@ fn hand_drawn() -> Vec { ] } +/// Painted and Sketched: one catalog paint or sketch effect over the whole appearance, so it +/// repaints the fill and the stroke together (watercolor, dry brush, pastels, charcoal and ink). +fn painted_sketched() -> Vec { + vec![ + style( + "Washed Blue", + vec![F(fill("#8ab9c9"))], + vec![fx("artistic.watercolor", json!({"brushDetail": 8.0, "shadowIntensity": 2.0, "texture": 2.0}))], + ), + style( + "Bristle Ochre", + vec![F(fill("#c4975a")), S(stroke("#76533d", 1.0))], + vec![fx("artistic.dryBrush", json!({"brushSize": 4.0, "brushDetail": 7.0, "texture": 2.0}))], + ), + style( + "Pastel Heather", + vec![F(fill("#a78ca5"))], + vec![fx( + "artistic.roughPastels", + json!({ + "strokeLength": 8.0, + "strokeDetail": 9.0, + "texture": "canvas", + "scaling": 100.0, + "relief": 5.0, + "lightDirection": "topLeft", + "invert": false + }), + )], + ), + style( + "Charcoal Shade", + vec![F(fill("#666666"))], + vec![fx("sketch.charcoal", json!({"thickness": 3.0, "detail": 3.0, "lightDarkBalance": 55.0}))], + ), + style( + "Ink Contour", + vec![F(fill("#d9e2df")), S(stroke("#26323b", 1.0))], + vec![fx("brushStrokes.inkOutlines", json!({"strokeLength": 6.0, "darkIntensity": 24.0, "lightIntensity": 8.0}))], + ), + style( + "Wet Ink", + vec![F(fill("#637b85"))], + vec![fx("brushStrokes.sumiE", json!({"strokeWidth": 7.0, "strokePressure": 5.0, "contrast": 20.0}))], + ), + style( + "Fine Pen", + vec![F(fill("#d8d4c6"))], + vec![fx("sketch.graphicPen", json!({"strokeLength": 6.0, "lightDarkBalance": 55.0, "strokeDirection": "rightDiagonal"}))], + ), + ] +} + fn gradient_finishes() -> Vec { let rim = |h: &str| S(stroke(h, 1.0)); vec![ @@ -552,6 +606,36 @@ mod tests { assert_eq!(status_names, ["Poison Tint", "Freeze Edge", "Burn Aura", "Stun Pulse", "Stealth Fade", "Shield Buff"]); } + #[test] + fn painted_and_sketched_follows_hand_drawn_with_one_effect_per_style() { + let ids: Vec<&str> = STYLE_LIBRARIES.iter().map(|b| b.id).collect(); + let at = ids.iter().position(|&id| id == "painted-sketched").expect("listed"); + assert_eq!(ids[at - 1], "hand-drawn"); + let lib = builtin_style_library("painted-sketched").unwrap(); + assert_eq!(lib.name, "Painted and Sketched"); + let names: Vec<&str> = lib.styles.iter().map(|g| g.name.as_str()).collect(); + assert_eq!(names, ["Washed Blue", "Bristle Ochre", "Pastel Heather", "Charcoal Shade", "Ink Contour", "Wet Ink", "Fine Pen"]); + let effects: Vec<&str> = lib.styles.iter().flat_map(|g| g.appearance.effects.iter().map(|e| e.id.as_str())).collect(); + assert_eq!( + effects, + [ + "artistic.watercolor", + "artistic.dryBrush", + "artistic.roughPastels", + "sketch.charcoal", + "brushStrokes.inkOutlines", + "brushStrokes.sumiE", + "sketch.graphicPen" + ] + ); + assert!(lib.styles.iter().all(|g| g.appearance.items.iter().all(|i| i.effects().is_empty())), "no effects on a fill or stroke"); + // No other built-in library holds a style of the same name. + for b in STYLE_LIBRARIES.iter().filter(|b| b.id != "painted-sketched") { + let other = builtin_style_library(b.id).unwrap(); + assert!(other.styles.iter().all(|g| !names.contains(&g.name.as_str())), "{}", b.id); + } + } + #[test] fn vcstyles_round_trip() { let mut lib = (*builtin_style_library("gradient-finishes").unwrap()).clone(); diff --git a/crates/engine/src/tests/stylelib.rs b/crates/engine/src/tests/stylelib.rs index 5124fb4502..baceb94a6e 100644 --- a/crates/engine/src/tests/stylelib.rs +++ b/crates/engine/src/tests/stylelib.rs @@ -82,6 +82,87 @@ fn builtin_libraries_are_listed_with_original_styles_using_catalogued_effects() assert!(s.execute("graphicStyle.library", &json!({"library": "nope"})).is_err()); } +#[test] +fn painted_and_sketched_styles_set_every_effect_setting_and_reopen_applied() { + let mut s = session(); + let list = run(&mut s, "graphicStyle.libraries", json!({})); + let libs = list["libraries"].as_array().unwrap(); + let at = libs.iter().position(|l| l["id"] == "painted-sketched").expect("listed"); + assert_eq!((libs[at]["name"].as_str(), libs[at]["count"].as_u64()), (Some("Painted and Sketched"), Some(7))); + assert_eq!(libs[at - 1]["id"], "hand-drawn"); + let g = run(&mut s, "graphicStyle.library", json!({"library": "Painted and Sketched"})); + let listed: Vec<(&str, &str, Vec<&str>)> = + g["styles"].as_array().unwrap().iter().map(|x| (x["name"].as_str().unwrap(), x["fill"].as_str().unwrap(), names(&x["effects"]))).collect(); + let want = [ + ("Washed Blue", "#8ab9c9", "artistic.watercolor"), + ("Bristle Ochre", "#c4975a", "artistic.dryBrush"), + ("Pastel Heather", "#a78ca5", "artistic.roughPastels"), + ("Charcoal Shade", "#666666", "sketch.charcoal"), + ("Ink Contour", "#d9e2df", "brushStrokes.inkOutlines"), + ("Wet Ink", "#637b85", "brushStrokes.sumiE"), + ("Fine Pen", "#d8d4c6", "sketch.graphicPen"), + ]; + assert_eq!(listed, want.iter().map(|&(n, f, e)| (n, f, vec![e])).collect::>()); + // Every setting of each effect is given, with the catalog's key and kind of value. + let (_, lib) = cmd::stylelib::library(&s, "painted-sketched").unwrap(); + for st in &lib.styles { + let e = &st.appearance.effects[0]; + let defaults = vectorcraft_render::effects::effect_info(&e.id).unwrap().defaults; + let (set, all) = (e.params.as_object().unwrap(), defaults.as_object().unwrap()); + let keys = |m: &serde_json::Map| m.keys().cloned().collect::>(); + assert_eq!(keys(set), keys(all), "{}", st.name); + for (k, v) in set { + let kind = |v: &Value| (v.is_number(), v.is_string(), v.is_boolean()); + assert_eq!(kind(v), kind(&all[k]), "{} / {k}", st.name); + } + } + let strokes: Vec<(&str, f64)> = + lib.styles.iter().filter(|g| g.appearance.stroke_paint() != Paint::None).map(|g| (g.name.as_str(), g.appearance.stroke_width())).collect(); + assert_eq!(strokes, [("Bristle Ochre", 1.0), ("Ink Contour", 1.0)]); + + // Each one adds and applies to a rectangle in one undo step. + let mut ids = vec![]; + for (i, (name, fill, effect)) in want.iter().enumerate() { + let id = rect(&mut s, 10.0 + 25.0 * i as f64, 20.0, 20.0, 30.0); + let before = undo_len(&s); + let r = run(&mut s, "graphicStyle.addFromLibrary", json!({"library": "painted-sketched", "name": name, "apply": true, "ids": [id.0]})); + assert_eq!((names(&r["added"]), r["applied"].as_str()), (vec![*name], Some(*name))); + assert_eq!(undo_len(&s), before + 1, "{name}"); + let node = doc(&s).node(id).unwrap(); + assert_eq!(node.appearance.fill_paint(), Paint::solid(vectorcraft_color::Color::from_hex(fill).unwrap()), "{name}"); + assert_eq!(node.appearance.effects.iter().map(|e| e.id.as_str()).collect::>(), [*effect]); + assert_eq!(node.appearance.effects[0].params, lib.style(name).unwrap().appearance.effects[0].params, "{name}"); + ids.push((id, *name)); + } + // Adding one again reuses it: no new style, no undo step. + let before = (undo_len(&s), doc(&s).graphic_styles.len()); + let r = run(&mut s, "graphicStyle.addFromLibrary", json!({"library": "painted-sketched", "name": "Wet Ink"})); + assert_eq!((names(&r["added"]), names(&r["existing"])), (vec![], vec!["Wet Ink"])); + assert_eq!((undo_len(&s), doc(&s).graphic_styles.len()), before); + // An unknown style or library changes nothing. + assert!(s.execute("graphicStyle.addFromLibrary", &json!({"library": "painted-sketched", "names": ["Wet Ink", "Nope"]})).is_err()); + assert!(s.execute("graphicStyle.addFromLibrary", &json!({"library": "painted-and-sketched", "name": "Wet Ink"})).is_err()); + assert_eq!((undo_len(&s), doc(&s).graphic_styles.len()), before); + + // Saved and opened again, the styles keep their effects and the rectangles stay linked to them. + let b64 = run(&mut s, "document.serialize", json!({}))["dataBase64"].as_str().unwrap().to_string(); + run(&mut s, "document.open", json!({"name": "painted.vectorcraft", "dataBase64": b64})); + for (id, name) in &ids { + let d = doc(&s); + let style = d.graphic_style(name).unwrap(); + assert!(style.same_look(lib.style(name).unwrap()), "{name}"); + let node = d.node(*id).unwrap(); + assert_eq!((node.extras().graphic_style, &node.appearance.effects), (Some(style.id), &style.appearance.effects), "{name}"); + } + + // With nothing selected, `apply` only adds; empty `names` adds the whole library in order. + run(&mut s, "file.new", json!({"width": 100, "height": 100})); + let r = run(&mut s, "graphicStyle.addFromLibrary", json!({"library": "painted-sketched", "name": "Fine Pen", "apply": true})); + assert!(r.get("applied").is_none() && doc(&s).graphic_style("Fine Pen").is_some()); + let r = run(&mut s, "graphicStyle.addFromLibrary", json!({"library": "painted-sketched", "names": []})); + assert_eq!((names(&r["added"]), names(&r["existing"])), (want[..6].iter().map(|w| w.0).collect::>(), vec!["Fine Pen"])); +} + #[test] fn add_dedupes_names_as_one_undo_step() { let mut s = session(); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index bc67d8fd87..62f130dd16 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1398,6 +1398,14 @@ font-technology Variable Променливи Hair Space Тънък интервал Hand Tool Инструмент „Ръка“ Hand-Drawn Ръчно рисувани + Painted and Sketched Рисувани и скицирани + Washed Blue Размито синьо + Bristle Ochre Охра с четка + Pastel Heather Пастелен пирен + Charcoal Shade Сянка с въглен + Ink Contour Мастилен контур + Wet Ink Мокро мастило + Fine Pen Фино перо Handles Дръжки Hard Light Твърда светлина Hard Mix Твърдо смесване diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 6454cffcc9..142af7344e 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -795,6 +795,14 @@ Shield Buff Štit Outlines and Rules Obrisi i linije Hand-Drawn Ručno crtano + Painted and Sketched Slikano i skicirano + Washed Blue Isprano plavo + Bristle Ochre Oker kistom + Pastel Heather Pastelni vrijesak + Charcoal Shade Sjena ugljenom + Ink Contour Kontura tušem + Wet Ink Mokri tuš + Fine Pen Fino pero Gradient Finishes Gradijentne završnice Shape Effects Efekti oblika Blends and Transparency Stapanja i prozirnost diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 1567e0e8b5..9d852140cd 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -1905,6 +1905,14 @@ digits Malayalam Malaiàlam Hand Mà Hand Tool Eina de mà Hand-Drawn Dibuixat a mà + Painted and Sketched Pintat i esbossat + Washed Blue Blau rentat + Bristle Ochre Ocre de cerres + Pastel Heather Bruc pastel + Charcoal Shade Ombra de carbó + Ink Contour Contorn de tinta + Wet Ink Tinta humida + Fine Pen Ploma fina Handles Nanses Hard Dur Hard Light Llum dura diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index e9df0812f2..7fd853134f 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -803,6 +803,14 @@ Shield Buff Štít Outlines and Rules Obrysy a linky Hand-Drawn Ručně kreslené + Painted and Sketched Malované a skicované + Washed Blue Rozmytá modrá + Bristle Ochre Okr štětcem + Pastel Heather Pastelový vřes + Charcoal Shade Stín uhlem + Ink Contour Obrys tuší + Wet Ink Mokrá tuš + Fine Pen Jemné pero Gradient Finishes Přechodové povrchy Shape Effects Efekty tvarů Blends and Transparency Prolnutí a průhlednost diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index e259ecba84..2c53139bf8 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -741,6 +741,14 @@ Shield Buff Skjold Outlines and Rules Konturer og linjer Hand-Drawn Håndtegnet + Painted and Sketched Malet og skitseret + Washed Blue Udvasket blå + Bristle Ochre Børsteokker + Pastel Heather Pastellyng + Charcoal Shade Kulskygge + Ink Contour Blækkontur + Wet Ink Vådt blæk + Fine Pen Fin pen Gradient Finishes Gradientfinish Shape Effects Formeffekter Blends and Transparency Blandinger og gennemsigtighed diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index abfaaa13a7..2ac8ef3851 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1435,6 +1435,14 @@ file-filter Templates Vorlagen Hair Space Haarspatium Hand Tool Hand-Werkzeug Hand-Drawn Handgezeichnet + Painted and Sketched Gemalt und skizziert + Washed Blue Verwaschenes Blau + Bristle Ochre Borstenocker + Pastel Heather Pastellheide + Charcoal Shade Kohleschatten + Ink Contour Tuschekontur + Wet Ink Nasse Tusche + Fine Pen Feine Feder Handles Griffe Hard Light Hartes Licht Hard Mix Hart mischen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 4611904ff0..386ec443d5 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1373,6 +1373,14 @@ font-technology Variable Μεταβλητές Hair Space Πολύ λεπτό διάστημα Hand Tool Χέρι (Hand Tool) Hand-Drawn Σχεδιασμένο στο χέρι + Painted and Sketched Ζωγραφισμένο και σκιτσαρισμένο + Washed Blue Ξεπλυμένο μπλε + Bristle Ochre Ώχρα με πινέλο + Pastel Heather Παστέλ ρείκι + Charcoal Shade Σκιά με κάρβουνο + Ink Contour Περίγραμμα με μελάνι + Wet Ink Υγρό μελάνι + Fine Pen Λεπτή πένα Handles Λαβές Hard Light Έντονο φως Hard Mix Έντονη ανάμειξη diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index b91d89b8c0..f432bc4f20 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1452,6 +1452,14 @@ font-technology Variable Variables Hair Space Espacio fino Hand Tool Herramienta Mano Hand-Drawn Dibujado a mano + Painted and Sketched Pintado y bocetado + Washed Blue Azul lavado + Bristle Ochre Ocre de cerdas + Pastel Heather Brezo pastel + Charcoal Shade Sombra al carboncillo + Ink Contour Contorno de tinta + Wet Ink Tinta húmeda + Fine Pen Plumilla fina Handles Manejadores Hard Light Luz fuerte Hard Mix Mezcla definida diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index e296459895..98154222f2 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -794,6 +794,14 @@ Shield Buff Kilpi Outlines and Rules Ääriviivat ja viivat Hand-Drawn Käsin piirretyt + Painted and Sketched Maalatut ja luonnostellut + Washed Blue Haalea sininen + Bristle Ochre Harjasokra + Pastel Heather Pastellikanerva + Charcoal Shade Hiilivarjo + Ink Contour Mustekontuuri + Wet Ink Märkä muste + Fine Pen Hieno kynä Gradient Finishes Liukuväriviimeistelyt Shape Effects Muototehosteet Blends and Transparency Sulautukset ja läpinäkyvyys diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index ff206b9317..a74f882022 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1446,6 +1446,14 @@ font-technology Variable Variables Hair Space Espace ultrafine Hand Tool Outil Main Hand-Drawn Dessiné à la main + Painted and Sketched Peint et esquissé + Washed Blue Bleu lavé + Bristle Ochre Ocre à la brosse + Pastel Heather Bruyère pastel + Charcoal Shade Ombre au fusain + Ink Contour Contour encré + Wet Ink Encre humide + Fine Pen Plume fine Handles Poignées Hard Light Lumière crue Hard Mix Mélange maximal diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 6698f10a4b..0c6cef82c4 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -2100,6 +2100,14 @@ antiAlias Sharp शार्प Hand हैंड Hand Tool हैंड टूल Hand-Drawn हाथ-ड्रन + Painted and Sketched पेंट और स्केच + Washed Blue धुला नीला + Bristle Ochre ब्रश गेरू + Pastel Heather पेस्टल बैंगनी + Charcoal Shade चारकोल छाया + Ink Contour स्याही रूपरेखा + Wet Ink गीली स्याही + Fine Pen बारीक पेन Handles हैंडल्स Hard कठोर Hard Light हार्ड लाइट diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index a8d02ca4c4..5252b197b4 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1846,6 +1846,14 @@ file-filter Templates Sablonok Hand Kéz Hand Tool Kéz Hand-Drawn Kézzel rajzolt + Painted and Sketched Festett és vázlatos + Washed Blue Kimosott kék + Bristle Ochre Sörteokker + Pastel Heather Pasztell hanga + Charcoal Shade Szénárnyék + Ink Contour Tuskontúr + Wet Ink Nedves tus + Fine Pen Finom toll Handles Fogantyúk Hard Szigorú Hard Light Kemény fény diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 04581cd727..6163d14a66 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -741,6 +741,14 @@ Shield Buff Perisai Outlines and Rules Garis luar dan garis Hand-Drawn Gambar tangan + Painted and Sketched Lukisan dan sketsa + Washed Blue Biru luntur + Bristle Ochre Oker kuas kering + Pastel Heather Ungu pastel + Charcoal Shade Bayangan arang + Ink Contour Kontur tinta + Wet Ink Tinta basah + Fine Pen Pena halus Gradient Finishes Finishing gradien Shape Effects Efek bentuk Blends and Transparency Blend dan transparansi diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 82827964fe..8b30029541 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1465,6 +1465,14 @@ file-filter Templates Modelli Hair Space Spazio sottilissimo Hand Tool Strumento Mano Hand-Drawn Disegnato a mano + Painted and Sketched Dipinto e schizzato + Washed Blue Blu slavato + Bristle Ochre Ocra a setole + Pastel Heather Erica pastello + Charcoal Shade Ombra a carboncino + Ink Contour Contorno a china + Wet Ink China bagnata + Fine Pen Pennino fine Handles Maniglie Hard Light Luce intensa Hard Mix Miscela intensa diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 42a4aa3cf2..02ef74f5b4 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -827,6 +827,14 @@ digits Malayalam マラヤーラム数字 Shield Buff シールド Outlines and Rules アウトラインと罫線 Hand-Drawn 手描き風 + Painted and Sketched 絵画とスケッチ + Washed Blue にじみブルー + Bristle Ochre 筆跡オーカー + Pastel Heather パステルヘザー + Charcoal Shade 木炭シェード + Ink Contour インク輪郭 + Wet Ink 濡れ墨 + Fine Pen 細いペン Gradient Finishes グラデーション仕上げ Shape Effects シェイプ効果 Blends and Transparency ブレンドと透明 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 30b0abd6bf..82453c673b 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -700,6 +700,14 @@ Shield Buff ფარი Outlines and Rules კონტურები და ხაზები Hand-Drawn ხელით დახატული + Painted and Sketched დახატული და ესკიზური + Washed Blue გარეცხილი ლურჯი + Bristle Ochre ფუნჯის ოხრა + Pastel Heather პასტელის იისფერი + Charcoal Shade ნახშირის ჩრდილი + Ink Contour მელნის კონტური + Wet Ink სველი მელანი + Fine Pen წვრილი კალამი Gradient Finishes გრადიენტული ზედაპირები Shape Effects ფიგურის ეფექტები Blends and Transparency გადასვლები და გამჭვირვალობა diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 3bc8360b49..6e3c6cfc08 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -820,6 +820,14 @@ digits Malayalam 말라얄람어 Shield Buff 방패 Outlines and Rules 윤곽선과 괘선 Hand-Drawn 손그림 + Painted and Sketched 페인팅과 스케치 + Washed Blue 번진 파랑 + Bristle Ochre 붓결 황토 + Pastel Heather 파스텔 헤더 + Charcoal Shade 목탄 음영 + Ink Contour 잉크 윤곽 + Wet Ink 젖은 먹 + Fine Pen 가는 펜 Gradient Finishes 그레이디언트 마감 Shape Effects 모양 효과 Blends and Transparency 블렌드와 투명도 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index c6f37b432a..6f0096eb11 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -755,6 +755,14 @@ Shield Buff Schild Outlines and Rules Omtrekken en lijnen Hand-Drawn Handgetekend + Painted and Sketched Geschilderd en geschetst + Washed Blue Verwassen blauw + Bristle Ochre Borsteloker + Pastel Heather Pastelheide + Charcoal Shade Houtskoolschaduw + Ink Contour Inktcontour + Wet Ink Natte inkt + Fine Pen Fijne pen Gradient Finishes Verloopafwerkingen Shape Effects Vormeffecten Blends and Transparency Vermengingen en transparantie diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 3647b56f54..57a7567e40 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -750,6 +750,14 @@ Shield Buff Tarcza Outlines and Rules Kontury i linie Hand-Drawn Rysowane odręcznie + Painted and Sketched Malowane i szkicowane + Washed Blue Rozmyty błękit + Bristle Ochre Ochra pędzlem + Pastel Heather Pastelowy wrzos + Charcoal Shade Cień węglem + Ink Contour Kontur tuszem + Wet Ink Mokry tusz + Fine Pen Cienkie pióro Gradient Finishes Wykończenia gradientowe Shape Effects Efekty kształtów Blends and Transparency Przejścia i przezroczystość diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 530a62719a..0623cd8151 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1277,6 +1277,14 @@ antiAlias Sharp Nítido Hair Space Espaço Fino Hand Tool Ferramenta Mão Hand-Drawn Desenhado à Mão + Painted and Sketched Pintado e Esboçado + Washed Blue Azul Lavado + Bristle Ochre Ocre de Cerdas + Pastel Heather Urze Pastel + Charcoal Shade Sombra a Carvão + Ink Contour Contorno a Nanquim + Wet Ink Nanquim Úmido + Fine Pen Pena Fina Handles Alças Hard Light Luz Forte Hard Mix Mistura definida diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 341dc196f4..1f8ee5beeb 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1462,6 +1462,14 @@ file-filter Templates Шаблоны Hair Space Волосяная шпация Hand Tool Инструмент «Рука» Hand-Drawn Нарисованный от руки + Painted and Sketched Живопись и эскизы + Washed Blue Размытый синий + Bristle Ochre Охра кистью + Pastel Heather Пастельный вереск + Charcoal Shade Тень углём + Ink Contour Контур тушью + Wet Ink Влажная тушь + Fine Pen Тонкое перо Handles Маркеры Hard Light Жёсткий свет Hard Mix Жёсткое наложение diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index ed59d98113..753a6b44d6 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -1411,6 +1411,14 @@ font-technology Variable Variabla Hair Space Hårfint blanksteg Hand Tool Handverktyg Hand-Drawn Handritad + Painted and Sketched Målat och skissat + Washed Blue Urtvättad blå + Bristle Ochre Borstockra + Pastel Heather Pastelljung + Charcoal Shade Kolskugga + Ink Contour Tuschkontur + Wet Ink Våt tusch + Fine Pen Fin penna Handles Handtag Hard Light Hårt ljus Hard Mix Hård blandning diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index c63e000795..44ee531d76 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -238,6 +238,20 @@ fn anti_aliasing_method_labels_are_in_complete_catalogs() { } } +/// The Painted and Sketched graphic style library's name and its styles' names have a row in every +/// bundled catalog. +#[test] +fn painted_and_sketched_style_names_are_in_every_catalog() { + let lib = vectorcraft_doc::style_libs::builtin_style_library("painted-sketched").expect("built in"); + let labels: Vec<&str> = std::iter::once(lib.name.as_str()).chain(lib.styles.iter().map(|g| g.name.as_str())).collect(); + assert_eq!(labels.len(), 8); + for lang in LANGUAGES.iter().filter(|lang| !lang.source.is_empty()) { + for label in &labels { + assert!(lang.catalog().plain(label).is_some(), "{} is missing {label}", lang.code); + } + } +} + /// Each language counts with its own plural rule: the form a count picks (`one`, then `few`, then /// `other`/`many`). #[test] diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index dc89f33802..f0c5cf5788 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1375,6 +1375,14 @@ font-technology Variable Değişken Hair Space Saç Alanı Hand Tool El Aracı Hand-Drawn elle çizilmiş + Painted and Sketched Boyanmış ve Eskiz + Washed Blue Soluk Mavi + Bristle Ochre Fırça Darbeli Toprak Sarısı + Pastel Heather Pastel Funda + Charcoal Shade Kömür Gölge + Ink Contour Mürekkep Kontur + Wet Ink Islak Mürekkep + Fine Pen İnce Kalem Handles Kollar Hard Light Sert Işık Harmony Rules: Uyum Kuralları: diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 8d67846ecd..2e41ccb2c7 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1459,6 +1459,14 @@ file-filter Templates Шаблони Hair Space Волосяна шпація Hand Tool Інструмент «Рука» Hand-Drawn Намальований від руки + Painted and Sketched Мальовані та ескізні + Washed Blue Розмитий синій + Bristle Ochre Охра пензлем + Pastel Heather Пастельний верес + Charcoal Shade Тінь вугіллям + Ink Contour Контур тушшю + Wet Ink Мокра туш + Fine Pen Тонке перо Handles Маркери Hard Light Жорстке світло Hard Mix Жорстке накладання diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 8f1ea434fd..2712e53218 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1686,6 +1686,14 @@ file-filter Templates Mẫu Halo Vầng hào quang Hand Tool Công cụ Bàn tay Hand-Drawn Vẽ tay + Painted and Sketched Vẽ màu và phác thảo + Washed Blue Xanh nhạt nhòa + Bristle Ochre Vàng đất cọ khô + Pastel Heather Tím phấn màu + Charcoal Shade Bóng than + Ink Contour Đường viền mực + Wet Ink Mực ướt + Fine Pen Bút mảnh Handles Tay nắm Hard Cứng Hard Light Ánh sáng mạnh diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 05c6539bf7..7f0c0aad84 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1402,6 +1402,14 @@ file-filter Templates 模板 Hair Space 极细空格 Hand Tool 抓手工具 Hand-Drawn 手绘 + Painted and Sketched 绘画与素描 + Washed Blue 水洗蓝 + Bristle Ochre 干刷土黄 + Pastel Heather 粉彩淡紫 + Charcoal Shade 炭笔阴影 + Ink Contour 墨线轮廓 + Wet Ink 湿墨 + Fine Pen 细笔 Handles 手柄 Hard Light 强光 Hard Mix 实色混合 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index cb8a61362e..37032585a6 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1430,6 +1430,14 @@ file-filter Templates 範本 Hair Space 極細空格 Hand Tool 手形工具 Hand-Drawn 手繪 + Painted and Sketched 繪畫與素描 + Washed Blue 水洗藍 + Bristle Ochre 乾刷土黃 + Pastel Heather 粉彩淡紫 + Charcoal Shade 炭筆陰影 + Ink Contour 墨線輪廓 + Wet Ink 濕墨 + Fine Pen 細筆 Handles 控制把手 Hard Light 實光 Hard Mix 實色混合 diff --git a/crates/ui-egui/src/panels/tests_style_libraries.rs b/crates/ui-egui/src/panels/tests_style_libraries.rs index a776819381..46c274dee6 100644 --- a/crates/ui-egui/src/panels/tests_style_libraries.rs +++ b/crates/ui-egui/src/panels/tests_style_libraries.rs @@ -101,6 +101,30 @@ fn clicking_a_library_style_adds_and_applies_it_in_one_step() { assert!(last_open(&app.ui).is_none()); } +#[test] +fn painted_and_sketched_opens_from_the_window_menu_and_a_click_applies_its_style() { + let mut app = app(); + let entry = crate::menus::menu_entries(&app) + .into_iter() + .find(|e| e.path == ["Window", "Graphic Style Libraries"] && e.label == "Painted and Sketched") + .expect("a Window menu item"); + assert_eq!((entry.command.as_deref(), &entry.params["library"]), (Some("window.graphicStyleLibrary"), &json!("painted-sketched"))); + let rect = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 80, "height": 40})).unwrap()["id"].as_u64().unwrap(); + app.run(entry.command.as_deref().unwrap(), entry.params.clone()).unwrap(); + assert_eq!(last_open(&app.ui).map(|p| (p.kind.as_str(), p.id.as_str())), Some(("graphicStyles", "painted-sketched"))); + let ctx = context(); + let undo = undo_len(&app); + click(&mut app, &ctx, "Charcoal Shade", Modifiers::NONE, 0.0); + let (g, linked) = app.session.selection_graphic_style().expect("applied to the selection"); + assert_eq!((g.name.as_str(), linked), ("Charcoal Shade", true)); + assert_eq!(undo_len(&app), undo + 1, "added and applied as one undo step"); + let node = app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(rect)).unwrap().appearance.effects.clone(); + assert_eq!(node.iter().map(|e| e.id.as_str()).collect::>(), ["sketch.charcoal"]); + let lib = GraphicStyleLibraries::get(&app, "painted-sketched").unwrap().1; + let rows = GraphicStyleLibraries::rows(&lib, "ink"); + assert_eq!(rows.iter().map(|r| r.name).collect::>(), ["Ink Contour", "Wet Ink"]); +} + #[test] fn the_window_menu_and_the_panel_open_every_library() { let mut app = app(); diff --git a/docs/gaps.md b/docs/gaps.md index a228181abb..d7daac6701 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -646,11 +646,12 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi Direct Selection of a path, compound path or type inside a dynamic instance also takes opacity and blend (`transparency.set`) and a gradient or pattern fill or stroke (`paint.setFill` / `paint.setStroke`, Eyedropper), for that instance only; - graphic style libraries (Window › Graphic Style Libraries; eight original libraries generated in code, including - 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 + graphic style libraries (Window › Graphic Style Libraries; nine original libraries generated in code, including + Game HUD with Health Fill, Mana Glow, Damage Flash, Quest Outline, Coin Shine and Boss Alert, Status Effects + with Poison Tint, Freeze Edge, Burn Aura, Stun Pulse, Stealth Fade and Shield Buff, and Painted and Sketched with + Washed Blue, Bristle Ochre, Pastel Heather, Charcoal Shade, Ink Contour, Wet Ink and Fine Pen; 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 six shipped (and further graphic-style libraries beyond the nine 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 7f6df79837..3cd41ab912 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3353,7 +3353,7 @@ as a value left out. ## Graphic style libraries Graphic style libraries are read-only sets of graphic styles generated in code (Shadows and Glows, Outlines and -Rules, Hand-Drawn, Gradient Finishes, Shape Effects, Blends and Transparency, Game HUD, Status Effects). `graphicStyle.libraries` lists them +Rules, Hand-Drawn, Painted and Sketched, Gradient Finishes, Shape Effects, Blends and Transparency, Game HUD, Status Effects). `graphicStyle.libraries` lists them (`id`, `name`, `category`: `builtIn`, `user` or `loaded`, `count`) and `graphicStyle.library {library}` returns one's styles in the `graphicStyle.list` shape (`library` is an id or a name). `graphicStyle.addFromLibrary {library, name? | names?}` copies styles into the document's Graphic Styles as one undo step (no names: the whole library); styles the @@ -3363,6 +3363,13 @@ the patterns the styles paint with come along. `apply: true` also applies the fi does. Library styles keep placed gradients in unit-box space, so each object gets them at the same place relative to its bounds. +Painted and Sketched (`painted-sketched`) holds Washed Blue, Bristle Ochre, Pastel Heather, Charcoal Shade, Ink +Contour, Wet Ink and Fine Pen. Each is a solid fill (Bristle Ochre and Ink Contour add a 1 pt stroke) under one object +effect that repaints fill and stroke together: `artistic.watercolor`, `artistic.dryBrush`, `artistic.roughPastels`, +`sketch.charcoal`, `brushStrokes.inkOutlines`, `brushStrokes.sumiE` and `sketch.graphicPen`, in that order. The +colors and effect settings are VectorCraft's own. These are raster effects, so the document's raster effects +resolution sets how fine they draw. + ```json {"name":"run_command","arguments":{"command":"graphicStyle.library","params":{"library":"hand-drawn"}}} {"name":"run_command","arguments":{"command":"graphicStyle.addFromLibrary","params":{"library":"hand-drawn","name":"Crosshatch","apply":true}}} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 908895ad64..09e7620c49 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1354,7 +1354,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Transparency:** three-state Knockout Group (on, neutral, off), Opacity & Mask Define Knockout Shape, Page Isolated Blending and Page Knockout Group, on screen and in SVG/PDF. SVG export writes a knockout group of opaque art plainly, without masks, so shapes meeting edge to edge leave no seam; translucent, blended, masked or brushed art knocks out through masks. Blend modes match the standard formulas on screen, in SVG and in PDF, and non-isolated groups blend with the art below. Flatten Transparency with presets, a dialog and a Flattener Preview panel. Flattening for files without transparency (PDF/X-1a, PDF/X-3) in a CMYK document keeps the inks where it rasterizes, as under an image with see-through pixels: the images it makes hold ink amounts, not screen colours the file would separate again, so a C100 M6 Y41 K3 panel stays C100 M6 Y41 K3 there (#2160, `engine/src/cmd/fileio/tests_pdfx.rs`). On the canvas, the renderer no longer draws the picture of the art below back as an image each time it copies the backdrop of a non-isolated group that blends: it keeps that picture and draws the art that follows over it. At 2880 × 1800, 50 translucent groups with Multiply members take 65 ms a frame instead of 299 ms on 8 threads (`vectorcraft-cli bench`), and the frames have the same pixels. Type inside a group, layer, symbol instance, blend or repeat keeps its opacity, blend mode, opacity mask, Opacity & Mask Define Knockout Shape, own effects, perspective and visibility when the effects on the container outline it. Hidden type with a geometry effect of its own is left out of exports. - - **Graphic styles:** styles keep opacity and blend mode, capture groups and type, apply on top with Alt, and link to the objects using them; Redefine, Break Link, Graphic Style Options, Select All Unused, Sort by Name, and Select > Same Graphic Style / Appearance Attribute. The panel has list and thumbnail views with rendered previews, merge, reorder and drag and drop, plus a Control bar Style picker; eight generated style libraries (including Game HUD and Status Effects) save and load as `.vcstyles`. + - **Graphic styles:** styles keep opacity and blend mode, capture groups and type, apply on top with Alt, and link to the objects using them; Redefine, Break Link, Graphic Style Options, Select All Unused, Sort by Name, and Select > Same Graphic Style / Appearance Attribute. The panel has list and thumbnail views with rendered previews, merge, reorder and drag and drop, plus a Control bar Style picker; nine generated style libraries (including Game HUD, Status Effects, and Painted and Sketched, whose seven styles each repaint a solid fill with one watercolor, dry brush, pastel, charcoal or ink effect) save and load as `.vcstyles`. In the Graphic Styles panel, Shift-click selects a range of styles and Ctrl/Cmd-click adds or removes one. Applying a style to several objects, and Redefine Graphic Style, give the objects one shared copy of the style's appearance instead of a copy each; an object that is edited later gets its own copy, and an object whose placed gradients are fitted to its own bounds gets its own. The undo history's memory estimate counts a shared appearance once. Saved files are unchanged (`crates/engine/src/tests/styles.rs`). - **Several text styles at once:** in the Character Styles and Paragraph Styles panels, Shift-click highlights a range of styles and Ctrl/Cmd-click adds or removes one, without applying them; the panel menu's Select All Unused (`charStyle.unused`, `paraStyle.unused`) highlights the styles no type uses (in the layers, opacity masks, symbols, graph designs, pattern tiles and the art of brushes), never the Normal style; Delete, the button or the menu item, deletes every highlighted style but Normal in one undo step (`charStyle.delete {names}`, `paraStyle.delete {names}`). The menu's Duplicate copies every highlighted style in one undo step and highlights the copies. A highlighted row dragged onto Create New Style or Delete carries every highlighted style: dropped on Create New Style it copies them all and highlights the copies, and dropped on Delete it deletes them all but Normal and clears the highlight; a row from outside the highlight carries only its own style. Public reference: [Character and paragraph styles](https://web.archive.org/web/20261015081534/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). From f82a31045c058934ec379919ab3b8b51dfae9dd4 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 21:11:12 -0800 Subject: [PATCH 12/43] Blend: the Blend panel's Create Spine menu sets whether new blends get a spine The Blend panel gets a Create Spine menu (Auto, Always, Never) under Make, and object.blend.createSpine {mode?} sets or reads it. It is kept with the preferences, is not an undo step and leaves blends already made as they are. object.blend.make takes createSpine to choose for one new blend. Auto and Always give every new blend a straight spine, as before. Never makes a blend without one: its steps sit where a straight spine puts them, object.blend.info reports hasSpine: false, easing is refused and dimmed, Reverse Spine and the spine point commands refuse it, Release gives back no spine path, and Replace Spine gives it a spine. A blend taking more keys keeps whether it has a spine. Native files save noSpine; older files open with their spines. Builds on #5177 by adambelis. --- crates/doc/src/blend.rs | 30 ++- crates/doc/src/live.rs | 2 +- crates/doc/src/live/blend_spec.rs | 63 +++++++ crates/engine/src/cmd/live/blend.rs | 63 ++++++- crates/engine/src/cmd/live/commands.rs | 23 ++- crates/engine/src/cmd/plugin_call/reviewed.rs | 1 + crates/engine/src/tests/blendopts.rs | 176 ++++++++++++++++++ crates/engine/tests/command_sweep.rs | 2 + crates/ui-egui/src/i18n/bg.tsv | 4 + crates/ui-egui/src/i18n/bs.tsv | 4 + crates/ui-egui/src/i18n/ca.tsv | 4 + crates/ui-egui/src/i18n/cs.tsv | 4 + crates/ui-egui/src/i18n/da.tsv | 4 + crates/ui-egui/src/i18n/de.tsv | 4 + crates/ui-egui/src/i18n/el.tsv | 4 + crates/ui-egui/src/i18n/es.tsv | 4 + crates/ui-egui/src/i18n/fi.tsv | 4 + crates/ui-egui/src/i18n/fr.tsv | 4 + crates/ui-egui/src/i18n/hi.tsv | 4 + crates/ui-egui/src/i18n/hu.tsv | 4 + crates/ui-egui/src/i18n/id.tsv | 4 + crates/ui-egui/src/i18n/it.tsv | 4 + crates/ui-egui/src/i18n/ja.tsv | 4 + crates/ui-egui/src/i18n/ka.tsv | 4 + crates/ui-egui/src/i18n/ko.tsv | 4 + crates/ui-egui/src/i18n/nl.tsv | 4 + crates/ui-egui/src/i18n/pl.tsv | 4 + crates/ui-egui/src/i18n/pt-br.tsv | 4 + crates/ui-egui/src/i18n/ru.tsv | 4 + crates/ui-egui/src/i18n/sv.tsv | 4 + crates/ui-egui/src/i18n/tr.tsv | 4 + crates/ui-egui/src/i18n/uk.tsv | 4 + crates/ui-egui/src/i18n/vi.tsv | 4 + crates/ui-egui/src/i18n/zh-hans.tsv | 4 + crates/ui-egui/src/i18n/zh-hant.tsv | 4 + crates/ui-egui/src/panels/blend.rs | 58 ++++-- crates/ui-egui/src/panels/tests_blend.rs | 60 +++++- docs/mcp.md | 22 ++- docs/target-app-parity.md | 1 + 39 files changed, 569 insertions(+), 40 deletions(-) diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index d6b7eda2c5..05968ced7e 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -1052,11 +1052,27 @@ fn pinned(spec: &BlendSpec, keys: usize, n: usize) -> Option> { /// its handles) moved onto the key's centre, so moving a key moves its end of the spine; /// - a spine from Replace Spine: its first subpath, `None` for the anchors (the keys spread /// evenly along it by arc length). +/// +/// `None` for a blend made without a spine ([`BlendSpec::no_spine`]). pub fn blend_spine(keys: &[Arc], spec: &BlendSpec) -> Option<(PathData, Option>)> { + if spec.no_spine { + return None; + } + layout_spine(keys, spec) +} + +/// The spine `spec` stores, unless the blend was made without one. +fn stored_spine(spec: &BlendSpec) -> Option<&PathData> { + spec.spine.as_ref().filter(|_| !spec.no_spine) +} + +/// [`blend_spine`] for placing the steps: a blend made without a spine still places them on the +/// straight lines between its key centers. +fn layout_spine(keys: &[Arc], spec: &BlendSpec) -> Option<(PathData, Option>)> { if keys.len() < 2 { return None; } - let Some(stored) = &spec.spine else { + let Some(stored) = stored_spine(spec) else { let centers: Vec = keys.iter().map(|k| center_of(k)).collect(); return Some((PathData::single(SubPath::polyline(¢ers, false)), Some((0..keys.len()).collect()))); }; @@ -1127,10 +1143,10 @@ impl Rail { /// `None` when nothing moves: no stored spine and the steps keep the page's orientation. fn new(keys: &[Arc], spec: &BlendSpec) -> Option { let rotate = spec.orientation == BlendOrientation::AlignToPath; - if spec.spine.is_none() && !rotate { + if stored_spine(spec).is_none() && !rotate { return None; } - let (path, anchors) = blend_spine(keys, spec)?; + let (path, anchors) = layout_spine(keys, spec)?; let spine = Spine::new(&path)?; let total = spine.length(); let k = keys.len(); @@ -1192,10 +1208,10 @@ impl Rail { /// spine it is moved to, and so does its own centre, which it turns about. `None` when nothing /// moves. pub(crate) fn rail_reach(keys: &[Arc], spec: &BlendSpec, symbols: Option<&crate::SymbolBoxes>) -> Option { - if spec.spine.is_none() && spec.orientation != BlendOrientation::AlignToPath { + if stored_spine(spec).is_none() && spec.orientation != BlendOrientation::AlignToPath { return None; } - let spine = blend_spine(keys, spec)?.0.bounds()?; + let spine = layout_spine(keys, spec)?.0.bounds()?; let radius = keys .iter() .filter_map(|k| { @@ -1404,7 +1420,9 @@ pub fn blend_expand(keys: &[Arc], spec: &BlendSpec) -> Vec { for j in 1..=n { // Even position of the step, then where the easing puts its shape and its colours. let u = j as f64 / (n + 1) as f64; - let (t, tc) = (if rail.is_some() { spec.ease.apply(u) } else { u }, spec.color_position(u)); + // Easing needs a spine: a blend made without one keeps its steps evenly spaced. + let eased = rail.is_some() && !spec.no_spine; + let (t, tc) = (if eased { spec.ease.apply(u) } else { u }, spec.color_position(u)); let mut s = lerp.at_eased(t, tc); if let Some(r) = &rail { r.place(&mut s, i, t); diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index 304c4e70f6..7233f88a50 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -49,7 +49,7 @@ mod expand; mod mesh; mod warp; -pub use blend_spec::{BlendDefaults, BlendEase, BlendEaseType, BlendOrientation, BlendSpacing, BlendSpec}; +pub use blend_spec::{BlendDefaults, BlendEase, BlendEaseType, BlendOrientation, BlendSpacing, BlendSpec, BlendSpineMode}; #[cfg(test)] use blend_spec::{blend_color_shift, blend_ramp}; pub use envelope::{ diff --git a/crates/doc/src/live/blend_spec.rs b/crates/doc/src/live/blend_spec.rs index 75df5eee1f..56405e0d18 100644 --- a/crates/doc/src/live/blend_spec.rs +++ b/crates/doc/src/live/blend_spec.rs @@ -107,6 +107,11 @@ pub struct BlendSpec { /// on anchor points); `None`: chosen so closed shapes don't twist. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub starts: Vec>, + /// The blend was made without a spine (Create Spine › Never): it has no spine to show, edit, + /// reverse or release, and no easing. Its steps still sit on the straight lines between the + /// key centers. Replace Spine gives it one. + #[serde(default, rename = "noSpine", skip_serializing_if = "is_false", deserialize_with = "lenient_bool")] + pub no_spine: bool, } impl BlendSpec { @@ -116,6 +121,41 @@ impl BlendSpec { } } +/// The Blend panel's Create Spine setting: whether a new blend gets a spine. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum BlendSpineMode { + /// The usual rule: every new blend gets a spine, the straight lines between its key centers. + #[default] + Auto, + /// Every new blend gets a spine, whatever its key objects are. + Always, + /// New blends get no spine. + Never, +} + +impl BlendSpineMode { + /// Every mode, in the order the Blend panel lists them. + pub const ALL: [Self; 3] = [Self::Auto, Self::Always, Self::Never]; + + pub fn id(self) -> &'static str { + match self { + Self::Auto => "auto", + Self::Always => "always", + Self::Never => "never", + } + } + + pub fn from_id(id: &str) -> Option { + Self::ALL.into_iter().find(|m| m.id() == id) + } + + /// Whether a new blend made with this mode gets a spine. + pub fn creates_spine(self) -> bool { + self != Self::Never + } +} + /// Blend Options set with no blend selected: what new blends start with. #[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct BlendDefaults { @@ -123,6 +163,29 @@ pub struct BlendDefaults { pub spacing: BlendSpacing, #[serde(default)] pub orientation: BlendOrientation, + /// Create Spine (`object.blend.createSpine`). + #[serde(default, rename = "createSpine", skip_serializing_if = "is_auto", deserialize_with = "spine_mode")] + pub create_spine: BlendSpineMode, +} + +fn is_false(v: &bool) -> bool { + !*v +} + +fn is_auto(v: &BlendSpineMode) -> bool { + *v == BlendSpineMode::Auto +} + +/// Reading a native file never fails over a `noSpine` that is not a boolean: the blend keeps its +/// spine. +fn lenient_bool<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + Ok(serde_json::Value::deserialize(d)?.as_bool().unwrap_or(false)) +} + +/// Reading the preferences never fails over an unknown Create Spine value: it becomes Auto. +fn spine_mode<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + let v = serde_json::Value::deserialize(d)?; + Ok(v.as_str().and_then(BlendSpineMode::from_id).unwrap_or_default()) } /// Reading a native file never fails over one bad blend number: out-of-range values clamp, diff --git a/crates/engine/src/cmd/live/blend.rs b/crates/engine/src/cmd/live/blend.rs index 7d7c21227a..609936ecf7 100644 --- a/crates/engine/src/cmd/live/blend.rs +++ b/crates/engine/src/cmd/live/blend.rs @@ -1,5 +1,7 @@ use std::collections::BTreeSet; +use vectorcraft_doc::live::BlendSpineMode; + use super::common::{ DEFAULT_DISTANCE, DEFAULT_STEPS, ease_param, fix_ids, ids_json, is_blend, orientation_param, percent_param, roots_param, selected_of, spacing_param, starts_param, wrap, @@ -61,6 +63,19 @@ fn extend_spine(spec: &mut BlendSpec, old: &[Arc], old_spec: &BlendSpec, k spec.spine = Some(path); } +/// Does `p` set easing (`easingType` or `ramp`)? Easing needs a spine. +fn gives_easing(p: &Value) -> bool { + ["easingType", "ramp"].iter().any(|key| p.get(*key).is_some_and(|v| !v.is_null())) +} + +/// The Create Spine mode `p` gives as `key`, or `current` when it is omitted or null. +fn spine_mode_param(p: &Value, cmd: &str, key: &str, current: BlendSpineMode) -> Result { + match p.get(key).filter(|v| !v.is_null()) { + None => Ok(current), + Some(v) => v.as_str().and_then(BlendSpineMode::from_id).ok_or_else(|| bad(cmd, format!("`{key}` must be one of auto, always, never"))), + } +} + pub(super) fn blend_make(s: &mut Session, p: &Value) -> Result { const C: &str = "object.blend.make"; let roots = roots_param(s, p)?; @@ -69,16 +84,22 @@ pub(super) fn blend_make(s: &mut Session, p: &Value) -> Result { } let starts = starts_param(p, &roots)?; let base = first_blend(&s.doc()?.doc, &roots); + let defaults = s.prefs.blend_options.unwrap_or_default(); + // Create Spine applies to a new blend; one taking more keys keeps whether it has a spine. + let create_spine = spine_mode_param(p, C, "createSpine", defaults.create_spine)?; let cur = match base.as_ref().map(|n| &n.kind) { - Some(NodeKind::Blend { spec, .. }) => BlendDefaults { spacing: spec.spacing, orientation: spec.orientation }, - _ => s.prefs.blend_options.unwrap_or_default(), + Some(NodeKind::Blend { spec, .. }) => BlendDefaults { spacing: spec.spacing, orientation: spec.orientation, ..defaults }, + _ => defaults, }; let spacing = spacing_param(p, C, cur.spacing)?; let orientation = orientation_param(p, cur.orientation); - let (ease0, color0) = match base.as_ref().map(|n| &n.kind) { - Some(NodeKind::Blend { spec, .. }) => (spec.ease, spec.color_shift), - _ => Default::default(), + let (ease0, color0, no_spine) = match base.as_ref().map(|n| &n.kind) { + Some(NodeKind::Blend { spec, .. }) => (spec.ease, spec.color_shift, spec.no_spine), + _ => (Default::default(), 0.0, !create_spine.creates_spine()), }; + if no_spine && gives_easing(p) { + return Err(bad(C, "the blend has no spine")); + } let ease = ease_param(p, C, ease0)?; let color_shift = percent_param(p, C, "colorShift", color0, -100.0)?; let id = s.edit("Make Blend", |d, sel| { @@ -91,7 +112,7 @@ pub(super) fn blend_make(s: &mut Session, p: &Value) -> Result { n.knockout = vectorcraft_doc::Knockout::On; n }); - let mut spec = BlendSpec { spacing, orientation, ease, color_shift, starts, ..Default::default() }; + let mut spec = BlendSpec { spacing, orientation, ease, color_shift, starts, no_spine, ..Default::default() }; if let NodeKind::Blend { children: old, spec: old_spec } = &n.kind { extend_spine(&mut spec, old, old_spec, &children); } @@ -346,9 +367,12 @@ pub(super) fn blend_options(s: &mut Session, p: &Value) -> Result { // Nothing to set them on: the options new blends start with (easing is per blend). let d = s.prefs.blend_options.unwrap_or_default(); let spacing = spacing_param(p, C, d.spacing)?; - s.prefs.blend_options = Some(BlendDefaults { spacing, orientation: orientation_param(p, d.orientation) }); + s.prefs.blend_options = Some(BlendDefaults { spacing, orientation: orientation_param(p, d.orientation), ..d }); return Ok(json!({ "defaults": true })); }; + if gives_easing(p) && any_without_spine(s) { + return Err(bad(C, "the blend has no spine")); + } let spacing = spacing_param(p, C, cur.spacing)?; let orientation = orientation_param(p, cur.orientation); let ease = ease_param(p, C, cur.ease)?; @@ -384,6 +408,7 @@ pub(super) fn blend_info(s: &mut Session, _: &Value) -> Result { v["colorShift"] = json!(spec.color_shift); v["starts"] = json!((0..keys.len()).map(|i| spec.start(i)).collect::>()); v["keys"] = json!(keys.iter().map(|k| k.0).collect::>()); + v["hasSpine"] = json!(!spec.no_spine); if let Some(NodeKind::Blend { children, .. }) = s.doc()?.doc.node(id).map(|n| &n.kind) && let Some((path, anchors)) = live::blend_spine(children, &spec) && let Some(sp) = path.subpaths.first() @@ -403,6 +428,19 @@ pub(super) fn blend_info(s: &mut Session, _: &Value) -> Result { }) } +/// The Blend panel's Create Spine: with `mode`, sets whether new blends get a spine; without, reads +/// it. A tool setting kept with the preferences, not an undo step; blends already made keep theirs. +pub(super) fn blend_create_spine(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.blend.createSpine"; + let mut d = s.prefs.blend_options.unwrap_or_default(); + let mode = spine_mode_param(p, C, "mode", d.create_spine)?; + if mode != d.create_spine { + d.create_spine = mode; + s.prefs.blend_options = Some(d); + } + Ok(json!({ "mode": mode.id() })) +} + pub(super) fn blend_expand(s: &mut Session, _: &Value) -> Result { let blends = selected_of(s, is_blend); let ids = s.edit("Expand Blend", |d, sel| { @@ -507,6 +545,8 @@ pub(super) fn blend_replace_spine(s: &mut Session, _: &Value) -> Result { let spine = d.node(path).and_then(|n| n.path_data().cloned()).ok_or(EngineError::NoNode(path))?; d.remove(path)?; if let Some(NodeKind::Blend { children, spec }) = d.node_mut(b).map(|n| &mut n.kind) { + // A blend made without a spine gets this one. + spec.no_spine = false; spec.spine = Some(spine); spec.key_anchors.clear(); keys_to_spine(children, spec); @@ -517,7 +557,16 @@ pub(super) fn blend_replace_spine(s: &mut Session, _: &Value) -> Result { ok() } +/// Is any selected blend one made without a spine? +fn any_without_spine(s: &Session) -> bool { + let Ok(st) = s.doc() else { return false }; + selected_of(s, is_blend).iter().any(|b| matches!(st.doc.node(*b).map(|n| &n.kind), Some(NodeKind::Blend { spec, .. }) if spec.no_spine)) +} + pub(super) fn blend_reverse_spine(s: &mut Session, _: &Value) -> Result { + if any_without_spine(s) { + return Err(bad("object.blend.reverseSpine", "the blend has no spine")); + } edit_blends(s, "Reverse Spine", |children, spec| { if spec.spine.is_some() && pin(children, spec) { // The keys keep their anchors, which now count from the other end. diff --git a/crates/engine/src/cmd/live/commands.rs b/crates/engine/src/cmd/live/commands.rs index b69b4e869d..5480f7ae58 100644 --- a/crates/engine/src/cmd/live/commands.rs +++ b/crates/engine/src/cmd/live/commands.rs @@ -1,6 +1,6 @@ use super::blend::{ - blend_expand, blend_info, blend_make, blend_options, blend_release, blend_replace_spine, blend_reverse_spine, blend_reverse_stack, - spine_add_anchor, spine_convert_anchor, spine_move_anchor, spine_remove_anchor, spine_reshape_segment, + blend_create_spine, blend_expand, blend_info, blend_make, blend_options, blend_release, blend_replace_spine, blend_reverse_spine, + blend_reverse_stack, spine_add_anchor, spine_convert_anchor, spine_move_anchor, spine_remove_anchor, spine_reshape_segment, }; use super::common::{has_blend, has_envelope, has_mesh}; use super::envelope::{ @@ -18,7 +18,7 @@ pub fn specs() -> Vec { "Make", ["Object", "Blend"], Some("Cmd+Alt+B"), - "{ids?, steps?: n | distance?: pt | smooth?: bool, orientation?: page|path (default: the Blend Options set with nothing selected, else smooth colour and page), starts?: [anchor index | null, …] (per object of `ids`, else of the selection in paint order: the anchor of its first subpath the blend starts from; an open path's last anchor runs it the other way), spacing?: smooth|steps|distance (with value), value?: n, easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct (0..100), colorShift?: pct (-100..100; 0: even)} blend the selected objects (paint order) into a live blend; a blend among them takes the others in as more key objects (keeping its options, name and transparency) instead of nesting → {id}", + "{ids?, steps?: n | distance?: pt | smooth?: bool, orientation?: page|path (default: the Blend Options set with nothing selected, else smooth colour and page), starts?: [anchor index | null, …] (per object of `ids`, else of the selection in paint order: the anchor of its first subpath the blend starts from; an open path's last anchor runs it the other way), spacing?: smooth|steps|distance (with value), value?: n, easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct (0..100), colorShift?: pct (-100..100; 0: even), createSpine?: auto|always|never (default: the mode object.blend.createSpine sets; never: a blend without a spine, which takes no easing)} blend the selected objects (paint order) into a live blend; a blend among them takes the others in as more key objects (keeping its options, name, transparency and whether it has a spine) instead of nesting → {id}", has_doc, blend_make ), @@ -27,7 +27,7 @@ pub fn specs() -> Vec { "Release", ["Object", "Blend"], Some("Cmd+Alt+Shift+B"), - "{} release the selected blends: the key objects come back and the spine stays as a path with no fill or stroke, below them; a blend with a name, opacity, blend mode, isolation, opacity mask or appearance of its own comes back as a group keeping them (and its knockout) → {ids (the keys), spines: [ids], groups: [ids]}", + "{} release the selected blends: the key objects come back and the spine (if the blend has one) stays as a path with no fill or stroke, below them; a blend with a name, opacity, blend mode, isolation, opacity mask or appearance of its own comes back as a group keeping them (and its knockout) → {ids (the keys), spines: [ids], groups: [ids]}", has_blend, blend_release ), @@ -40,6 +40,15 @@ pub fn specs() -> Vec { always, blend_options ), + cmd!( + "object.blend.createSpine", + "Create Spine", + [], + None, + "{mode?: auto|always|never} the Blend panel's Create Spine: whether new blends get a spine (auto and always: the straight lines between the key centers, as before; never: no spine to show, edit, reverse or release, and no easing, until Replace Spine gives one); with no `mode`, reads it; blends already made keep theirs (a tool setting kept with the preferences, not an undo step) → {mode}", + always, + blend_create_spine + ), cmd!( "object.blend.expand", "Expand", @@ -54,7 +63,7 @@ pub fn specs() -> Vec { "Replace Spine", ["Object", "Blend"], None, - "{} use the selected path as the spine of the selected blend (the path is consumed)", + "{} use the selected path as the spine of the selected blend (the path is consumed); a blend made without a spine gets this one", has_blend, blend_replace_spine ), @@ -63,7 +72,7 @@ pub fn specs() -> Vec { "Reverse Spine", ["Object", "Blend"], None, - "{} reverse the order of the key objects along the spine", + "{} reverse the order of the key objects along the spine; an error when a selected blend has no spine", has_blend, blend_reverse_spine ), @@ -72,7 +81,7 @@ pub fn specs() -> Vec { "Blend Info", [], None, - "{} the options of the first selected blend, else those new blends start with → {target: blend|defaults, id?, spacing: smooth|steps|distance, steps, distance, orientation: page|path, easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct, colorShift?: pct, keys?: [ids], starts?: [anchor|null], spine?: {anchors: [{x, y, in: [x, y], out: [x, y]}], closed, keyAnchors: [anchor each key sits on] | null (keys spread evenly: a spine from Replace Spine), explicit: false for the straight lines between the key centres}}", + "{} the options of the first selected blend, else those new blends start with → {target: blend|defaults, id?, spacing: smooth|steps|distance, steps, distance, orientation: page|path, easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct, colorShift?: pct, keys?: [ids], starts?: [anchor|null], hasSpine?: false for a blend made without a spine (no `spine` then), spine?: {anchors: [{x, y, in: [x, y], out: [x, y]}], closed, keyAnchors: [anchor each key sits on] | null (keys spread evenly: a spine from Replace Spine), explicit: false for the straight lines between the key centres}}", always, blend_info ), diff --git a/crates/engine/src/cmd/plugin_call/reviewed.rs b/crates/engine/src/cmd/plugin_call/reviewed.rs index 51470e742d..5c03317fa9 100644 --- a/crates/engine/src/cmd/plugin_call/reviewed.rs +++ b/crates/engine/src/cmd/plugin_call/reviewed.rs @@ -676,6 +676,7 @@ pub(super) const DENIED: &[&str] = &[ "addon.pen.nextSegment", "effect.refreshDeferred", "graphicStyle.setOptions", + "object.blend.createSpine", "object.blend.options", "object.liquify", "object.pathfinder.options", diff --git a/crates/engine/src/tests/blendopts.rs b/crates/engine/src/tests/blendopts.rs index 059357ddb3..4b391c5714 100644 --- a/crates/engine/src/tests/blendopts.rs +++ b/crates/engine/src/tests/blendopts.rs @@ -418,3 +418,179 @@ fn native_blend_easing_values_clamp_and_non_finite_become_default() { assert_eq!((spec.ease.ramp, spec.color_shift), (ramp, shift), "{bad}"); } } + +fn create_spine(s: &mut Session, p: Value) -> Value { + s.execute("object.blend.createSpine", &p).unwrap()["mode"].clone() +} + +/// Two squares 100 pt apart blended with `p`; the blend. +fn make(s: &mut Session, y: f64, p: Value) -> NodeId { + let (a, b) = (rect(s, 0.0, y, 10.0), rect(s, 100.0, y, 10.0)); + let mut p = p; + p["ids"] = json!([a.0, b.0]); + p["steps"] = json!(3); + id_of(&s.execute("object.blend.make", &p).unwrap()) +} + +#[test] +fn create_spine_sets_whether_new_blends_get_a_spine() { + let mut s = session(); + assert_eq!(create_spine(&mut s, json!({})), "auto", "Auto until one is chosen"); + let spined = make(&mut s, 0.0, json!({})); + assert!(!spec(&s, spined).1.no_spine); + let depth = s.doc().unwrap().history.undo.len(); + assert_eq!(create_spine(&mut s, json!({"mode": "never"})), "never"); + assert_eq!(s.doc().unwrap().history.undo.len(), depth, "a tool setting, not an undo step"); + assert_eq!(create_spine(&mut s, json!({})), "never"); + // The Blend Options set with nothing selected keep it, and it keeps them. + s.execute("select.none", &json!({})).unwrap(); + s.execute("object.blend.options", &json!({"steps": 7})).unwrap(); + assert_eq!(create_spine(&mut s, json!({})), "never"); + assert_eq!(s.prefs.blend_options.unwrap().spacing, BlendSpacing::Steps(7)); + // A new blend has no spine; the blend made before keeps its own. + let bare = make(&mut s, 50.0, json!({})); + assert!(spec(&s, bare).1.no_spine); + assert!(!spec(&s, spined).1.no_spine, "blends already made keep their spine"); + sel(&mut s, &[bare]); + let info = s.execute("object.blend.info", &json!({})).unwrap(); + assert_eq!(info["hasSpine"], json!(false)); + assert!(info.get("spine").is_none(), "{info}"); + sel(&mut s, &[spined]); + assert_eq!(s.execute("object.blend.info", &json!({})).unwrap()["hasSpine"], json!(true)); + // `createSpine` on Make chooses for that blend only. + let forced = make(&mut s, 100.0, json!({"createSpine": "always"})); + assert!(!spec(&s, forced).1.no_spine); + assert_eq!(create_spine(&mut s, json!({})), "never"); + assert_eq!(create_spine(&mut s, json!({"mode": "always"})), "always"); + let always = make(&mut s, 150.0, json!({})); + assert!(!spec(&s, always).1.no_spine); + assert!(spec(&s, bare).1.no_spine, "blends already made keep having no spine"); + let none = make(&mut s, 200.0, json!({"createSpine": "never"})); + assert!(spec(&s, none).1.no_spine); + // The setting survives a round trip through the preferences file; Auto is not written. + let back: Prefs = serde_json::from_value(s.prefs.to_json()).unwrap(); + assert_eq!(back.blend_options.unwrap().create_spine, vectorcraft_doc::live::BlendSpineMode::Always); + assert_eq!(s.prefs.to_json()["blendOptions"]["createSpine"], json!("always")); + create_spine(&mut s, json!({"mode": "auto"})); + assert!(s.prefs.to_json()["blendOptions"].get("createSpine").is_none()); + let mut odd = s.prefs.to_json(); + odd["blendOptions"]["createSpine"] = json!("sometimes"); + let unknown: Prefs = serde_json::from_value(odd).unwrap(); + assert_eq!(unknown.blend_options.unwrap().create_spine, vectorcraft_doc::live::BlendSpineMode::Auto, "an unknown value reads as Auto"); +} + +#[test] +fn create_spine_refuses_bad_values_and_changes_nothing() { + let mut s = session(); + create_spine(&mut s, json!({"mode": "never"})); + let prefs = s.prefs.blend_options; + for bad in [json!({"mode": "sometimes"}), json!({"mode": 1}), json!({"mode": true}), json!({"mode": ["never"]}), json!({"mode": "Never"})] { + let e = s.execute("object.blend.createSpine", &bad).unwrap_err().to_string(); + assert!(e.contains("`mode` must be one of auto, always, never"), "{bad}: {e}"); + assert_eq!(s.prefs.blend_options, prefs, "{bad}"); + } + let (a, b) = (rect(&mut s, 0.0, 0.0, 10.0), rect(&mut s, 100.0, 0.0, 10.0)); + let depth = s.doc().unwrap().history.undo.len(); + for bad in [json!({"createSpine": "x"}), json!({"createSpine": 0}), json!({"createSpine": "never", "easingType": "easeIn"}), json!({"ramp": 30})] + { + let mut p = bad.clone(); + p["ids"] = json!([a.0, b.0]); + assert!(s.execute("object.blend.make", &p).is_err(), "{bad}"); + assert_eq!(s.doc().unwrap().history.undo.len(), depth, "{bad}: nothing made"); + } + assert!(s.execute("object.blend.createSpine", &json!({"spine": "never"})).is_err(), "an unknown key"); +} + +#[test] +fn a_blend_without_a_spine_has_none_to_edit_reverse_ease_or_release() { + let mut s = session(); + let g = make(&mut s, 0.0, json!({"createSpine": "never"})); + sel(&mut s, &[g]); + let before = node(&s, g); + let depth = s.doc().unwrap().history.undo.len(); + // Reverse Spine, easing and the spine point commands refuse it and change nothing. + let refused = [ + ("object.blend.reverseSpine", json!({})), + ("object.blend.options", json!({"easingType": "easeOut"})), + ("object.blend.options", json!({"ramp": 40})), + ("object.blend.spine.addAnchor", json!({"x": 50, "y": 5})), + ("object.blend.spine.moveAnchor", json!({"anchor": 0, "x": 0, "y": 50})), + ]; + for (cmd, p) in refused { + let e = s.execute(cmd, &p).unwrap_err().to_string(); + assert!(e.contains("the blend has no spine"), "{cmd}: {e}"); + assert_eq!((node(&s, g), s.doc().unwrap().history.undo.len()), (before.clone(), depth), "{cmd}"); + } + // Spacing, Orientation and Shift still apply; Align to Path makes no spine. + s.execute("object.blend.options", &json!({"steps": 2, "orientation": "path", "colorShift": 20})).unwrap(); + let sp = spec(&s, g).1; + assert_eq!((sp.spacing, sp.orientation, sp.color_shift, sp.no_spine), (BlendSpacing::Steps(2), BlendOrientation::AlignToPath, 20.0, true)); + assert!(s.execute("object.blend.info", &json!({})).unwrap().get("spine").is_none()); + // Taking in another key keeps it without a spine, whatever Create Spine says. + let c = rect(&mut s, 200.0, 0.0, 10.0); + sel(&mut s, &[g, c]); + create_spine(&mut s, json!({"mode": "always"})); + let g = id_of(&s.execute("object.blend.make", &json!({})).unwrap()); + assert_eq!(spec(&s, g).0.len(), 3); + assert!(spec(&s, g).1.no_spine && spec(&s, g).1.spine.is_none()); + // Release gives back the keys and no spine path. + sel(&mut s, &[g]); + let r = s.execute("object.blend.release", &json!({})).unwrap(); + assert_eq!((r["ids"].as_array().map(Vec::len), &r["spines"]), (Some(3), &json!([]))); + s.execute("edit.undo", &json!({})).unwrap(); + // Replace Spine gives it one: then it reverses and eases like any other. + let line = id_of(&s.execute("path.create", &json!({"d": "M0 100 L200 150"})).unwrap()); + sel(&mut s, &[g, line]); + s.execute("object.blend.replaceSpine", &json!({})).unwrap(); + let sp = spec(&s, g).1; + assert!(!sp.no_spine && sp.spine.is_some()); + let info = s.execute("object.blend.info", &json!({})).unwrap(); + assert_eq!((&info["hasSpine"], &info["spine"]["explicit"]), (&json!(true), &json!(true))); + s.execute("object.blend.reverseSpine", &json!({})).unwrap(); + s.execute("object.blend.options", &json!({"easingType": "easeOut", "ramp": 40})).unwrap(); + assert_eq!(spec(&s, g).1.ease.ramp, 0.4); +} + +#[test] +fn a_blend_without_a_spine_draws_its_steps_where_one_with_a_spine_does() { + let mut s = session(); + for orientation in ["page", "path"] { + let spined = make(&mut s, 0.0, json!({"orientation": orientation})); + let bare = make(&mut s, 0.0, json!({"orientation": orientation, "createSpine": "never"})); + let boxes = |g: NodeId| -> Vec<_> { vectorcraft_doc::live::expand_live(&node(&s, g)).iter().map(|n| n.geometric_bounds()).collect() }; + assert_eq!(boxes(bare), boxes(spined), "{orientation}"); + assert!(boxes(bare).len() >= 3, "the three steps at least"); + } + // Easing stored on a blend without a spine (a hand-edited file) leaves its steps evenly spaced. + let bare = make(&mut s, 300.0, json!({"orientation": "path", "createSpine": "never"})); + let even = vectorcraft_doc::live::expand_live(&node(&s, bare)); + let mut eased = node(&s, bare); + if let NodeKind::Blend { spec, .. } = &mut eased.kind { + spec.ease = vectorcraft_doc::live::BlendEase { kind: vectorcraft_doc::live::BlendEaseType::In, ramp: 1.0 }; + } + let boxes = |v: &[Node]| -> Vec<_> { v.iter().map(|n| n.geometric_bounds()).collect() }; + assert_eq!(boxes(&vectorcraft_doc::live::expand_live(&eased)), boxes(&even)); +} + +#[test] +fn whether_a_blend_has_a_spine_is_saved_and_old_blends_have_one() { + let mut s = session(); + let bare = make(&mut s, 0.0, json!({"createSpine": "never"})); + let spined = make(&mut s, 50.0, json!({})); + let d = &s.doc().unwrap().doc; + let back = vectorcraft_format::load(&vectorcraft_format::save(d, false)).unwrap(); + assert_eq!(&back.layers, &d.layers); + let saved = |g: NodeId| match &back.node(g).unwrap().kind { + NodeKind::Blend { spec, .. } => spec.no_spine, + _ => panic!("not a blend"), + }; + assert!(saved(bare) && !saved(spined)); + assert_eq!(serde_json::to_value(spec(&s, bare).1).unwrap()["noSpine"], json!(true)); + assert!(serde_json::to_value(spec(&s, spined).1).unwrap().get("noSpine").is_none(), "a spine is not written"); + let old: BlendSpec = serde_json::from_value(json!({"spacing": {"steps": 4}})).unwrap(); + assert!(!old.no_spine, "a blend saved before Create Spine has a spine"); + for odd in [json!("yes"), json!(1), json!(null)] { + let v: BlendSpec = serde_json::from_value(json!({"noSpine": odd.clone()})).unwrap_or_else(|e| panic!("{odd}: {e}")); + assert!(!v.no_spine, "{odd}"); + } +} diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index 947cf559d0..b3779d8108 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -674,6 +674,8 @@ fn structured_junk() { ("object.blend.options", json!({"spacing": "distance", "value": -1e308, "orientation": 5})), ("object.blend.options", json!({"ramp": 1e308, "colorShift": -1e308, "easingType": [1]})), ("object.blend.make", json!({"ids": [1, 2], "ramp": f64::NAN, "colorShift": [1]})), + ("object.blend.make", json!({"ids": [1, 2], "createSpine": ["never"], "easingType": "easeIn"})), + ("object.blend.createSpine", json!({"mode": 1e308})), ("object.blend.spine.moveAnchor", json!({"id": 2, "anchor": u64::MAX, "x": 1e308, "y": -1e308, "handle": "sideways"})), ("object.blend.spine.addAnchor", json!({"id": u64::MAX, "x": 0, "y": 0})), ("object.blend.spine.reshapeSegment", json!({"id": 2, "segment": u64::MAX, "t": f64::NAN, "dx": 1e308, "dy": -1e308})), diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index bc67d8fd87..1482ab04c5 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2741,6 +2741,10 @@ digits Malayalam Малаялам Replace Font Замяна на шрифт Replace it with another file, or ignore it: its images keep showing the preview saved with the document. Заменете го с друг файл или го игнорирайте: неговите изображения ще продължат да показват записания с документа преглед. Replace Spine Замяна на оста + Create Spine Създаване на ос + Create Spine: Създаване на ос: + Always Винаги + Never Никога Replace Symbol Замяна на символ Reset Transformation Нулиране на трансформацията Replace With Font Замяна с шрифт diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 6454cffcc9..92366ca639 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -255,6 +255,10 @@ Blend Options… Opcije stapanja… Expand Proširi Replace Spine Zamijeni kičmu + Create Spine Kreiraj kičmu + Create Spine: Kreiraj kičmu: + Always Uvijek + Never Nikad Reverse Spine Obrni kičmu Reverse Front to Back Obrni od naprijed prema nazad Envelope Distort Izobličenje omotnicom diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 1567e0e8b5..fec6d9b52f 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -3489,6 +3489,10 @@ digits Malayalam Malaiàlam Replace Font Reemplaça el tipus de lletra Replace it with another file, or ignore it: its images keep showing the preview saved with the document. Reemplaça’l per un altre fitxer o ignora’l: les seves imatges continuaran mostrant la previsualització desada amb el document. Replace Spine Reemplaça l’espina + Create Spine Crea l’espina + Create Spine: Crea l’espina: + Always Sempre + Never Mai Replace Symbol Reemplaça el símbol Reset Transformation Restableix la transformació Replace With Font Reemplaça per un tipus de lletra diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index e9df0812f2..289b225a30 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -261,6 +261,10 @@ Blend Options… Možnosti prolnutí… Expand Rozložit Replace Spine Nahradit páteř + Create Spine Vytvořit páteř + Create Spine: Vytvořit páteř: + Always Vždy + Never Nikdy Reverse Spine Obrátit páteř Reverse Front to Back Obrátit pořadí zepředu dozadu Envelope Distort Deformace obálkou diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index e259ecba84..70fa791796 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -247,6 +247,10 @@ Blend Options… Blandingsindstillinger… Expand Udvid Replace Spine Erstat rygrad + Create Spine Opret rygrad + Create Spine: Opret rygrad: + Always Altid + Never Aldrig Reverse Spine Omvend rygrad Reverse Front to Back Omvend fortil og bagtil Envelope Distort Kuvertforvrængning diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index abfaaa13a7..3479ec4d4f 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2773,6 +2773,10 @@ digits Malayalam Malayalam Replace Font Schrift ersetzen Replace it with another file, or ignore it: its images keep showing the preview saved with the document. Ersetzen Sie sie durch eine andere Datei oder ignorieren Sie sie: Ihre Bilder zeigen weiterhin die mit dem Dokument gespeicherte Vorschau. Replace Spine Achse ersetzen + Create Spine Achse erstellen + Create Spine: Achse erstellen: + Always Immer + Never Nie Replace Symbol Symbol ersetzen Reset Transformation Transformation zurücksetzen Replace With Font Durch Schrift ersetzen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 4611904ff0..9de4167383 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3426,6 +3426,10 @@ digits Malayalam Μαλαγιάλαμ Replace Font Αντικατάσταση γραμματοσειράς Replace it with another file, or ignore it: its images keep showing the preview saved with the document. Αντικαταστήστε το με άλλο αρχείο ή αγνοήστε το: οι εικόνες του θα συνεχίσουν να εμφανίζουν την προεπισκόπηση που αποθηκεύτηκε στο έγγραφο. Replace Spine Αντικατάσταση κεντρικής καμπύλης + Create Spine Δημιουργία κεντρικής καμπύλης + Create Spine: Δημιουργία κεντρικής καμπύλης: + Always Πάντα + Never Ποτέ Replace Symbol Αντικατάσταση συμβόλου Reset Transformation Επαναφορά μετασχηματισμού Replace With Font Αντικατάσταση με γραμματοσειρά diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index b91d89b8c0..a9db37d416 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2815,6 +2815,10 @@ digits Malayalam Malayálam Replace Font Reemplazar fuente Replace it with another file, or ignore it: its images keep showing the preview saved with the document. Reemplázalo con otro archivo o ignóralo: sus imágenes seguirán mostrando la vista previa guardada con el documento. Replace Spine Reemplazar eje + Create Spine Crear eje + Create Spine: Crear eje: + Always Siempre + Never Nunca Replace Symbol Reemplazar símbolo Reset Transformation Restablecer transformación Replace With Font Reemplazar con fuente diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index e296459895..84c49e161f 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -268,6 +268,10 @@ Blend Options… Sulautuksen asetukset… Expand Laajenna Replace Spine Korvaa selkäranka + Create Spine Luo selkäranka + Create Spine: Luo selkäranka: + Always Aina + Never Ei koskaan Reverse Spine Käännä selkäranka Reverse Front to Back Käännä edestä taakse Envelope Distort Vääristyskuori diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index ff206b9317..f701ecb2c3 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2807,6 +2807,10 @@ digits Malayalam Malayalam Replace Font Remplacer la police Replace it with another file, or ignore it: its images keep showing the preview saved with the document. Remplacez-le par un autre fichier ou ignorez-le : ses images continueront d’afficher l’aperçu enregistré avec le document. Replace Spine Remplacer la dorsale + Create Spine Créer une dorsale + Create Spine: Créer une dorsale : + Always Toujours + Never Jamais Replace Symbol Remplacer le symbole Reset Transformation Réinitialiser la transformation Replace With Font Remplacer par la police diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 6698f10a4b..20a6eb3bd8 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -3805,6 +3805,10 @@ antiAlias Sharp शार्प Replace Missing Fonts अनुपलब्ध फ़ॉन्ट रिप्लेस करें Replace Missing Fonts… अनुपलब्ध फ़ॉन्ट रिप्लेस करें… Replace Spine स्पाइन रिप्लेस करें + Create Spine स्पाइन बनाएँ + Create Spine: स्पाइन बनाएँ: + Always हमेशा + Never कभी नहीं Replace Symbol सिंबल रिप्लेस करें Reset Transformation रूपांतरण रीसेट करें Replace With Font इस फ़ॉन्ट से रिप्लेस करें diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index a8d02ca4c4..3873afd01b 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3355,6 +3355,10 @@ mark-style Japanese Japán Replace Font Betűtípus cseréje Replace it with another file, or ignore it: its images keep showing the preview saved with the document. Cseréld le egy másik fájlra, vagy hagyd ki: a képei a dokumentummal mentett előnézetet mutatják tovább. Replace Spine Gerinc cseréje + Create Spine Gerinc létrehozása + Create Spine: Gerinc létrehozása: + Always Mindig + Never Soha Replace Symbol Szimbólum cseréje Reset Transformation Átalakítás visszaállítása Replace With Font Helyettesítő betűtípus diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 04581cd727..81e4472fc7 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -247,6 +247,10 @@ Blend Options… Opsi blend… Expand Perluas Replace Spine Ganti spine + Create Spine Buat spine + Create Spine: Buat spine: + Always Selalu + Never Tidak pernah Reverse Spine Balikkan spine Reverse Front to Back Balikkan depan ke belakang Envelope Distort Distorsi amplop diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 82827964fe..011017b892 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2849,6 +2849,10 @@ digits Malayalam Malayalam Replace Font Sostituisci font Replace it with another file, or ignore it: its images keep showing the preview saved with the document. Sostituiscilo con un altro file oppure ignoralo: le sue immagini continueranno a mostrare l'anteprima salvata con il documento. Replace Spine Sostituisci dorsale + Create Spine Crea dorsale + Create Spine: Crea dorsale: + Always Sempre + Never Mai Replace Symbol Sostituisci simbolo Reset Transformation Reimposta trasformazione Replace With Font Sostituisci con font diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 42a4aa3cf2..84eaeb217b 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -289,6 +289,10 @@ digits Malayalam マラヤーラム数字 Blend Options… ブレンドオプション… Expand 拡張 Replace Spine スパインを置き換え + Create Spine スパインを作成 + Create Spine: スパインを作成: + Always 常に + Never しない Reverse Spine スパインを反転 Reverse Front to Back 前後を反転 Envelope Distort エンベロープ diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 30b0abd6bf..2adf814857 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -219,6 +219,10 @@ Blend Options… გადასვლის პარამეტრები… Expand გაშლა Replace Spine ხერხემლის ჩანაცვლება + Create Spine ხერხემლის შექმნა + Create Spine: ხერხემლის შექმნა: + Always ყოველთვის + Never არასოდეს Reverse Spine ხერხემლის შებრუნება Reverse Front to Back რიგის შებრუნება წინიდან უკან Envelope Distort გარსით დეფორმაცია diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 3bc8360b49..bb88da4286 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -282,6 +282,10 @@ digits Malayalam 말라얄람어 Blend Options… 블렌드 옵션… Expand 확장 Replace Spine 스파인 바꾸기 + Create Spine 스파인 만들기 + Create Spine: 스파인 만들기: + Always 항상 + Never 안 함 Reverse Spine 스파인 반전 Reverse Front to Back 앞뒤 반전 Envelope Distort 엔벨로프 왜곡 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index c6f37b432a..f3469d99b3 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -261,6 +261,10 @@ Blend Options… Vermengopties… Expand Uitbreiden Replace Spine Rug vervangen + Create Spine Rug maken + Create Spine: Rug maken: + Always Altijd + Never Nooit Reverse Spine Rug omkeren Reverse Front to Back Van voor naar achter omkeren Envelope Distort Envelopvervorming diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 3647b56f54..86bc805fd9 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -225,6 +225,10 @@ Blend Options… Opcje przejścia… Expand Rozszerz Replace Spine Zastąp grzbiet + Create Spine Utwórz grzbiet + Create Spine: Utwórz grzbiet: + Always Zawsze + Never Nigdy Reverse Spine Odwróć grzbiet Reverse Front to Back Odwróć kolejność od przodu do tyłu Envelope Distort Zniekształcenie kopertowe diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 530a62719a..12519f2457 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2351,6 +2351,10 @@ digits Malayalam Malaiala Replace: Substituir: Replace Font Substituir Fonte Replace Spine Substituir Coluna + Create Spine Criar Coluna + Create Spine: Criar Coluna: + Always Sempre + Never Nunca Replace Symbol Substituir Símbolo Reset Transformation Redefinir Transformação Replace With Font Substituir Com Fonte diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 341dc196f4..40c2d374db 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2844,6 +2844,10 @@ digits Malayalam Малаялам Replace Font Заменить шрифт Replace it with another file, or ignore it: its images keep showing the preview saved with the document. Замените его другим файлом или игнорируйте: его изображения продолжат показывать предварительный просмотр, сохранённый с документом. Replace Spine Заменить остов + Create Spine Создать остов + Create Spine: Создать остов: + Always Всегда + Never Никогда Replace Symbol Заменить символ Reset Transformation Сбросить трансформацию Replace With Font Заменить шрифтом diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index ed59d98113..52f1da61b0 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -2751,6 +2751,10 @@ digits Malayalam Malayalam Replace Font Ersätt typsnitt Replace it with another file, or ignore it: its images keep showing the preview saved with the document. Ersätt den med en annan fil eller ignorera den: dess bilder fortsätter att visa förhandsgranskningen som sparats med dokumentet. Replace Spine Ersätt ryggrad + Create Spine Skapa ryggrad + Create Spine: Skapa ryggrad: + Always Alltid + Never Aldrig Replace Symbol Ersätt symbol Reset Transformation Återställ transformering Replace With Font Ersätt med typsnitt diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index dc89f33802..50059e043f 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2620,6 +2620,10 @@ digits Malayalam Malayalam Replace Font Yazı Tipini Değiştir Replace it with another file, or ignore it: its images keep showing the preview saved with the document. Onu başka bir dosyayla değiştirin veya yok sayın: resimleri, belgeyle birlikte kaydedilen önizlemeyi göstermeye devam eder. Replace Spine Omurgayı Değiştir + Create Spine Omurga Oluştur + Create Spine: Omurga Oluştur: + Always Her Zaman + Never Asla Replace Symbol Sembolü Değiştir Reset Transformation Dönüşümü Sıfırla Replace With Font Yazı Tipiyle Değiştir diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 8d67846ecd..fe1ad0a93d 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2841,6 +2841,10 @@ digits Malayalam Малаялам Replace Font Замінити шрифт Replace it with another file, or ignore it: its images keep showing the preview saved with the document. Замініть його іншим файлом або ігноруйте: зображення продовжать показувати попередній перегляд, збережений з документом. Replace Spine Замінити вісь + Create Spine Створити вісь + Create Spine: Створити вісь: + Always Завжди + Never Ніколи Replace Symbol Замінити символ Reset Transformation Скинути трансформацію Replace With Font Замінити шрифтом diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 8f1ea434fd..0256d827a0 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3123,6 +3123,10 @@ mark-style Japanese Kiểu Nhật Replace Font Thay thế phông chữ Replace it with another file, or ignore it: its images keep showing the preview saved with the document. Thay thế bằng tệp khác hoặc bỏ qua: ảnh của nó vẫn hiển thị bản xem trước đã lưu cùng tài liệu. Replace Spine Thay thế trục + Create Spine Tạo trục + Create Spine: Tạo trục: + Always Luôn luôn + Never Không bao giờ Replace Symbol Thay thế ký hiệu Reset Transformation Đặt lại biến đổi Replace With Font Thay thế bằng phông chữ diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 05c6539bf7..ae78e18875 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2715,6 +2715,10 @@ digits Malayalam 马拉雅拉姆数字 Replace Font 替换字体 Replace it with another file, or ignore it: its images keep showing the preview saved with the document. 将其替换为其他文件,或忽略:其图像继续显示随文档保存的预览。 Replace Spine 替换脊线 + Create Spine 创建脊线 + Create Spine: 创建脊线: + Always 始终 + Never 从不 Replace Symbol 替换符号 Reset Transformation 重置变换 Replace With Font 替换为字体 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index cb8a61362e..57fad09f66 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3811,6 +3811,10 @@ digits Malayalam 馬來亞拉姆數字 Replace Font 取代字體 Replace it with another file, or ignore it: its images keep showing the preview saved with the document. 以其他檔案取代,或忽略:其影像會繼續顯示隨文件儲存的預覽。 Replace Spine 取代脊線 + Create Spine 建立脊線 + Create Spine: 建立脊線: + Always 一律 + Never 永不 Replace Symbol 取代符號 Reset Transformation 重設變形 Replace With Font 取代為字體 diff --git a/crates/ui-egui/src/panels/blend.rs b/crates/ui-egui/src/panels/blend.rs index e258cfbc09..a41c2946c7 100644 --- a/crates/ui-egui/src/panels/blend.rs +++ b/crates/ui-egui/src/panels/blend.rs @@ -1,13 +1,17 @@ -//! Blend panel (Window → Blend): Make, the Spacing and Orientation of the selected blend (with no -//! blend selected, the options new blends start with), Reverse Front to Back, Reverse Spine, -//! Replace Spine, Expand and Release. Each control runs its Object → Blend command -//! (`object.blend.make`, `object.blend.options` and the others), so a change of the selected -//! blend's spacing or orientation shows on the canvas at once as one undo step. Make is dimmed -//! until two or more objects are selected. Spacing and Orientation are dimmed until a blend or two -//! or more objects are selected, and the other buttons are dimmed until a blend is selected. +//! Blend panel (Window → Blend): Make, Create Spine (whether new blends get a spine), the Spacing +//! and Orientation of the selected blend (with no blend selected, the options new blends start +//! with), Reverse Front to Back, Reverse Spine, Replace Spine, Expand and Release. Each control +//! runs its Object → Blend command (`object.blend.make`, `object.blend.createSpine`, +//! `object.blend.options` and the others), so a change of the selected blend's spacing or +//! orientation shows on the canvas at once as one undo step. Make is dimmed until two or more +//! objects are selected. Create Spine is always available. Spacing and Orientation are dimmed until +//! a blend or two or more objects are selected, and the other buttons are dimmed until a blend is +//! selected. Easing and Reverse Spine are dimmed for a blend made without a spine. use egui::Ui; use serde_json::{Value, json}; +use vectorcraft_doc::NodeKind; +use vectorcraft_doc::live::BlendSpineMode; use super::selection_len; use crate::VectorcraftApp; @@ -38,10 +42,18 @@ fn buttons(app: &mut VectorcraftApp, ui: &mut Ui, row: &[(&str, &str)]) { }); } -/// Can `cmd` run on the selection? Make needs two or more objects, and the other commands need a -/// selected blend. +/// Can `cmd` run on the selection? Make needs two or more objects, the other commands need a +/// selected blend, and Reverse Spine needs every selected blend to have a spine. fn available(app: &VectorcraftApp, cmd: &str) -> bool { - crate::menus::enabled(app, cmd) && (cmd != "object.blend.make" || selection_len(app) >= 2) + crate::menus::enabled(app, cmd) + && (cmd != "object.blend.make" || selection_len(app) >= 2) + && (cmd != "object.blend.reverseSpine" || !selects_blend_without_spine(app)) +} + +/// Is a blend made without a spine selected? +fn selects_blend_without_spine(app: &VectorcraftApp) -> bool { + let Ok(st) = app.session.doc() else { return false }; + st.selection.objects.iter().any(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Blend { spec, .. }) if spec.no_spine)) } /// `object.blend.options` parameters for spacing mode `mode`, with the step count and distance @@ -95,8 +107,9 @@ fn settings(app: &mut VectorcraftApp, ui: &mut Ui) { } }); let selected = info["target"] == "blend"; - // Position easing needs a spine to run along: a replaced one, or Align to Path. - let spined = info["spine"]["explicit"] == true || info["orientation"] == "path"; + // Position easing needs a spine to run along: a replaced one, or Align to Path. A blend made + // without a spine has none. + let spined = info["hasSpine"] != false && (info["spine"]["explicit"] == true || info["orientation"] == "path"); ui.add_enabled_ui(selected && spined, |ui| { let modes = ["easeIn", "easeOut", "easeInOut", "easeOutIn"]; let labels = [tl!("Ease In"), tl!("Ease Out"), tl!("Ease In Out"), tl!("Ease Out In")]; @@ -129,9 +142,30 @@ fn settings(app: &mut VectorcraftApp, ui: &mut Ui) { } } +/// Create Spine: whether new blends get a spine. It shows the setting new blends take, whatever is +/// selected, and a change runs `object.blend.createSpine`; blends already made keep theirs. +fn create_spine(app: &mut VectorcraftApp, ui: &mut Ui) { + let current = app.session.prefs.blend_options.unwrap_or_default().create_spine; + let modes = BlendSpineMode::ALL; + let labels = [tl!("Auto"), tl!("Always"), tl!("Never")]; + let shown = modes.iter().position(|m| *m == current).and_then(|i| labels.get(i)).copied().unwrap_or(labels[0]); + let mut change = None; + widgets::label_row(ui, tl!("Create Spine:"), LABEL_W, |ui| { + if let Some(mode) = + widgets::dropdown(ui, "blend-panel-create-spine", shown, &labels, ui.available_width().max(FIELD_W)).and_then(|i| modes.get(i)) + { + change = Some(*mode); + } + }); + if let Some(mode) = change { + app.run("object.blend.createSpine", json!({ "mode": mode.id() })).ok(); + } +} + pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let blend = crate::menus::enabled(app, "object.blend.release"); buttons(app, ui, &[(tl!("Make"), "object.blend.make")]); + create_spine(app, ui); widgets::divider(ui); ui.add_enabled_ui(blend || selection_len(app) >= 2, |ui| settings(app, ui)); widgets::label_row(ui, tl!("Direction:"), LABEL_W, |ui| buttons(app, ui, &[(tl!("Reverse Front to Back"), "object.blend.reverseFrontToBack")])); diff --git a/crates/ui-egui/src/panels/tests_blend.rs b/crates/ui-egui/src/panels/tests_blend.rs index b79823237b..46521d3a65 100644 --- a/crates/ui-egui/src/panels/tests_blend.rs +++ b/crates/ui-egui/src/panels/tests_blend.rs @@ -1,8 +1,8 @@ //! The Blend panel drawn headless: Window › Blend opens it, and its controls do nothing until the //! selection allows them. Make needs two or more objects, Spacing and Orientation need a blend or -//! two or more objects, and the other buttons need a blend. Make blends the selection, Spacing and -//! Orientation edit the selected blend through `object.blend.options`, and each other button runs -//! its Object › Blend command. +//! two or more objects, and the other buttons need a blend. Make blends the selection, Create Spine +//! sets whether new blends get a spine, Spacing and Orientation edit the selected blend through +//! `object.blend.options`, and each other button runs its Object › Blend command. use egui::{Event, Pos2, Rect, pos2, vec2}; use serde_json::json; @@ -39,6 +39,11 @@ impl Frames { /// The center of the text `label` the panel draws. fn text(&mut self, app: &mut VectorcraftApp, label: &str) -> Pos2 { + self.find_text(app, label).unwrap_or_else(|texts| panic!("no {label:?} in {texts:?}")) + } + + /// The center of the text `label` the panel draws, or every text it draws. + fn find_text(&mut self, app: &mut VectorcraftApp, label: &str) -> Result> { self.run(app, vec![]); let out = self.run(app, vec![]); let mut texts = vec![]; @@ -50,7 +55,7 @@ impl Frames { } } out.shapes.iter().for_each(|c| collect(&c.shape, &mut texts)); - texts.iter().find(|(t, _)| t == label).unwrap_or_else(|| panic!("no {label:?} in {texts:?}")).1.center() + texts.iter().find(|(t, _)| t == label).map(|(_, r)| r.center()).ok_or(texts) } /// The center of the button that screen readers name `label`. @@ -124,7 +129,19 @@ fn window_blend_opens_the_panel() { crate::menus::invoke(&mut app, "window.panel", item.params.clone()); assert!(crate::panel_docking::showing(&app.ui, ID)); let text = crate::tests_labels::painted_text(&mut app, |app, ui| super::show_icon_panel(app, ui, ID)); - for label in ["Make", "Spacing:", "Orientation:", "Direction:", "Reverse Front to Back", "Reverse Spine", "Replace Spine", "Expand", "Release"] { + for label in [ + "Make", + "Create Spine:", + "Auto", + "Spacing:", + "Orientation:", + "Direction:", + "Reverse Front to Back", + "Reverse Spine", + "Replace Spine", + "Expand", + "Release", + ] { assert!(text.contains(label), "{label} in {text}"); } assert!(!text.contains("on the roadmap"), "{text}"); @@ -226,6 +243,39 @@ fn each_button_runs_its_blend_command() { assert!(objects.len() >= 2 && objects.iter().all(|(_, blend)| !blend), "the keys are back: {objects:?}"); } +#[test] +fn create_spine_sets_what_new_blends_get_whatever_is_selected() { + let (mut app, id) = blended(); + let (before, depth) = (spec(&app, id), undo_depth(&app)); + let mode = |app: &mut VectorcraftApp| app.session.execute("object.blend.createSpine", &json!({})).unwrap()["mode"].clone(); + let mut f = Frames::new(); + f.text(&mut app, "Create Spine:"); + f.click_text(&mut app, "Auto"); + f.click_text(&mut app, "Never"); + assert_eq!(mode(&mut app), "never"); + assert_eq!((spec(&app, id), undo_depth(&app)), (before, depth), "the selected blend and the undo history stay as they were"); + f.text(&mut app, "Never"); + // Nothing selected: the dropdown still shows and sets the setting. + app.run("select.none", json!({})).unwrap(); + f.click_text(&mut app, "Never"); + f.click_text(&mut app, "Always"); + assert_eq!(mode(&mut app), "always"); + f.click_text(&mut app, "Always"); + f.click_text(&mut app, "Never"); + // A blend made now has no spine: Reverse Spine and easing are dimmed for it. + app.run("edit.undo", json!({})).unwrap(); + app.run("select.all", json!({})).unwrap(); + f.click_text(&mut app, "Make"); + let (g, blend) = top(&app)[0]; + assert!(blend && spec(&app, g).no_spine, "{:?}", top(&app)); + let made = spec(&app, g); + let depth = undo_depth(&app); + f.click_text(&mut app, "Reverse Spine"); + f.click_text(&mut app, "Ease In"); + assert!(f.find_text(&mut app, "Ease Out In").is_err(), "the Type menu did not open"); + assert_eq!((spec(&app, g), undo_depth(&app), app.ui.status.as_str()), (made, depth, ""), "nothing ran"); +} + #[test] fn panel_easing_type_ramp_and_shift_edit_the_blend_and_undo() { // Type and Ramp need a spine: Align to Path gives the blend one. diff --git a/docs/mcp.md b/docs/mcp.md index 7f6df79837..227c25abc7 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -8465,7 +8465,8 @@ keys without bounds make 1 step. `starts` gives, per object, the anchor of its f from (as the Blend tool's clicks on anchor points do; an open path's last anchor runs it the other way). With the Blend tool, `press_key` Escape drops the object clicked first and stops the next click adding to the blend just made; the document and the selection stay as they are. A blend -among the objects takes the others in as more key objects, keeping its options, name and transparency: +among the objects takes the others in as more key objects, keeping its options, name, transparency and whether it +has a spine: ```json {"name":"run_command","arguments":{"command":"object.blend.make","params":{"ids":[12,15],"starts":[0,2],"steps":8}}} @@ -8473,6 +8474,23 @@ among the objects takes the others in as more key objects, keeping its options, {"name":"run_command","arguments":{"command":"object.blend.info","params":{}}} ``` +`object.blend.createSpine {mode?: "auto" | "always" | "never"}` is the Blend panel's Create Spine menu: whether new +blends get a spine (the [help page](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blend-panel-overview.html) +describes the three choices). Without `mode` it reads the setting; both return `{mode}`. Auto, the default, and Always +give every new blend a straight spine between the key centers, as before, whatever its key objects are. Never makes blends without a spine: their steps sit where a straight spine puts them, and +`object.blend.info` reports `hasSpine: false` and no `spine`. Such a blend has no easing (`easingType` and `ramp` are +refused, and the Blend panel dims Type and Ramp), Reverse Spine and the `object.blend.spine.*` commands refuse it +("the blend has no spine"), and Release gives back its keys with no spine path. Replace Spine gives it a spine. The +setting is kept with the preferences, is not an undo step and does not change blends already made, and +`object.blend.make {createSpine}` chooses for one new blend. Any other value is refused ("`mode` must be one of auto, +always, never") and changes nothing. A blend saved without a spine keeps `noSpine` in the native file; `.ai` and +EPS files hold its drawn steps, as they do for every blend: + +```json +{"name":"run_command","arguments":{"command":"object.blend.createSpine","params":{"mode":"never"}}} +{"name":"run_command","arguments":{"command":"object.blend.make","params":{"ids":[20,31],"createSpine":"always"}}} +``` + With the Blend tool active, `tool.select {tool: "blend"}` finishes the blend, as a click on the tool's button or W does: it drops the object clicked first and stops the next click adding to the blend just made. The document and the selection stay as they are. Choosing any other tool that is already active leaves its work as it is. @@ -8483,7 +8501,7 @@ the spine passes nearest, `object.blend.spine.moveAnchor {anchor, x, y}` moves a `handle: "in" | "out"` it places that handle), `object.blend.spine.convertAnchor {anchor, to: "smooth" | "corner", x?, y?}` converts a point's handles (a Smooth drag with `x, y` creates opposite handles; Corner retracts both), `object.blend.spine.reshapeSegment {segment, t, dx, dy, perpendicular?}` bends a segment so its point at `t` moves by (dx, dy), turning a straight spine into a curve (`perpendicular` works as in `path.reshapeSegment`), and `object.blend.spine.removeAnchor {anchor}` deletes a point no key sits on; the first edit turns the spine into a path. Moving a key moves its end of the spine. Release -(`object.blend.release`) leaves the spine as a path that paints nothing (`spines` in the result): +(`object.blend.release`) leaves the spine of a blend that has one as a path that paints nothing (`spines` in the result): ```json {"name":"run_command","arguments":{"command":"object.blend.spine.addAnchor","params":{"id":40,"x":150,"y":100}}} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 908895ad64..6bab3b9435 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1270,6 +1270,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A drag with the Live Paint Selection tool draws a marquee and selects the faces and edges it touches, partly enclosed ones included, and a Shift-drag adds them to the selection or removes them from it ([Modify Live Paint groups](https://web.archive.org/web/20261006115247/https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/modify-live-paint-groups.html)). A click and a marquee change the selection on release, and Escape before the release leaves the selection as it was. Locked Live Paint groups, those on a locked layer or artboard, and in isolation mode those outside the isolated art are not highlighted or selected. When the object the Blend tool picked first is deleted or undone away before the second click, the next click on an object picks that object first instead of doing nothing. Regression tests: `blend_tool_picks_again_when_its_first_pick_is_gone` in `meshblend.rs` and `the_blend_tool_picks_again_when_its_first_pick_is_deleted` in `tests/blendopts.rs`. Window › Blend opens the Blend panel: Make, the selected blend's Spacing and Orientation (with no blend selected, the options new blends start with), Reverse Front to Back, Reverse Spine, Replace Spine, Expand and Release, each running its Object › Blend command. Make is dimmed until two or more objects are selected. Spacing and Orientation are dimmed until a blend or two or more objects are selected, and the other buttons are dimmed until a blend is selected. + The Blend panel's Create Spine menu (Auto, Always, Never; `object.blend.createSpine`) sets whether new blends get a spine, from the Blend tool, Make and `object.blend.make` alike ([Blend panel overview](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blend-panel-overview.html)). Auto and Always give every new blend a straight spine, as before; Never makes blends without one, which keep their steps on the straight lines between the keys, have Type and Ramp dimmed, refuse Reverse Spine and spine edits, release without a spine path, and get a spine from Replace Spine. The setting is kept with the preferences and leaves blends already made as they are; Auto as the starting value and keeping the setting across sessions are VectorCraft's own rules. Builds on #5177 by adambelis. Evidence: `tests::blendopts::{create_spine_sets_whether_new_blends_get_a_spine, a_blend_without_a_spine_has_none_to_edit_reverse_ease_or_release, whether_a_blend_has_a_spine_is_saved_and_old_blends_have_one}` and `panels::tests_blend::create_spine_sets_what_new_blends_get_whatever_is_selected`. With the Blend tool, Escape drops the object clicked first and stops the next click adding to the blend just made, so the next click picks a new first object; the document and the selection stay as they are. Regression tests: `escape_drops_the_blend_tools_first_pick` in `meshblend.rs` and `tests/blendopts.rs`. Objects on Path (#2545): a click with the Objects on Path tool (in Edit Toolbar's Modify section and the Blend tool's flyout; Object › Objects on Path › Attach and the context menu with two or more objects selected choose it) attaches the selected objects to the path clicked, open or closed, in an Objects on Path group with the path at its bottom. The objects go along the path column by column from left to right, spread evenly from its first anchor (the last one sits on the end of an open path), each turned to the path at its attachment point; Pivot and Rotate set where each one attaches and how far it tilts (`object.objectsOnPath.attach`, `object.objectsOnPath.options`), and with Objects on Path groups selected the Properties panel's Objects on Path Options section shows them as a nine-point Pivot grid and a Rotate field with common angles, each change one undo step for every selected group. Editing the path moves the objects, an object moved on its own goes back, objects moved into or out of the group in the Layers panel join or leave the path, and Object › Objects on Path › Expand leaves a plain group. The group saves and reopens live and exports as plain art. With one Objects on Path group selected, the Selection and Objects on Path tools show the Rotate All widget beside its first object: dragging it turns every object about its attachment point by the angle the pointer turns, adding it to the group's Rotate in one undo step, in 15° steps with Shift ([Objects on Path](https://helpx.adobe.com/illustrator/using/objects-on-path.html)). The Select/Move widget and Detach are missing. Objects on Path Space widget: with one Objects on Path group of two or more objects selected, the Selection and Objects on Path tools show a square handle beyond its last object; dragging it along the path, round its bends, sets the distance between the objects' attachment points in one undo step, the first object staying where it is, and with Alt (Option on macOS) the objects spread from both ends toward the center; the distance shows next to the pointer during the drag, in the document's units ([Rearrange and edit objects on a path](https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/rearrange-and-edit-objects-on-a-path.html), [Objects on Path](https://helpx.adobe.com/illustrator/using/objects-on-path.html)). The group keeps the distance (`object.objectsOnPath.options {spacing, start}`) through Pivot and Rotate changes, path edits, copies, saving and reopening, and scales it with the group; by default (`"auto"`) the objects are spread evenly as before. Evidence: `objects_on_path::tests::{space_sets_the_distance_between_the_stops, space_on_a_closed_path_wraps_at_the_first_anchor_and_stops_short_of_the_first_object}` in `crates/doc`, `tests::objects_on_path::space_sets_the_distance_between_the_objects_in_one_undo_step` in `crates/engine`, `objects_on_path::tests::dragging_the_space_widget_spaces_the_objects_from_the_first_one` in `crates/tools`. From 44eb3be71301e8973e5f96f2346b925a991d1ee3 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 21:21:45 -0800 Subject: [PATCH 13/43] Scripts: a flattened string stays counted while the next copy is made - the relational operators root both strings while flattening them, so the first copy still counts when the second one starts a collection - join roots its separator for the whole join and each element while it is written, and JSON.stringify roots a string while it writes it - a String object flattens its string when it is made, with the copy counted, so reading one of its characters copies nothing --- crates/js/src/builtins/array.rs | 31 ++++++++++++--------- crates/js/src/builtins/json.rs | 8 +++--- crates/js/src/interp.rs | 13 ++++++--- crates/js/src/string_builtins.rs | 2 ++ crates/js/src/text_tests.rs | 46 +++++++++++++++++++++++++------- 5 files changed, 71 insertions(+), 29 deletions(-) diff --git a/crates/js/src/builtins/array.rs b/crates/js/src/builtins/array.rs index 263656437c..838fc4ff98 100644 --- a/crates/js/src/builtins/array.rs +++ b/crates/js/src/builtins/array.rs @@ -279,17 +279,20 @@ fn join_with(interp: &mut Interp, o: ObjId, separator: &Value, item: fn(&mut Int if len == 0 { return Ok(Value::str("")); } - let flat = flat_charged(interp, &sep)?; - let sep = &flat[..]; - // A result that cannot fit is refused before any element is read. - let least = (sep.len() as u64).saturating_mul(u64::from(len - 1)); - if least > (interp.limits.heap_bytes / 4) as u64 { - return Err(Abrupt::Limit(crate::LimitKind::Heap)); - } - interp.join_stack.push(o); - let r = join_elements(interp, o, len, sep, item); - interp.join_stack.pop(); - r + // The separator stays rooted for the whole join, so its flattened copy stays counted. + interp.rooted(&[Value::String(sep.clone())], |interp| { + let flat = flat_charged(interp, &sep)?; + let sep = &flat[..]; + // A result that cannot fit is refused before any element is read. + let least = (sep.len() as u64).saturating_mul(u64::from(len - 1)); + if least > (interp.limits.heap_bytes / 4) as u64 { + return Err(Abrupt::Limit(crate::LimitKind::Heap)); + } + interp.join_stack.push(o); + let r = join_elements(interp, o, len, sep, item); + interp.join_stack.pop(); + r + }) } fn join_elements(interp: &mut Interp, o: ObjId, len: u32, sep: &[u16], item: fn(&mut Interp, Value) -> JsResult>) -> JsResult { @@ -302,8 +305,10 @@ fn join_elements(interp: &mut Interp, o: ObjId, len: u32, sep: &[u16], item: fn( let Some(s) = item(interp, v)? else { continue }; out.push_repeated(interp, sep, k.saturating_sub(seps))?; seps = k; - let units = flat_charged(interp, &s)?; - out.push_units(interp, &units)?; + interp.rooted(&[Value::String(s.clone())], |interp| { + let units = flat_charged(interp, &s)?; + out.push_units(interp, &units) + })?; } out.push_repeated(interp, sep, u64::from(len - 1).saturating_sub(seps))?; Ok(out.finish(interp)) diff --git a/crates/js/src/builtins/json.rs b/crates/js/src/builtins/json.rs index 991495ba9d..364ec99696 100644 --- a/crates/js/src/builtins/json.rs +++ b/crates/js/src/builtins/json.rs @@ -633,10 +633,10 @@ fn write_primitive(interp: &mut Interp, out: &mut Builder, v: &Value) -> JsResul Value::Bool(b) => out.push_str(interp, if *b { "true" } else { "false" }), Value::Number(n) if n.is_finite() => out.push_units(interp, &number_to_str(*n).flat()), Value::Number(_) => out.push_str(interp, "null"), - Value::String(s) => { - let units = flat_charged(interp, s)?; - write_quoted(interp, out, &units) - } + Value::String(s) => interp.rooted(std::slice::from_ref(v), |i| { + let units = flat_charged(i, s)?; + write_quoted(i, out, &units) + }), Value::Undefined | Value::Object(_) => Ok(()), } } diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index afca1a1024..9801cc9047 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -2626,7 +2626,11 @@ impl Interp { Value::Undefined | Value::Null => return Err(self.type_error("cannot convert undefined or null to an object")), Value::Bool(b) => (Class::Boolean(*b), self.intrinsics.boolean_proto), Value::Number(n) => (Class::Number(*n), self.intrinsics.number_proto), - Value::String(s) => (Class::String(s.clone()), self.intrinsics.string_proto), + // Flattened now, with its copy counted, so reading a character copies nothing. + Value::String(s) => { + flat_charged(self, s)?; + (Class::String(s.clone()), self.intrinsics.string_proto) + } }; Ok(self.heap.alloc_object(Object::new(class, Some(proto)))) } @@ -2740,8 +2744,11 @@ impl Interp { (self.to_primitive(x, Hint::Number)?, py) }; if let (Value::String(a), Value::String(b)) = (&px, &py) { - let (a, b) = (flat_charged(self, a)?, flat_charged(self, b)?); - return Ok(Some(*a < *b)); + // Both stay rooted, so the first copy is still counted while the second is made. + return self.rooted(&[px.clone(), py.clone()], |i| { + let (a, b) = (flat_charged(i, a)?, flat_charged(i, b)?); + Ok(Some(*a < *b)) + }); } let nx = self.to_number(&px)?; let ny = self.to_number(&py)?; diff --git a/crates/js/src/string_builtins.rs b/crates/js/src/string_builtins.rs index b3758bdf54..ba9c79155c 100644 --- a/crates/js/src/string_builtins.rs +++ b/crates/js/src/string_builtins.rs @@ -214,6 +214,8 @@ fn string_ctor(interp: &mut Interp, _: Value, args: &[Value], construct: bool) - if !construct { return Ok(Value::String(s)); } + // Flattened now, with its copy counted, so reading a character copies nothing. + flat_charged(interp, &s)?; let proto = interp.intrinsics.string_proto; Ok(Value::Object(interp.heap.alloc_object(Object::new(Class::String(s), Some(proto))))) } diff --git a/crates/js/src/text_tests.rs b/crates/js/src/text_tests.rs index 3134e318df..03b4fa4339 100644 --- a/crates/js/src/text_tests.rs +++ b/crates/js/src/text_tests.rs @@ -314,15 +314,43 @@ fn operators_and_builtins_count_a_flattened_rope_against_the_heap() { // Three ropes of about 524,288 units, each 1 MiB once flat, with a 3 MiB heap. let build = "var s = 'x'; for (var i = 0; i < 19; i++) s = s + s; var t = s + 'y', u = s + 'z';"; let limits = Limits { heap_bytes: 3 << 20, ..Limits::default() }; - let not_stopped: Vec<_> = - ["X < 'a'", "X == 1", "+X", "X[0]", "({})[{ toString: function () { return X; } }]", "JSON.stringify(0, null, X)", "[1].join(X)"] - .into_iter() - .filter(|read| { - let src = format!("{build} {}; {}; {}; 0", read.replace('X', "s"), read.replace('X', "t"), read.replace('X', "u")); - let result = Realm::with_options(Options::default(), limits.clone()).run(&src); - !matches!(result, Err(ref e) if e.kind == RunErrorKind::Limit(LimitKind::Heap)) - }) - .collect(); + let not_stopped: Vec<_> = [ + "X < 'a'", + "X == 1", + "+X", + "X[0]", + "new String(X)[0]", + "({})[{ toString: function () { return X; } }]", + "JSON.stringify(0, null, X)", + "[1].join(X)", + ] + .into_iter() + .filter(|read| { + let src = format!("{build} {}; {}; {}; 0", read.replace('X', "s"), read.replace('X', "t"), read.replace('X', "u")); + let result = Realm::with_options(Options::default(), limits.clone()).run(&src); + !matches!(result, Err(ref e) if e.kind == RunErrorKind::Limit(LimitKind::Heap)) + }) + .collect(); + assert!(not_stopped.is_empty(), "not stopped by the heap limit: {not_stopped:?}"); +} + +#[test] +fn a_flattened_temporary_stays_counted_while_the_next_copy_is_made() { + // Ropes of 262,145 units, each about half the heap once flat: one fits, two don't. Each is + // made by a function, so nothing but the native code holds it while it is flattened. + let build = "var s = 'x'; for (var i = 0; i < 18; i++) s = s + s; function f(c) { return s + c; }"; + let limits = Limits { heap_bytes: 4 * 262_146, ..Limits::default() }; + let run = |read: &str| Realm::with_options(Options::default(), limits.clone()).run(&format!("{build} {read}; 0")); + assert!(run("f('a')[0]").is_ok(), "one flattened copy fits"); + let not_stopped: Vec<_> = [ + "({ valueOf: function () { return f('a'); } }) < ({ valueOf: function () { return f('b'); } })", + "JSON.stringify({ toJSON: function () { return f('a'); } })", + "[1, 2].join({ toString: function () { return f('a'); } })", + "[{ toString: function () { return f('a'); } }].join('')", + ] + .into_iter() + .filter(|read| !matches!(run(read), Err(ref e) if e.kind == RunErrorKind::Limit(LimitKind::Heap))) + .collect(); assert!(not_stopped.is_empty(), "not stopped by the heap limit: {not_stopped:?}"); } From a07cdf87f0eb40e49e0f64ddfafbca6f33fade3c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 21:29:50 -0800 Subject: [PATCH 14/43] PDF/X: names inside a string or a comment no longer fail the standard check The check that a PDF/X-1a or PDF/X-3 file has no transparency read names in literal strings and comments as syntax. The file identifier is a base64 hash of the file, so a file could now and then hold `/BM` or `/Lab` there and its export was refused as having blend modes or Lab colors. The check now leaves out the text of literal strings and comments as well as stream data. --- crates/pdf/src/pdfx.rs | 47 +++++++++++++++++++++++++++--------- crates/pdf/src/tests/pdfx.rs | 20 +++++++++++++++ 2 files changed, 55 insertions(+), 12 deletions(-) diff --git a/crates/pdf/src/pdfx.rs b/crates/pdf/src/pdfx.rs index 116d1cf0c7..e367221d59 100644 --- a/crates/pdf/src/pdfx.rs +++ b/crates/pdf/src/pdfx.rs @@ -211,23 +211,46 @@ pub(crate) fn verify(pdf: &[u8], standard: Standard) -> Result<(), PdfError> { Ok(()) } -/// `pdf` without its streams' data (dictionaries and the rest of the syntax), so that what is -/// looked for there isn't found in image or font bytes. +/// `pdf` without its streams' data, the text of its literal strings and its comments (dictionaries +/// and the rest of the syntax), so that what is looked for there isn't found in image or font +/// bytes, or in a string such as the file identifier, whose base64 hash can hold `/BM`. fn without_streams(pdf: &[u8]) -> Vec { let mut out = Vec::with_capacity(pdf.len().min(1 << 20)); let mut at = 0; - while let Some(s) = find(pdf, b"stream", at) { - let data = s + b"stream".len(); - // `endstream` itself, or a stream keyword that isn't one (inside a name or string). - if (s >= 3 && pdf.get(s - 3..s) == Some(b"end")) || !pdf.get(data).is_some_and(|b| matches!(b, b'\r' | b'\n')) { - out.extend_from_slice(pdf.get(at..data).unwrap_or_default()); - at = data; - continue; + while let Some(&b) = pdf.get(at) { + match b { + // A literal string: parentheses nest, and a backslash escapes the next byte. + b'(' => { + out.extend_from_slice(b"()"); + let mut depth = 0usize; + at += 1; + while let Some(&c) = pdf.get(at) { + at += 1; + match c { + b'\\' => at += 1, + b'(' => depth += 1, + b')' if depth == 0 => break, + b')' => depth -= 1, + _ => {} + } + } + } + // A comment, to the end of its line. + b'%' => at += pdf.get(at..).unwrap_or_default().iter().take_while(|c| !matches!(c, b'\r' | b'\n')).count(), + // A stream keyword (not `endstream`, and followed by its end of line): its data is left out. + b's' if pdf.get(at..at + 6) == Some(b"stream") + && !(at >= 3 && pdf.get(at - 3..at) == Some(b"end")) + && pdf.get(at + 6).is_some_and(|c| matches!(c, b'\r' | b'\n')) => + { + out.extend_from_slice(b"stream"); + at = find(pdf, b"endstream", at + 6).unwrap_or(pdf.len()); + } + _ => { + out.push(b); + at += 1; + } } - out.extend_from_slice(pdf.get(at..data).unwrap_or_default()); - at = find(pdf, b"endstream", data).unwrap_or(pdf.len()); } - out.extend_from_slice(pdf.get(at..).unwrap_or_default()); out } diff --git a/crates/pdf/src/tests/pdfx.rs b/crates/pdf/src/tests/pdfx.rs index dbc221e0dd..ec22eb0186 100644 --- a/crates/pdf/src/tests/pdfx.rs +++ b/crates/pdf/src/tests/pdfx.rs @@ -224,3 +224,23 @@ fn verify_finds_what_each_standard_forbids() { assert!(matches!(verify(&file(font), Standard::PdfX4), Err(PdfError::Unsupported(m)) if m.contains("fonts"))); assert!(verify(&file("<>"), Standard::PdfX4).is_ok()); } + +/// Names inside a string or a comment are text, not syntax: a file identifier, whose base64 hash +/// can hold `/BM` or `/Lab`, does not make a file break its standard. +#[test] +fn verify_skips_names_in_strings_and_comments() { + use crate::pdfx::verify; + let file = |body: &str| format!("%PDF-1.3\n%/BM/Multiply\n1 0 obj\n{body}\nendobj\n").into_bytes(); + for ok in [ + "<>", + "<>", + "<>", + ] { + for standard in [Standard::PdfX1a, Standard::PdfX3] { + assert!(verify(&file(ok), standard).is_ok(), "{standard:?}: {ok}"); + } + } + // A name after a string is still read. + let after = "<>"; + assert!(matches!(verify(&file(after), Standard::PdfX3), Err(PdfError::Unsupported(m)) if m.contains("blend modes"))); +} From 9fc4e3c5cf71919b117c50a2d7bd2faa9d7d03b1 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 21:30:00 -0800 Subject: [PATCH 15/43] =?UTF-8?q?Swatch=20libraries:=20Patterns=20?= =?UTF-8?q?=E2=80=BA=20Grit=20Textures=20adds=20seamless=20grain,=20speckl?= =?UTF-8?q?e=20and=20stipple=20patterns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Window › Swatch Libraries › Patterns and the Swatches panel's library menu gain a second built-in pattern library, Grit Textures, with eight black textures on square grid tiles of 18 to 36 pt: Fine Grain, Coarse Grit, Sand, Speckle, Stipple, Scratches, Sparse Flecks and Dense Flecks. Each is drawn in code from a fixed seed, so it is the same every time. Marks that reach past a tile edge are drawn on the next tile by the tiling, so the tiles join without a seam. Docs: docs/mcp.md, docs/target-app-parity.md, ROADMAP. --- ROADMAP.md | 1 + crates/engine/src/cmd/patternlib.rs | 162 +++++++++++++++++- crates/engine/src/tests/swatchlib.rs | 33 ++++ crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/bs.tsv | 1 + crates/ui-egui/src/i18n/ca.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hi.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ka.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + .../ui-egui/src/panels/library_panel/tests.rs | 3 +- docs/mcp.md | 5 +- docs/target-app-parity.md | 2 +- 33 files changed, 227 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 327036c371..bbc61c3df9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -126,6 +126,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap - 2026-11-04: New Document: the units chosen in Preset Details now belong to the chosen document, as the pane's options are listed for the selected preset; the chosen card shows them, every other Recent and Saved card keeps the units its document or preset was made in, and the built-in tabs still follow the chosen units, so switching between inches and millimetres no longer restyles every recent template. Scores and estimates unchanged. - 2026-11-02: Mouse wheel over a closed font-family field in Character, Properties or Control changes selected text through the family list using saved font filters (excluding search text), one undo step per notch, stopping at the ends (#5253). Scores and estimates unchanged. - 2026-10-31: Pattern libraries: Window › Swatch Libraries › Patterns and the Swatches panel's library menu open Geometric Patterns, a built-in library of eleven pattern swatches (dots, stripes, checks, a grid and shapes); clicking one adds its pattern and swatch to the document, once, and applies it to the active fill or stroke (`swatch.library.add`). +- 2026-11-11: Pattern libraries: Patterns › Grit Textures, a second built-in pattern library, holds eight seamless black textures (Fine Grain, Coarse Grit, Sand, Speckle, Stipple, Scratches, Sparse Flecks and Dense Flecks) on tiles of 18 to 36 pt, each drawn the same way every time; marks that reach past a tile edge continue on the next tile, so the tiles join without a seam (`swatch.library.add`). Scores and estimates unchanged. - 2026-11-01: Find Nearest Swatch joins the Color Effects add-on (Object › Plug-ins › Color Effects): for each colour of the selection, the closest swatches of the document, a built-in library or a library or colour book the user loaded, with their CIEDE2000 difference; Apply gives each colour its nearest swatch as one undo step, linking global and spot swatches and adding a library's to the document first, so a process colour becomes the closest spot colour (`addon.color.nearestSwatch`, `addon.color.applyNearestSwatch`, #4923). - 2026-11-01: Expand Dashes into Paths joins the Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): each selected path's dashed stroke becomes separate open paths, one per dash, laid out exactly as the stroke draws them (pattern, offset, Align Dashes to Corners and Path Ends), each keeping the stroke with the dashes off, so the gaps are real gaps for cutters and plotters, as one undo step (`addon.pathtools.expandDashes`, #4922). - 2026-11-01: Randomize Opacity joins the Arrange Tools add-on (Object › Plug-ins › Arrange Tools): each selected object gets its own opacity at random between a minimum and a maximum percent, with a seed that repeats the result, as one undo step (`addon.arrange.randomizeOpacity`, #4925). diff --git a/crates/engine/src/cmd/patternlib.rs b/crates/engine/src/cmd/patternlib.rs index 011dfc131c..8fd2a0ea5e 100644 --- a/crates/engine/src/cmd/patternlib.rs +++ b/crates/engine/src/cmd/patternlib.rs @@ -23,8 +23,10 @@ pub struct BuiltinPatternLibrary { } /// The built-in pattern libraries (Window → Swatch Libraries → Patterns), in menu order. -pub const PATTERN_LIBRARIES: &[BuiltinPatternLibrary] = - &[BuiltinPatternLibrary { id: "geometric-patterns", name: "Geometric Patterns", make: geometric_patterns }]; +pub const PATTERN_LIBRARIES: &[BuiltinPatternLibrary] = &[ + BuiltinPatternLibrary { id: "geometric-patterns", name: "Geometric Patterns", make: geometric_patterns }, + BuiltinPatternLibrary { id: "grit-textures", name: "Grit Textures", make: grit_textures }, +]; /// The library category of the built-in pattern libraries. pub const CATEGORY: &str = "patterns"; @@ -154,6 +156,106 @@ fn geometric_patterns(d: &mut Document) { add(d, "Zigzag", 10.0, 8.0, TileType::Grid, vec![polygon(&[(0.0, 5.0), (5.0, 1.0), (10.0, 5.0), (10.0, 7.0), (5.0, 3.0), (0.0, 7.0)])]); } +/// SplitMix64, a small generator with a fixed seed per pattern, so a grit pattern draws the same +/// marks every time it is made. +struct SplitMix64(u64); + +impl SplitMix64 { + fn next(&mut self) -> u64 { + self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = self.0; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + } + + /// A number from `lo` up to, but not including, `hi`. + fn range(&mut self, lo: f64, hi: f64) -> f64 { + lo + (self.next() >> 11) as f64 / (1u64 << 53) as f64 * (hi - lo) + } +} + +/// A round dot of radius `r` at (`x`, `y`). +fn dot(x: f64, y: f64, r: f64) -> PathData { + shapes::ellipse(Rect::new(x - r, y - r, x + r, y + r)) +} + +/// An irregular fleck of four to seven corners about (`x`, `y`), at most `r` from it. +fn fleck(rng: &mut SplitMix64, x: f64, y: f64, r: f64) -> PathData { + use std::f64::consts::TAU; + let n = 4 + (rng.next() % 4) as usize; + let turn = rng.range(0.0, TAU); + let points: Vec<(f64, f64)> = (0..n) + .map(|k| { + let a = turn + TAU * (k as f64 + rng.range(-0.3, 0.3)) / n as f64; + let reach = r * rng.range(0.55, 1.0); + (x + reach * a.cos(), y + reach * a.sin()) + }) + .collect(); + polygon(&points) +} + +/// A thin straight scratch `len` long and `width` wide, centered on (`x`, `y`) at a random angle. +fn scratch(rng: &mut SplitMix64, x: f64, y: f64, len: f64, width: f64) -> PathData { + let (s, c) = rng.range(0.0, std::f64::consts::PI).sin_cos(); + let (hx, hy, wx, wy) = (c * len / 2.0, s * len / 2.0, -s * width / 2.0, c * width / 2.0); + polygon(&[(x - hx - wx, y - hy - wy), (x + hx - wx, y + hy - wy), (x + hx + wx, y + hy + wy), (x - hx + wx, y - hy + wy)]) +} + +/// A grit pattern of `count` marks scattered over a square grid tile of side `size`, drawn from +/// `seed` (`mark` draws one at a point). A mark that reaches past an edge is kept whole: the tiling +/// draws the part outside the tile on the next tile, so the tiles join without a seam. +fn grit(d: &mut Document, name: &str, size: f64, seed: u64, count: usize, mark: impl Fn(&mut SplitMix64, f64, f64) -> PathData) { + let mut rng = SplitMix64(seed); + let marks = (0..count) + .map(|_| { + let (x, y) = (rng.range(0.0, size), rng.range(0.0, size)); + mark(&mut rng, x, y) + }) + .collect(); + add(d, name, size, size, TileType::Grid, marks); +} + +/// Grit Textures: seamless black grain, grit, sand, speckle, stipple, scratch and fleck textures on +/// a clear background, each drawn from its own fixed seed. +fn grit_textures(d: &mut Document) { + grit(d, "Fine Grain", 24.0, 1, 320, |rng, x, y| dot(x, y, rng.range(0.15, 0.35))); + grit(d, "Coarse Grit", 30.0, 2, 150, |rng, x, y| { + let r = rng.range(0.45, 1.1); + fleck(rng, x, y, r) + }); + grit(d, "Sand", 24.0, 3, 280, |rng, x, y| { + let r = rng.range(0.2, 0.45); + fleck(rng, x, y, r) + }); + grit(d, "Speckle", 30.0, 4, 90, |rng, x, y| dot(x, y, rng.range(0.3, 0.9))); + // Stipple: one dot in each cell of a 12 × 12 grid, moved a little off the cell's center. + let mut rng = SplitMix64(5); + let (size, cells) = (24.0, 12); + let step = size / f64::from(cells); + let dots = (0..cells) + .flat_map(|row| (0..cells).map(move |col| (col, row))) + .map(|(col, row)| { + let x = (f64::from(col) + 0.5) * step + rng.range(-0.3, 0.3) * step; + let y = (f64::from(row) + 0.5) * step + rng.range(-0.3, 0.3) * step; + dot(x, y, rng.range(0.3, 0.45)) + }) + .collect(); + add(d, "Stipple", size, size, TileType::Grid, dots); + grit(d, "Scratches", 36.0, 6, 45, |rng, x, y| { + let (len, width) = (rng.range(2.0, 7.0), rng.range(0.15, 0.35)); + scratch(rng, x, y, len, width) + }); + grit(d, "Sparse Flecks", 36.0, 7, 32, |rng, x, y| { + let r = rng.range(0.35, 0.8); + fleck(rng, x, y, r) + }); + grit(d, "Dense Flecks", 18.0, 8, 220, |rng, x, y| { + let r = rng.range(0.2, 0.5); + fleck(rng, x, y, r) + }); +} + #[cfg(test)] mod tests { use super::*; @@ -168,4 +270,60 @@ mod tests { assert_eq!(existing_pattern(&d, &lib, "Dots").as_deref(), expected, "{other}"); } } + + fn grit_doc() -> Document { + let mut d = Document::new(100.0, 100.0); + grit_textures(&mut d); + d + } + + #[test] + fn grit_textures_draw_the_same_marks_every_time() { + let (a, b) = (grit_doc(), grit_doc()); + assert_eq!(a.patterns.len(), 8); + assert_eq!(a.patterns, b.patterns); + } + + /// Every grit tile is 18 to 36 pt with at most 600 marks, each with an area, touching the tile + /// and near it. Marks reach past the edges, and the tiling draws a tile's art on the next tile + /// across each edge a mark crosses. No mark is another mark moved by a whole tile, which the tiling would draw + /// twice in one place. + #[test] + fn grit_tiles_join_without_a_seam() { + let mut crossing = 0; + for p in &grit_doc().patterns { + let (w, h) = (p.width(), p.height()); + assert!((18.0..=36.0).contains(&w) && w == h && p.tile.x0 == 0.0 && p.tile.y0 == 0.0, "{}", p.name); + assert!(!p.art.is_empty() && p.art.len() <= 600, "{}: {} marks", p.name, p.art.len()); + let bounds: Vec = p + .art + .iter() + .map(|n| match &n.kind { + vectorcraft_doc::NodeKind::Path { path, .. } => path.bounds().unwrap_or_else(|| panic!("{}: an empty mark", p.name)), + _ => panic!("{}: a mark that is not a path", p.name), + }) + .collect(); + let near = |a: &Rect, b: Rect| { + (a.x0 - b.x0).abs() < 1e-9 && (a.y0 - b.y0).abs() < 1e-9 && (a.x1 - b.x1).abs() < 1e-9 && (a.y1 - b.y1).abs() < 1e-9 + }; + let band = p.tile.inflate(w / 4.0, h / 4.0); + for b in &bounds { + assert!(b.width() > 0.0 && b.height() > 0.0, "{}: {b:?}", p.name); + assert!(b.x1 > 0.0 && b.y1 > 0.0 && b.x0 < w && b.y0 < h, "{}: {b:?} does not touch the tile", p.name); + assert!(b.x0 >= band.x0 && b.y0 >= band.y0 && b.x1 <= band.x1 && b.y1 <= band.y1, "{}: {b:?} is too far out", p.name); + // The tile past each edge the mark crosses draws this tile's art. + let (dx, dy) = (f64::from(i8::from(b.x1 > w) - i8::from(b.x0 < 0.0)), f64::from(i8::from(b.y1 > h) - i8::from(b.y0 < 0.0))); + for (x, y) in [(dx, 0.0), (0.0, dy), (dx, dy)] { + let next = p.tile + kurbo::Vec2::new(x * w, y * h); + assert!(p.offsets_covering(next).contains(&kurbo::Vec2::ZERO), "{}: {b:?} is cut off at {next:?}", p.name); + } + crossing += usize::from(dx != 0.0 || dy != 0.0); + for (dx, dy) in [(w, 0.0), (-w, 0.0), (0.0, h), (0.0, -h), (w, h), (w, -h), (-w, h), (-w, -h)] { + let moved = *b + kurbo::Vec2::new(dx, dy); + assert!(!bounds.iter().any(|o| near(o, moved)), "{}: {b:?} is drawn twice by the tiling", p.name); + } + } + } + assert!(crossing > 0, "no mark reaches past an edge"); + } } diff --git a/crates/engine/src/tests/swatchlib.rs b/crates/engine/src/tests/swatchlib.rs index 3ecb5e68e1..8271cfa564 100644 --- a/crates/engine/src/tests/swatchlib.rs +++ b/crates/engine/src/tests/swatchlib.rs @@ -765,6 +765,39 @@ fn a_pattern_library_adds_only_the_picked_pattern_and_it_undoes_and_saves() { assert_eq!(back.swatch("Checkerboard").map(|w| &w.paint), doc(&s).swatch("Checkerboard").map(|w| &w.paint)); } +/// Grit Textures is listed after Geometric Patterns. One of its patterns comes in with its swatch +/// once, as one undo step that also takes both away, and both stay after a save and reopen. +#[test] +fn a_grit_texture_adds_once_undoes_and_saves() { + let mut s = session(); + let list = run(&mut s, "swatch.library.list", json!({})); + let ids: Vec<&str> = + list["libraries"].as_array().unwrap().iter().filter(|l| l["category"] == "patterns").map(|l| l["id"].as_str().unwrap()).collect(); + assert_eq!(ids, ["geometric-patterns", "grit-textures"]); + let got = run(&mut s, "swatch.library.get", json!({"library": "Grit Textures"})); + let names: Vec<&str> = got["swatches"].as_array().unwrap().iter().map(|w| w["name"].as_str().unwrap()).collect(); + assert_eq!(names, ["Fine Grain", "Coarse Grit", "Sand", "Speckle", "Stipple", "Scratches", "Sparse Flecks", "Dense Flecks"]); + let counts = |d: &vectorcraft_doc::Document| (d.patterns.len(), d.swatches_iter().count()); + let empty = counts(doc(&s)); + let undo = undo_len(&s); + let r = run(&mut s, "swatch.library.add", json!({"library": "grit-textures", "names": ["Stipple"]})); + assert_eq!(r["added"], json!(["Stipple"])); + assert_eq!((counts(doc(&s)), undo_len(&s)), ((empty.0 + 1, empty.1 + 1), undo + 1)); + let def = doc(&s).pattern("Stipple").expect("the pattern comes with its swatch").clone(); + assert!(def.art.len() >= 144, "{} marks", def.art.len()); + assert!(matches!(doc(&s).swatch("Stipple").map(|w| &w.paint), Some(Paint::Pattern { pattern, .. }) if pattern == "Stipple")); + let r = run(&mut s, "swatch.library.add", json!({"library": "grit-textures", "names": ["Stipple"]})); + assert_eq!((r["added"].clone(), r["existing"].clone()), (json!([]), json!(["Stipple"]))); + assert_eq!((counts(doc(&s)), undo_len(&s)), ((empty.0 + 1, empty.1 + 1), undo + 1), "added once"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(counts(doc(&s)), empty); + assert!(doc(&s).pattern("Stipple").is_none() && doc(&s).swatch("Stipple").is_none()); + run(&mut s, "edit.redo", json!({})); + let back = vectorcraft_format::load(&vectorcraft_format::save(doc(&s), false)).unwrap(); + assert_eq!(back.pattern("Stipple"), Some(&def)); + assert_eq!(back.swatch("Stipple").map(|w| &w.paint), doc(&s).swatch("Stipple").map(|w| &w.paint)); +} + /// A device name is saved with a `-` after it, which can be another library's own name: neither /// library replaces the other, and saving either again replaces only itself. #[test] diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 24f16e1794..8c3f0ad02b 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -5810,6 +5810,7 @@ theme Appearance Външен вид Ruby Руби Geometric Guides Геометрични водачи Geometric Patterns Геометрични мостри + Grit Textures Зърнести текстури Ease In Плавно начало Ease Out Плавен край Ease In Out Плавно начало и край diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 83290bdbd9..bb8ba457ed 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -5827,6 +5827,7 @@ mark-style Japanese Japanski Ruby Ruby Geometric Guides Geometrijske vodilice Geometric Patterns Geometrijski uzorci + Grit Textures Zrnaste teksture Ease In Ublaži ulaz Ease Out Ublaži izlaz Ease In Out Ublaži ulaz i izlaz diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 717c2b59d9..01d16f3d51 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -1872,6 +1872,7 @@ digits Malayalam Malaiàlam Gridline every Línia de quadrícula cada Gridline every: Línia de quadrícula cada: Grids In Back Quadrícules al darrere + Grit Textures Textures granulades Group Agrupa Group Selection Selecció de grup Group Selection Tool Eina de selecció de grup diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 216579443f..1554110e28 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -5980,6 +5980,7 @@ mark-style Japanese Japonské Ruby Ruby Geometric Guides Geometrická vodítka Geometric Patterns Geometrické vzorky + Grit Textures Zrnité textury Ease In Pozvolný začátek Ease Out Pozvolný konec Ease In Out Pozvolný začátek a konec diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 2339ca27ed..07a9d73cd1 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -5825,6 +5825,7 @@ swatch Bright Violet Kraftig violet Ruby Ruby Geometric Guides Geometriske hjælpelinjer Geometric Patterns Geometriske mønstre + Grit Textures Kornede teksturer Ease In Langsom start Ease Out Langsom slutning Ease In Out Langsom start og slutning diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index e5ad28a0c2..4c669c24e4 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5561,6 +5561,7 @@ effect Soft Weich Ruby Ruby Geometric Guides Geometrische Hilfslinien Geometric Patterns Geometrische Muster + Grit Textures Körnige Texturen Ease In Langsam starten Ease Out Langsam enden Ease In Out Langsam starten und enden diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index c87ecb5713..7642033e06 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -5931,6 +5931,7 @@ effect Points Σημεία Ruby Ruby Geometric Guides Γεωμετρικοί οδηγοί Geometric Patterns Γεωμετρικά μοτίβα + Grit Textures Κοκκώδεις υφές Ease In Ομαλή αρχή Ease Out Ομαλό τέλος Ease In Out Ομαλή αρχή και τέλος diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index db8da4cc35..5e1fdfb1c7 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -5922,6 +5922,7 @@ mark-style Japanese Japonesas Ruby Ruby Geometric Guides Guías geométricas Geometric Patterns Motivos geométricos + Grit Textures Texturas granuladas Ease In Entrada suave Ease Out Salida suave Ease In Out Entrada y salida suaves diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index c3d7fcfb91..4f9413ff4f 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6010,6 +6010,7 @@ swatch Bright Violet Kirkas violetti Ruby Ruby Geometric Guides Geometriset apuviivat Geometric Patterns Geometriset kuviot + Grit Textures Rakeiset tekstuurit Ease In Hidas alku Ease Out Hidas loppu Ease In Out Hidas alku ja loppu diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index e557d273c9..c81852fcdc 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -5975,6 +5975,7 @@ theme Appearance Apparence Ruby Ruby Geometric Guides Repères géométriques Geometric Patterns Motifs géométriques + Grit Textures Textures granuleuses Ease In Accélération progressive Ease Out Décélération progressive Ease In Out Accélération et décélération diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 129dfeea3c..3a40dc706b 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -2064,6 +2064,7 @@ antiAlias Sharp शार्प Gridline every ग्रिडलाइन हर Gridline every: ग्रिडलाइन हर: Grids In Back ग्रिड में वापस + Grit Textures दानेदार टेक्सचर Group समूह Group Overlapping Objects समूह ओवरलैपिंग ऑब्जेक्ट Group Selection समूह सिलेक्शन diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 78c096b113..7de4f671c9 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5963,6 +5963,7 @@ imageTrace Outline Körvonal Ruby Ruby Geometric Guides Geometriai segédvonalak Geometric Patterns Geometriai mintázatok + Grit Textures Szemcsés textúrák Ease In Lassú kezdés Ease Out Lassú befejezés Ease In Out Lassú kezdés és befejezés diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 002740e114..91ad56ef22 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -5825,6 +5825,7 @@ swatch Bright Violet Violet cerah Ruby Ruby Geometric Guides Panduan Geometris Geometric Patterns Pola Geometris + Grit Textures Tekstur Butiran Ease In Awal Lambat Ease Out Akhir Lambat Ease In Out Awal dan Akhir Lambat diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 7f230b10f4..2710544ddb 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5595,6 +5595,7 @@ lighting Light Luce Ruby Ruby Geometric Guides Guide geometriche Geometric Patterns Motivi geometrici + Grit Textures Texture granulose Ease In Inizio graduale Ease Out Fine graduale Ease In Out Inizio e fine graduali diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 88c9013b2e..ddced9ea96 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5645,6 +5645,7 @@ lighting Light ライト Ruby ルビ Geometric Guides 幾何ガイド Geometric Patterns 幾何パターン + Grit Textures 粒状テクスチャ Ease In イーズイン Ease Out イーズアウト Ease In Out イーズインアウト diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 4b7dfdcac7..cd3c1ece1a 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -5920,6 +5920,7 @@ imageTrace Outline კონტური Ruby რუბი Geometric Guides გეომეტრიული მიმმართველები Geometric Patterns გეომეტრიული პატერნები + Grit Textures მარცვლოვანი ტექსტურები Ease In ნელი დასაწყისი Ease Out ნელი დასასრული Ease In Out ნელი დასაწყისი და დასასრული diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index ac3f3d0cf6..c4c32f6a67 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5556,6 +5556,7 @@ lighting Light 광원 Ruby 루비 Geometric Guides 기하학적 안내선 Geometric Patterns 기하학적 패턴 + Grit Textures 입자 텍스처 Ease In 서서히 시작 Ease Out 서서히 종료 Ease In Out 서서히 시작 및 종료 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 0622156953..ab2017cd55 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -5876,6 +5876,7 @@ swatch Bright Violet Felviolet Ruby Ruby Geometric Guides Geometrische hulplijnen Geometric Patterns Geometrische patronen + Grit Textures Korrelige texturen Ease In Langzaam beginnen Ease Out Langzaam eindigen Ease In Out Langzaam beginnen en eindigen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 11dd0dc99a..6d5d9f8eaf 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5834,6 +5834,7 @@ mark-style Japanese Japońskie Ruby Ruby Geometric Guides Geometryczne linie pomocnicze Geometric Patterns Wzorki geometryczne + Grit Textures Tekstury ziarniste Ease In Łagodny początek Ease Out Łagodny koniec Ease In Out Łagodny początek i koniec diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 62b2fa772a..ffa02858e2 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -5895,6 +5895,7 @@ mark-style Japanese Japonesas Ruby Ruby Geometric Guides Guias geométricas Geometric Patterns Padrões geométricos + Grit Textures Texturas granuladas Ease In Entrada suave Ease Out Saída suave Ease In Out Entrada e saída suaves diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 87c359b83b..ea39a33867 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5589,6 +5589,7 @@ lighting Light Свет Ruby Руби Geometric Guides Геометрические направляющие Geometric Patterns Геометрические узоры + Grit Textures Зернистые текстуры Ease In Плавное начало Ease Out Плавное окончание Ease In Out Плавное начало и окончание diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 92de527ff1..1095a88d12 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -6003,6 +6003,7 @@ font-script Japanese Japanska Ruby Ruby Geometric Guides Geometriska stödlinjer Geometric Patterns Geometriska mönster + Grit Textures Korniga texturer Ease In Långsam start Ease Out Långsamt slut Ease In Out Långsam start och slut diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index e2ec9c6851..4569d6777c 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -5975,6 +5975,7 @@ axis Y Axis Y ekseni Snap to Perpendicular Dikeye Yasla Geometric Guides Geometrik Kılavuzlar Geometric Patterns Geometrik Desenler + Grit Textures Grenli Dokular @msg `method` must be classic or perceptual `method` classic veya perceptual olmalıdır # Heavy plug-in filters (plugin.inspect, plug-in runs) Install “{name}”, which asks for a heavy budget? Büyük bir hesaplama bütçesi isteyen “{name}” yüklensin mi? diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 235a4d389e..a92b04b758 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5575,6 +5575,7 @@ lighting Light Світло Ruby Рубі Geometric Guides Геометричні напрямні Geometric Patterns Геометричні візерунки + Grit Textures Зернисті текстури Ease In Плавний початок Ease Out Плавне завершення Ease In Out Плавний початок і завершення diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 87d2c22625..2e5deb16b2 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5576,6 +5576,7 @@ theme Appearance Giao diện Ruby Ruby Geometric Guides Đường dẫn hình học Geometric Patterns Họa tiết hình học + Grit Textures Kết cấu hạt Ease In Chậm lúc đầu Ease Out Chậm lúc cuối Ease In Out Chậm lúc đầu và cuối diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index eea8526b99..d3e0952825 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -5817,6 +5817,7 @@ lighting Light 光源 Ruby 注音 Geometric Guides 几何参考线 Geometric Patterns 几何图案 + Grit Textures 颗粒纹理 Ease In 缓入 Ease Out 缓出 Ease In Out 缓入缓出 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 60cc2d1d43..0a9112879a 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5613,6 +5613,7 @@ lighting Light 光源 Ruby 注音 Geometric Guides 幾何參考線 Geometric Patterns 幾何圖樣 + Grit Textures 顆粒紋理 Ease In 緩入 Ease Out 緩出 Ease In Out 緩入緩出 diff --git a/crates/ui-egui/src/panels/library_panel/tests.rs b/crates/ui-egui/src/panels/library_panel/tests.rs index 31976746e3..f28e7e5540 100644 --- a/crates/ui-egui/src/panels/library_panel/tests.rs +++ b/crates/ui-egui/src/panels/library_panel/tests.rs @@ -155,8 +155,9 @@ fn find_filters_list_view_and_arrows_step_through_the_libraries() { assert!(ctx.read_response(tile_id(panel, "#00FF00")).is_none()); let first = SWATCH_IDS[0]; let last = SwatchLibraries::list(&app).pop().unwrap().id; - assert_eq!(last, "geometric-patterns", "gradient and then pattern libraries come after the swatch libraries"); + assert_eq!(last, "grit-textures", "gradient and then pattern libraries come after the swatch libraries"); assert_eq!(neighbour::(&app, "radial-glows", 1).as_deref(), Some("geometric-patterns")); + assert_eq!(neighbour::(&app, "geometric-patterns", 1).as_deref(), Some("grit-textures")); assert_eq!(neighbour::(&app, first, -1), Some(last), "wraps around"); assert_eq!(neighbour::(&app, "web-safe-216", 1).as_deref(), Some(SWATCH_IDS[1])); } diff --git a/docs/mcp.md b/docs/mcp.md index da260b6823..8f726e5d31 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3290,8 +3290,9 @@ in the `swatch.list` shape (`library` is an id or a name). `swatch.library.add { into the document as one undo step: a colour group's name brings the whole group, a swatch's name the swatch alone, no names the whole library; swatches the document already has (same name and paint) are reported under `existing` and not added again. `apply: "fill"|"stroke"` also applies the first one, in the same undo step (what clicking a -swatch in the library panel does). The pattern library Geometric Patterns (`geometric-patterns`, category -`patterns`) holds pattern swatches drawn in code; adding one also adds its pattern, as pasting art painted with it does +swatch in the library panel does). The pattern libraries Geometric Patterns (`geometric-patterns`) and Grit Textures +(`grit-textures`: seamless black grain, grit, sand, speckle, stipple, scratch and fleck tiles), category `patterns`, +hold pattern swatches drawn in code; adding one also adds its pattern, as pasting art painted with it does (the same pattern the document has is reused, and a name another pattern or swatch has gets a number). `swatch.resetDefaults {replace?}` brings back the missing default swatches. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 18d50447ff..86cd65e34a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1274,7 +1274,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Double-clicking the Objects on Path tool opens Objects on Path Tool Options ([Objects on Path](https://helpx.adobe.com/illustrator/using/objects-on-path.html)): the nine-point Pivot grid and the Rotate field set the attachment point and angle of the objects attached next, and groups already attached keep theirs; Reset puts the center and 0° back. Save keeps the options across tool switches and saves them with the preferences (`tool.setOption {tool: "objectsOnPath"}`). - **Paint and appearance (M3):** - **CSS swatch libraries:** fractional linear and radial gradient stop positions are preserved, so nearby stops such as 12.5% and 12.890625% remain distinct instead of collapsing to 13%. Reference: [CSS Images 3 color stops](https://www.w3.org/TR/css-images-3/#color-stop-syntax). The original synthetic regression is `palette_io::tests::css_preserves_fractional_gradient_stop_positions` in `crates/color/src/palette_io.rs`. Scores and hours are unchanged. - - **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries, five gradient libraries and a pattern library (Geometric Patterns, whose swatches bring their patterns) in a library panel, saved as `.vcswatches`, `.gpl`, swatch exchange (`.ase`) or CSS, and loaded from `.vcswatches`, `.gpl` and `.ase` files (`.ase` both ways: RGB, CMYK, Lab and Gray; global, spot and process; groups) and from color books (`.acb`, read only: the RGB, CMYK or Lab colors of a book such as a Pantone book a user owns, named with its prefix and suffix, spot unless it says process, #831); `swatch.library.copyToUser` copies a loaded library into the User Defined folder without replacing a file, and a file with the same extension and bytes as a User Defined library opens as that library. Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate. + - **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries, five gradient libraries and two pattern libraries (Geometric Patterns, and Grit Textures with eight seamless grain, speckle, stipple, scratch and fleck tiles; their swatches bring their patterns) in a library panel, saved as `.vcswatches`, `.gpl`, swatch exchange (`.ase`) or CSS, and loaded from `.vcswatches`, `.gpl` and `.ase` files (`.ase` both ways: RGB, CMYK, Lab and Gray; global, spot and process; groups) and from color books (`.acb`, read only: the RGB, CMYK or Lab colors of a book such as a Pantone book a user owns, named with its prefix and suffix, spot unless it says process, #831); `swatch.library.copyToUser` copies a loaded library into the User Defined folder without replacing a file, and a file with the same extension and bytes as a User Defined library opens as that library. Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate. Editing, renaming, deleting or merging a global swatch also changes the colors linked to it in graph designs, the art of brushes and the tile that pattern editing's Cancel puts back, in the same undo step as the rest of the art; Default Swatches with replace unlinks them there too. Add Used Colors (`swatch.addUsedColors`) and Select All Unused (`swatch.unused`) look colors, swatch names, links, tints and gradients up in hash sets instead of searching every swatch and color for each color or linked paint (New Color Group from artwork too), and New Swatch with several colors (`swatch.new`), New Color Group (`swatch.newGroup`) and Edit Color Group (`swatch.editGroup`) name their new swatches from one set of the names already taken. The new swatches keep their names, order and links, and Select All Unused lists the same swatches. Evidence: `add_used_colors_adds_each_new_color_once_with_free_names`, `add_used_colors_global_links_every_paint_of_a_new_color`, `unused_matches_links_tints_colors_gradients_and_patterns_exactly`, `new_swatches_from_colors_are_numbered_after_names_already_taken`, `new_color_group_numbers_its_colors_after_the_group_and_its_moved_swatches`, `new_color_group_from_artwork_lists_each_color_once_with_free_names` and `edit_color_group_numbers_extra_colors_after_the_names_it_keeps` in `crates/engine/src/tests/swatchcmds.rs`; `free_swatch_names_match_naming_each_swatch_as_it_is_added` and `free_swatch_names_match_new_swatch_names_as_swatches_are_added` in `crates/doc/src/swatches.rs`. - **Colorized images (#1466):** a fill color applied to a selected grayscale or 1-bit image (`paint.setFill`) colorizes it with black, white, a process color or a spot color, and refuses a process color when the image has an alpha channel ([Convert color modes](https://web.archive.org/web/20251220223850/https://helpx.adobe.com/illustrator/desktop/manage-colors/select-and-adjust-colors/convert-color-modes.html), "Colorize Grayscale or 1-bit images"); black and white are accepted there too. The canvas draws each pixel in the color as far as the pixel is dark, with white pixels transparent; PDF export writes the image in the color (a spot color as its ink, Registration in every ink) with the image as its soft mask; Separations Preview and printed separations show it on its color's plates; native files keep the color. SVG writes the fill color through a coverage mask (or a colorized PNG in Tiny 1.2); EPS writes binary coverage as an image mask in the color's ink, and a grayscale image as ink samples scaled by coverage, with white pixels left out at Level 3 and gray midtones written over white. From aeeb90e60c4badc15920bfdaa1928c9a981b978d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 21:41:04 -0800 Subject: [PATCH 16/43] Save and Export: the hover's note list states its limits The hover lists up to 30 notes, fewer when the window is small, and says how many were left out. The docs and command description said it lists them all. --- crates/ui-egui/src/io/export.rs | 2 +- crates/ui-egui/src/io/save.rs | 3 ++- crates/ui-egui/src/menus/ui_commands.rs | 2 +- docs/mcp.md | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/io/export.rs b/crates/ui-egui/src/io/export.rs index 246511669e..7e5bc01380 100644 --- a/crates/ui-egui/src/io/export.rs +++ b/crates/ui-egui/src/io/export.rs @@ -244,7 +244,7 @@ pub(crate) fn open_original(app: &mut VectorcraftApp, path: &str) -> Result<(), /// back as base64. With `openLocation`, the file manager shows the first file written. When an /// export into a folder or a download has warnings, the status bar shows them as [`report_saved`] /// shows a save's notes: "Saved with n notes: …", and hovering the -/// message lists them all. +/// message lists up to 30 of them, fewer when the window is small, with how many were left out. pub fn export_for_screens(app: &mut VectorcraftApp, params: Value) -> Result { export_files(app, "document.exportForScreens", params) } diff --git a/crates/ui-egui/src/io/save.rs b/crates/ui-egui/src/io/save.rs index 708557fc4a..42b76ce2d8 100644 --- a/crates/ui-egui/src/io/save.rs +++ b/crates/ui-egui/src/io/save.rs @@ -395,7 +395,8 @@ pub(super) fn view_file(app: &mut VectorcraftApp, path: &str) { } /// "Saved ", with the first of the result's warnings and how many there are; hovering the -/// message lists them all ([`show_with_notes`]). +/// message lists up to 30 of them, fewer when the window is small, with how many were left out +/// ([`show_with_notes`]). pub(crate) fn report_saved(app: &mut VectorcraftApp, path: &str, r: &Value) { show_with_notes(app, saved_status(path, r), notes_of(r)); } diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 6c842814e1..f958afefdd 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -1487,7 +1487,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "css.exportFile", "Export CSS…", "", - "{path?, scope?: selection|all (default selection), …css.selection options} css.export written to path, else a picked file (the web downloads it), with the pictures of rasterized art next to it (CSS Properties panel: Export Selected CSS…, Export All…) → {path, rules, images: [path…], warnings (css.export's; the status bar shows the first and how many there are, and hovering the message lists them all)}", + "{path?, scope?: selection|all (default selection), …css.selection options} css.export written to path, else a picked file (the web downloads it), with the pictures of rasterized art next to it (CSS Properties panel: Export Selected CSS…, Export All…) → {path, rules, images: [path…], warnings (css.export's; the status bar shows the first and how many there are, and hovering the message lists up to 30 of them, fewer when the window is small, with how many were left out)}", ), ( "file.print", diff --git a/docs/mcp.md b/docs/mcp.md index 4905ee30f7..a9b3b0be1f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6386,7 +6386,7 @@ writes the `.css` file with the PNGs of rasterized art next to it (no path: `dat `warnings` add the PNGs' notes to the CSS's, each once (an image that couldn't be decoded, type written in the fallback font…). In the app, `css.copy` copies the CSS and `css.exportFile` writes it through a save panel (the web downloads it) and shows the first note in the status bar, with how many there are; resting the pointer on the -message lists them all. +message lists up to 30 of them, fewer when the list would not fit in the window, with how many were left out. ```json {"name":"run_command","arguments":{"command":"css.selection","params":{"units":"px","position":true}}} From 386e405800390afd6d8b675fdcd103ee078e912c Mon Sep 17 00:00:00 2001 From: HassanKhan Date: Thu, 12 Nov 2026 10:47:06 +0500 Subject: [PATCH 17/43] Commands: draw_shape and object.reflect say which way they turn; test the notes the key walk misses (#6340) draw_shape's rotation also turns rectangles and ellipses; object.reflect's numeric axis is counter-clockwise from horizontal. A test covers that axis and the Scatter brush's rotation range, which every_turn_param_says_which_way_it_turns can't reach. --- crates/engine/src/cmd/object/commands.rs | 2 +- crates/engine/src/tests/turn_direction.rs | 9 +++++++++ crates/mcp/src/tools/definitions.rs | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/object/commands.rs b/crates/engine/src/cmd/object/commands.rs index eb019a88e9..5c66ec8442 100644 --- a/crates/engine/src/cmd/object/commands.rs +++ b/crates/engine/src/cmd/object/commands.rs @@ -52,7 +52,7 @@ pub fn specs() -> Vec { "Reflect…", ["Object", "Transform"], None, - "{ids?: [id…] (selected first; default: the selection), axis: \"vertical\"|\"horizontal\"|deg, origin?, copy?, objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size)}. With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.", + "{ids?: [id…] (selected first; default: the selection), axis: \"vertical\"|\"horizontal\"|deg (the mirror line, counter-clockwise from horizontal; 0: horizontal; omitted: vertical), origin?, copy?, objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size)}. With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.", has_selection, reflect ), diff --git a/crates/engine/src/tests/turn_direction.rs b/crates/engine/src/tests/turn_direction.rs index f12f0d1962..a8961f3c46 100644 --- a/crates/engine/src/tests/turn_direction.rs +++ b/crates/engine/src/tests/turn_direction.rs @@ -114,3 +114,12 @@ fn keys_named(doc: &str) -> Vec { } found.into_iter().map(|(k, _)| k).collect() } + +/// Turn notes the key walk above doesn't reach: a numeric reflection axis and the Scatter brush's +/// `rotation [a, b]` range, whose head isn't a plain key. +#[test] +fn turns_outside_plain_keys_say_which_way() { + let doc = |id: &str| command_specs().iter().find(|c| c.id == id).map(|c| c.params).unwrap_or_default(); + assert!(doc("object.reflect").contains("|deg (the mirror line, counter-clockwise from horizontal"), "object.reflect's numeric axis"); + assert!(doc("brush.options").contains("rotation [a, b] −180..180° (counter-clockwise)"), "the Scatter brush's rotation"); +} diff --git a/crates/mcp/src/tools/definitions.rs b/crates/mcp/src/tools/definitions.rs index 17c7ecc912..781a2bf14d 100644 --- a/crates/mcp/src/tools/definitions.rs +++ b/crates/mcp/src/tools/definitions.rs @@ -259,7 +259,7 @@ pub fn tool_definitions() -> Vec { "radius1": num("Star outer radius"), "radius2": num("Star inner radius"), "points": {"type": "integer", "minimum": 3, "description": "Star points (default 5)"}, - "rotation": num("Rotation in degrees clockwise (polygon/star; 0: a corner or point straight up)"), + "rotation": num("Rotation in degrees clockwise about the centre (rectangle/ellipse: 0 upright; polygon/star: 0 a corner or point straight up)"), "x1": num("Line start x"), "y1": num("Line start y"), "x2": num("Line end x"), From 283e1267f6823e6b8370432e53b1338e89cd9fab Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 21:50:54 -0800 Subject: [PATCH 18/43] Script checker: names with escaped characters are read as names - A name may hold \u{...} escapes with any number of hexadecimal digits up to U+10FFFF. An empty, unclosed, out-of-range or surrogate escape is not part of a name. Each \u{...} escape read in a name is reported as syntax newer than ES3, with the escape as written. - # followed by an escaped name start is one private name, also in the code of an XML literal, and its escapes are read in the report (this.#app is #app). - A # and a name at the start of a line is a private name, not a directive, when the name starts with an escape, or when the line is inside braces and the name is not a directive's. A private name whose decoded name would make the line a directive is reported as written. - The identifier test against vectorcraft-js also compares braced escapes. Docs: docs/mcp.md, docs/target-app-parity.md. --- .../tests/it/js_lexer_vs_scriptcheck.rs | 12 ++ crates/scriptcheck/src/analyze.rs | 104 +++++++++---- crates/scriptcheck/src/includes.rs | 4 +- crates/scriptcheck/src/lex.rs | 89 +++++++++-- crates/scriptcheck/src/lib.rs | 33 +++-- crates/scriptcheck/src/tests.rs | 138 +++++++++++++++++- docs/mcp.md | 11 +- docs/target-app-parity.md | 2 +- 8 files changed, 331 insertions(+), 62 deletions(-) diff --git a/crates/engine/tests/it/js_lexer_vs_scriptcheck.rs b/crates/engine/tests/it/js_lexer_vs_scriptcheck.rs index ba029dd3b1..db8696a10f 100644 --- a/crates/engine/tests/it/js_lexer_vs_scriptcheck.rs +++ b/crates/engine/tests/it/js_lexer_vs_scriptcheck.rs @@ -115,15 +115,27 @@ fn both_lexers_take_the_same_characters_into_names() { assert!(checker_member("\u{88F}\u{1ACF}", "\u{88F}\u{1ACF}") && one_identifier("\u{88F}\u{1ACF}", "\u{88F}\u{1ACF}")); for &c in NAME_CHARS { let escape = format!("\\u{:04X}", u32::from(c)); + let braced = format!("\\u{{{:X}}}", u32::from(c)); for (src, name) in [ (format!("a{c}b"), format!("a{c}b")), (format!("{c}b"), format!("{c}b")), (format!("a{escape}b"), format!("a{c}b")), (format!("{escape}b"), format!("{c}b")), + (format!("a{braced}b"), format!("a{c}b")), + (format!("{braced}b"), format!("{c}b")), ] { assert_eq!(checker_member(&src, &name), one_identifier(&src, &name), "{src:?} (U+{:04X})", u32::from(c)); } } + // Braced escapes past U+FFFF and with leading zeros are read by both. + for (src, name) in [("\\u{1D49C}b", "\u{1D49C}b"), ("a\\u{0000000062}", "ab")] { + assert!(checker_member(src, name) && one_identifier(src, name), "{src:?}"); + } + // Out of range, a surrogate and an empty one: the parser reads no identifier, and the checker's + // name ends before the escape. + for src in ["a\\u{110000}", "a\\u{D800}", "a\\u{}"] { + assert!(checker_member(src, "a") && !one_identifier(src, "a"), "{src:?}"); + } } #[test] diff --git a/crates/scriptcheck/src/analyze.rs b/crates/scriptcheck/src/analyze.rs index 9a637274df..b8f887ff37 100644 --- a/crates/scriptcheck/src/analyze.rs +++ b/crates/scriptcheck/src/analyze.rs @@ -88,12 +88,25 @@ pub(crate) struct Scan { /// Scan `src` (already through [`unescaped`]): the script itself (`file` none) or a file it includes /// (`file`: its path inside the script's folder), with the names it declares (`declared`, from /// [`declarations`] on the same `src`), the `#includepath` folders `paths` in force at its start and -/// its variables holding what `known` says. -pub(crate) fn scan<'s>(src: &'s str, declared: &Declared<'s>, file: Option<&str>, paths: Rc<[String]>, known: &Known, acc: &mut Acc) -> Scan { +/// its variables holding what `known` says. Each of the `\u{…}` escapes `braced` that [`unescaped`] +/// read is syntax later than ECMAScript 3, reported when the scan reaches the escape's line. +pub(crate) fn scan<'s>( + src: &'s str, + declared: &Declared<'s>, + braced: &[Braced], + file: Option<&str>, + paths: Rc<[String]>, + known: &Known, + acc: &mut Acc, +) -> Scan { let mut lexer = Lexer::new(src, acc.limits.nesting); let mut a = Analyzer::new(acc, file, paths, known, declared); let mut breaks = LineBreaks::default(); + let mut braced = braced.iter().peekable(); for t in lexer.by_ref() { + while let Some((line, escape)) = braced.next_if(|(line, _)| *line <= t.line) { + a.acc.problem(report::NEWER_SYNTAX, Some(*line), file, Some(escape)); + } if let Some(code) = t.problem { a.problem(code, t.line); } @@ -103,6 +116,9 @@ pub(crate) fn scan<'s>(src: &'s str, declared: &Declared<'s>, file: Option<&str> Read::Token(sig) => a.token(sig), } } + for (line, escape) in braced { + a.acc.problem(report::NEWER_SYNTAX, Some(*line), file, Some(escape)); + } if let Some(line) = lexer.unclosed_xml { a.problem(report::UNTERMINATED_XML, line); } @@ -115,29 +131,61 @@ pub(crate) fn scan<'s>(src: &'s str, declared: &Declared<'s>, file: Option<&str> Scan { includes, module, lines: lexer.lines(), known } } -/// `src` with the `\uXXXX` escapes in its names written as the characters they stand for (ECMA-262 -/// 3rd edition, section 7.6), and `src` itself when no name has one. An escape for a character a -/// name may not hold where the escape stands stays as written. Line breaks don't change, so every -/// line keeps its number. -pub(crate) fn unescaped<'a>(src: Cow<'a, str>, acc: &mut Acc) -> Cow<'a, str> { - match unescape_names(&src, acc) { - Some(out) => Cow::Owned(out), - None => src, +/// A `\u{…}` escape in a name, which ECMAScript 3 does not have: its line and its text. +pub(crate) type Braced = (usize, String); + +/// `src` with the `\uXXXX` and `\u{…}` escapes in its names and private names written as the +/// characters they stand for (ECMA-262, Names and Keywords), and `src` itself when no name has one, +/// with the `\u{…}` escapes read, each listed once per line in a row. An escape for a character a +/// name may not hold where the escape stands stays as written, and so does a private name that +/// starts a line where the name it spells would start a preprocessor line. Line breaks don't +/// change, so every line keeps its number. +pub(crate) fn unescaped<'a>(src: Cow<'a, str>, acc: &mut Acc) -> (Cow<'a, str>, Vec) { + let mut braced = vec![]; + match unescape_names(&src, acc, &mut braced) { + Some(out) => (Cow::Owned(out), braced), + None => (src, braced), } } -fn unescape_names(src: &str, acc: &mut Acc) -> Option { +fn unescape_names(src: &str, acc: &mut Acc, braced: &mut Vec) -> Option { if !src.contains("\\u") { return None; } let mut out = String::with_capacity(src.len()); let mut changed = false; let mut lexer = Lexer::new(src, acc.limits.nesting); - for t in lexer.by_ref() { - if t.kind == T::Ident && t.text.contains("\\u") { - let name = unescape_name(t.text); - changed |= name != t.text; - out.push_str(&name); + // One more than the report keeps, so that the report is marked truncated. + let room = acc.limits.entries.saturating_add(1); + // Only blanks since the last line break (or the start). + let mut line_start = true; + let mut found = vec![]; + while let Some(t) = lexer.next() { + // A private name is `#` and a name. + let (mark, name) = match t.kind { + T::Ident => ("", t.text), + T::Other => t.text.strip_prefix('#').map_or(("", ""), |name| ("#", name)), + _ => ("", ""), + }; + let starts_line = line_start; + line_start = t.kind == T::Newline || (line_start && t.kind == T::Space); + if name.contains("\\u") { + found.clear(); + let written = unescape_name(name, t.line, &mut found, room); + for escape in found.drain(..) { + if braced.last() != Some(&escape) && braced.len() < room { + braced.push(escape); + } + } + // A private name that starts a line stays as written where the name it spells would + // start a preprocessor line. + if mark == "#" && starts_line && !lexer.private_line(&written) { + out.push_str(t.text); + continue; + } + changed |= written != name; + out.push_str(mark); + out.push_str(&written); } else { out.push_str(t.text); } @@ -146,25 +194,31 @@ fn unescape_names(src: &str, acc: &mut Acc) -> Option { changed.then_some(out) } -/// The name `text` with each `\uXXXX` escape for a character a name may hold where it stands written -/// as that character: a character a name may start with first, and one a name may hold after that. -fn unescape_name(text: &str) -> String { +/// The name `text`, on `line`, with each escape for a character a name may hold where it stands +/// written as that character: a character a name may start with first, and one a name may hold +/// after that. Each `\u{…}` escape read goes into `braced`, unless the last one there is the same +/// escape on the same line or `braced` holds `room` of them. +fn unescape_name(text: &str, line: usize, braced: &mut Vec, room: usize) -> String { let mut out = String::with_capacity(text.len()); let mut rest = text; while let Some(i) = rest.find("\\u") { out.push_str(rest.get(..i).unwrap_or_default()); - let after = rest.get(i + 2..).unwrap_or_default(); + let at = rest.get(i..).unwrap_or_default(); let first = out.is_empty(); - let c = crate::lex::escaped(rest.get(i..).unwrap_or_default()) - .filter(|&c| if first { crate::lex::ident_start(c) } else { crate::lex::ident_part(c) }); + let c = crate::lex::escape(at).filter(|&(c, _)| if first { crate::lex::ident_start(c) } else { crate::lex::ident_part(c) }); match c { - Some(c) => { + Some((c, len)) => { out.push(c); - rest = after.get(4..).unwrap_or_default(); + let source = at.get(..len).unwrap_or_default(); + let repeated = braced.last().is_some_and(|(l, s)| *l == line && s == source); + if source.starts_with("\\u{") && !repeated && braced.len() < room { + braced.push((line, source.to_string())); + } + rest = at.get(len..).unwrap_or_default(); } None => { out.push_str("\\u"); - rest = after; + rest = at.get(2..).unwrap_or_default(); } } } diff --git a/crates/scriptcheck/src/includes.rs b/crates/scriptcheck/src/includes.rs index f6e9c82b09..b4a7be7219 100644 --- a/crates/scriptcheck/src/includes.rs +++ b/crates/scriptcheck/src/includes.rs @@ -141,9 +141,9 @@ pub(crate) fn follow( read.insert(path.clone(), (status, Some(file))); let (text, _) = crate::decode(&b); let mut tallies = Acc::new(limits); - let text = analyze::unescaped(text, &mut tallies); + let (text, _) = analyze::unescaped(text, &mut tallies); let declared = analyze::declarations(&text, &mut tallies); - let scan = analyze::scan(&text, &declared, Some(&path), Rc::clone(&inc.paths), known, &mut tallies); + let scan = analyze::scan(&text, &declared, &[], Some(&path), Rc::clone(&inc.paths), known, &mut tallies); acc.steps = acc.steps.saturating_add(tallies.steps); known.add(scan.known, limits.entries); checked.push(ReadFile { path: path.clone(), bytes: b, paths: Rc::clone(&inc.paths) }); diff --git a/crates/scriptcheck/src/lex.rs b/crates/scriptcheck/src/lex.rs index 715a0582b0..02d8c2de03 100644 --- a/crates/scriptcheck/src/lex.rs +++ b/crates/scriptcheck/src/lex.rs @@ -41,7 +41,9 @@ pub(crate) enum T { /// end or the next `{`. XmlMore, Punct, - /// A character no token starts with, or `#` and a name away from the start of a line. + /// A character no token starts with, or a private name: `#` and a name away from the start of a + /// line, or at its start where the name starts with an escape, or inside braces where the name + /// is not a directive's. Other, } @@ -166,14 +168,33 @@ pub(crate) fn ident_part(c: char) -> bool { } } -/// The character the `\uXXXX` escape at the start of `s` stands for: `None` when `s` doesn't start -/// with `\u` and four hexadecimal digits, or when they give a lone surrogate. -pub(crate) fn escaped(s: &str) -> Option { - s.strip_prefix("\\u")? - .get(..4) - .filter(|hex| hex.bytes().all(|b| b.is_ascii_hexdigit())) - .and_then(|hex| u32::from_str_radix(hex, 16).ok()) - .and_then(char::from_u32) +/// The character the escape at the start of `s` stands for and the escape's length in bytes: `\u` +/// and four hexadecimal digits, or `\u{…}` with one or more hexadecimal digits of a value up to +/// U+10FFFF, leading zeros allowed (ECMA-262, UnicodeEscapeSequence and CodePoint). `None` when `s` +/// starts with neither, or when the escape gives a lone surrogate. +pub(crate) fn escape(s: &str) -> Option<(char, usize)> { + let rest = s.strip_prefix("\\u")?; + let Some(braced) = rest.strip_prefix('{') else { + let c = rest + .get(..4) + .filter(|hex| hex.bytes().all(|b| b.is_ascii_hexdigit())) + .and_then(|hex| u32::from_str_radix(hex, 16).ok()) + .and_then(char::from_u32)?; + return Some((c, 6)); + }; + // The value stops growing past U+10FFFF, so any number of digits is read once and never + // overflows. + let mut value: u32 = 0; + for (i, b) in braced.bytes().enumerate() { + match b { + b'}' if i > 0 => return char::from_u32(value).map(|c| (c, i + 4)), + _ => { + let digit = char::from(b).to_digit(16)?; + value = (value * 16 + digit).min(0x11_0000); + } + } + } + None } /// `Other_ID_Start` (Unicode PropList 17.0): characters kept in `ID_Start` though their category is @@ -394,18 +415,56 @@ impl<'s> Lexer<'s> { } /// The rest of a name (its first character included): the characters [`ident_part`] allows, - /// written out or as `\uXXXX` escapes. An escape for any other character, or one without four - /// hexadecimal digits, ends the name. + /// written out or as `\uXXXX` or `\u{…}` escapes ([`escape`]). An escape for any other + /// character, or a malformed one, ends the name. Each character of an escape is a step. fn name(&mut self) { loop { match self.peek() { - Some('\\') if escaped(self.rest()).is_some_and(ident_part) => self.skip(6), + Some('\\') => match escape(self.rest()) { + Some((c, len)) if ident_part(c) => { + self.pos += len; + self.steps += len; + } + _ => break, + }, Some(c) if ident_part(c) => self.bump(c), _ => break, } } } + /// A name starts right after the `#` here: its first character written out, or an escape for a + /// character a name may start with. + fn private_name_follows(&self) -> bool { + let after = self.rest().get(1..).unwrap_or_default(); + match after.chars().next() { + Some('\\') => escape(after).is_some_and(|(c, _)| ident_start(c)), + Some(c) => ident_start(c), + None => false, + } + } + + /// The `#` at the start of this line starts a private name, not a preprocessor line: the name + /// starts with an escape, which no directive's name holds, or [`Lexer::private_line`] holds for + /// it. + fn private_at_line_start(&self) -> bool { + if !self.private_name_follows() { + return false; + } + let after = self.rest().get(1..).unwrap_or_default(); + after.starts_with('\\') || self.private_line(after) + } + + /// A line that starts with `#` and then `rest` is a private name here, not a preprocessor line: + /// the line is inside braces or in the code of an XML literal, and the name `rest` starts with + /// is not a directive's. + pub fn private_line(&self, rest: &str) -> bool { + let in_code = !self.braces.is_empty() || self.deeper_braces > 0 || !self.xml.is_empty(); + let end = rest.find(|c: char| !c.is_ascii_alphanumeric() && c != '_').unwrap_or(rest.len()); + let name = rest.get(..end).unwrap_or_default(); + in_code && !catalog::DIRECTIVES.iter().any(|d| d.eq_ignore_ascii_case(name)) + } + fn digits(&mut self, ok: fn(char) -> bool) { while let Some(c) = self.peek().filter(|&c| ok(c) || c == '_') { self.bump(c); @@ -871,10 +930,10 @@ impl<'s> Iterator for Lexer<'s> { } else if self.starts("/*") { problem = self.block_comment(); T::Comment - } else if c == '#' && self.line_start { + } else if c == '#' && self.line_start && !self.private_at_line_start() { self.until_line_break(); T::Directive - } else if c == '#' && self.peek_at(1).is_some_and(ident_start) { + } else if c == '#' && self.private_name_follows() { self.bump('#'); self.name(); T::Other @@ -906,7 +965,7 @@ impl<'s> Iterator for Lexer<'s> { self.close_level(); self.bump('}'); return Some(self.markup_token(start, line, T::XmlMore, stmt, expr)); - } else if ident_start(c) || (c == '\\' && escaped(self.rest()).is_some_and(ident_start)) { + } else if ident_start(c) || (c == '\\' && escape(self.rest()).is_some_and(|(c, _)| ident_start(c))) { self.name(); T::Ident } else if c.is_ascii_digit() || (c == '.' && self.peek_at(1).is_some_and(|d| d.is_ascii_digit())) { diff --git a/crates/scriptcheck/src/lib.rs b/crates/scriptcheck/src/lib.rs index 0c62419f1c..2bd8c60fba 100644 --- a/crates/scriptcheck/src/lib.rs +++ b/crates/scriptcheck/src/lib.rs @@ -166,14 +166,14 @@ pub fn check(name: &str, bytes: &[u8], loader: Option<&mut dyn Loader>, limits: if invalid { acc.problem(report::NOT_UTF8, None, None, None); } - let text = analyze::unescaped(text, &mut acc); + let (text, braced) = analyze::unescaped(text, &mut acc); let declared = analyze::declarations(&text, &mut acc); let none = analyze::Known::default(); // With a loader, this first scan finds the includes and what the script's variables hold; its // tallies are left out. let mut tallies = report::Acc::new(limits); let pre = acc.clone(); - let scan = analyze::scan(&text, &declared, None, std::rc::Rc::from([]), &none, if loader.is_some() { &mut tallies } else { &mut acc }); + let scan = analyze::scan(&text, &declared, &braced, None, std::rc::Rc::from([]), &none, if loader.is_some() { &mut tallies } else { &mut acc }); acc.steps = acc.steps.saturating_add(tallies.steps); if scan.module { // A module's report holds its kind and its lines, and none of what the scan found. @@ -183,7 +183,7 @@ pub fn check(name: &str, bytes: &[u8], loader: Option<&mut dyn Loader>, limits: return acc.finish(Kind::Module, scan.lines); } let Some(loader) = loader else { - let mut acc = settle(acc, pre, &text, &declared, scan.known); + let mut acc = settle(acc, pre, &text, &declared, &braced, scan.known); includes::not_read(&mut acc, scan.includes); return acc.finish(Kind::Script, scan.lines); }; @@ -191,17 +191,23 @@ pub fn check(name: &str, bytes: &[u8], loader: Option<&mut dyn Loader>, limits: let read = includes::follow(&mut acc, scan.includes, loader, name, bytes.len(), &mut known); // Each file's text and the names it declares are worked out once for all the scans below. let decoded: Vec<_> = read.iter().map(|file| decode(&file.bytes)).collect(); - let texts: Vec<_> = decoded.into_iter().map(|(text, invalid)| (analyze::unescaped(text, &mut acc), invalid)).collect(); - let declarations: Vec<_> = texts.iter().map(|(text, _)| analyze::declarations(text, &mut acc)).collect(); + let texts: Vec<_> = decoded + .into_iter() + .map(|(text, invalid)| { + let (text, braced) = analyze::unescaped(text, &mut acc); + (text, braced, invalid) + }) + .collect(); + let declarations: Vec<_> = texts.iter().map(|(text, ..)| analyze::declarations(text, &mut acc)).collect(); // A variable may get what it holds from another file's variable: the files are scanned again, // each with what is known so far, while that grows, at most three more times. for _ in 0..3 { let before = known.len(); let mut tallies = report::Acc::new(limits); - let more = analyze::scan(&text, &declared, None, std::rc::Rc::from([]), &known, &mut tallies).known; + let more = analyze::scan(&text, &declared, &[], None, std::rc::Rc::from([]), &known, &mut tallies).known; known.add(more, limits.entries); - for ((file, (text, _)), declared) in read.iter().zip(&texts).zip(&declarations) { - let more = analyze::scan(text, declared, Some(&file.path), std::rc::Rc::clone(&file.paths), &known, &mut tallies).known; + for ((file, (text, ..)), declared) in read.iter().zip(&texts).zip(&declarations) { + let more = analyze::scan(text, declared, &[], Some(&file.path), std::rc::Rc::clone(&file.paths), &known, &mut tallies).known; known.add(more, limits.entries); } acc.steps = acc.steps.saturating_add(tallies.steps); @@ -210,12 +216,12 @@ pub fn check(name: &str, bytes: &[u8], loader: Option<&mut dyn Loader>, limits: } } // The scans for the report: every file starts with what any file's variables hold. - let lines = analyze::scan(&text, &declared, None, std::rc::Rc::from([]), &known, &mut acc).lines; - for ((file, (text, invalid)), declared) in read.iter().zip(&texts).zip(&declarations) { + let lines = analyze::scan(&text, &declared, &braced, None, std::rc::Rc::from([]), &known, &mut acc).lines; + for ((file, (text, braced, invalid)), declared) in read.iter().zip(&texts).zip(&declarations) { if *invalid { acc.problem(report::NOT_UTF8, None, Some(&file.path), None); } - analyze::scan(text, declared, Some(&file.path), std::rc::Rc::clone(&file.paths), &known, &mut acc); + analyze::scan(text, declared, braced, Some(&file.path), std::rc::Rc::clone(&file.paths), &known, &mut acc); } acc.finish(Kind::Script, lines) } @@ -230,6 +236,7 @@ fn settle<'l>( pre: report::Acc<'l>, text: &str, declared: &analyze::Declared<'_>, + braced: &[analyze::Braced], mut known: analyze::Known, ) -> report::Acc<'l> { if known.len() == 0 { @@ -240,7 +247,7 @@ fn settle<'l>( for _ in 0..3 { let before = known.len(); let mut acc = pre.clone(); - let more = analyze::scan(text, declared, None, std::rc::Rc::from([]), &known, &mut acc).known; + let more = analyze::scan(text, declared, braced, None, std::rc::Rc::from([]), &known, &mut acc).known; known.add(more, entries); if known.len() == before { acc.steps = acc.steps.saturating_add(spent); @@ -249,7 +256,7 @@ fn settle<'l>( spent = spent.saturating_add(acc.steps.saturating_sub(pre.steps)); } let mut acc = pre; - analyze::scan(text, declared, None, std::rc::Rc::from([]), &known, &mut acc); + analyze::scan(text, declared, braced, None, std::rc::Rc::from([]), &known, &mut acc); acc.steps = acc.steps.saturating_add(spent); acc } diff --git a/crates/scriptcheck/src/tests.rs b/crates/scriptcheck/src/tests.rs index 31cbef823a..19cee61603 100644 --- a/crates/scriptcheck/src/tests.rs +++ b/crates/scriptcheck/src/tests.rs @@ -271,7 +271,7 @@ fn names_written_with_escapes_are_read_as_the_names_they_spell() { /// `src` with the escapes in its names written as the characters they stand for. fn unescaped(src: &str) -> String { let limits = Limits::default(); - crate::analyze::unescaped(std::borrow::Cow::Borrowed(src), &mut crate::report::Acc::new(&limits)).into_owned() + crate::analyze::unescaped(std::borrow::Cow::Borrowed(src), &mut crate::report::Acc::new(&limits)).0.into_owned() } #[test] @@ -364,6 +364,140 @@ fn malformed_escapes_in_bulk_are_read_once_through() { assert_eq!(r.lines, 3); assert_eq!(feature(&r, "notify").map(|f| f.lines.as_slice()), Some(&[3][..])); assert!(r.steps <= 5 * src.len() + 16, "{} steps for {} bytes", r.steps, src.len()); + // Braced escapes: long runs of digits, closed and not, and ones that end at once. + let zeros = "0".repeat(100_000); + let src = format!( + "a\\u{{{zeros}62}};\n\\u{{{}\n{}\n{}\nalert(1);\n", + "f".repeat(100_000), + "\\u{".repeat(20_000), + "\\u{0000000061}\\u{110000}\\u{}\\u{D800}#\\u{61}".repeat(10_000) + ); + let r = report(&src); + assert_eq!(r.lines, 5); + assert_eq!(feature(&r, "notify").map(|f| f.lines.as_slice()), Some(&[5][..])); + assert!(r.steps <= 5 * src.len() + 16, "{} steps for {} bytes", r.steps, src.len()); +} + +#[test] +fn braced_escapes_in_names_are_read_by_their_value() { + for (src, want) in [ + ("\\u{61}pp", "app"), + ("a\\u{62}c", "abc"), + ("\\u{0000000061}", "a"), + ("\\u{2118}\\u{203F}", "\u{2118}\u{203F}"), + ("a\\u{1D7CE}", "a\u{1D7CE}"), + ("\\u{1D49C}", "\u{1D49C}"), + // Not at the start of a name, in no name, out of range, empty, a surrogate and not closed. + ("\\u{30}a", "\\u{30}a"), + ("a\\u{1F600}", "a\\u{1F600}"), + ("a\\u{110000}", "a\\u{110000}"), + ("a\\u{FFFFFFFFFFFFFFFFFFFF}b", "a\\u{FFFFFFFFFFFFFFFFFFFF}b"), + ("a\\u{}b", "a\\u{}b"), + ("a\\u{D800}", "a\\u{D800}"), + ("a\\u{61", "a\\u{61"), + ("a\\u{6 1}", "a\\u{6 1}"), + ] { + assert_eq!(unescaped(src), want, "{src:?}"); + } + assert_eq!(tokens("\\u{61}pp.x"), [(T::Ident, "\\u{61}pp"), (T::Punct, "."), (T::Ident, "x")]); + assert_eq!(tokens("a\\u{0000000062}"), [(T::Ident, "a\\u{0000000062}")]); + assert_eq!(tokens("a\\u{1F600}").get(..2), Some(&[(T::Ident, "a"), (T::Other, "\\")][..])); +} + +#[test] +fn braced_name_escape_reports_later_syntax_and_member() { + let r = report("\\u{61}pp.activeDocument.acmeName;\nvar a\\u{62}c = 1, \\u{0000000061}pp2 = 2;\n\\u{61}pp.x + \\u{61}pp.y;\n"); + let members: Vec<&str> = r.members.iter().map(|m| m.name.as_str()).collect(); + assert_eq!(members, ["app.activeDocument.acmeName", "app.x", "app.y"]); + assert_eq!( + problems(&r), + [ + ("newerSyntax", Some(1), Some("\\u{61}")), + ("newerSyntax", Some(2), Some("\\u{62}")), + ("newerSyntax", Some(2), Some("\\u{0000000061}")), + ("newerSyntax", Some(3), Some("\\u{61}")), + ] + ); + // An escape that isn't a name's character, or isn't closed, makes no name and no report of one. + for src in [ + "\\u{1F600}pp.activeDocument;", + "\\u{110000}pp.activeDocument;", + "\\u{}pp.activeDocument;", + "\\u{D800}pp.activeDocument;", + "\\u{61pp.activeDocument;", + ] { + let r = report(src); + assert!(r.members.iter().all(|m| !m.name.starts_with("app")), "{src:?}: {:?}", r.members); + assert!(r.problems.iter().all(|p| p.detail.as_deref().is_none_or(|d| !d.starts_with("\\u{"))), "{src:?}: {:?}", r.problems); + } + // In an included file, the report names the file. + let mut m = mem(&[("lib.jsxinc", "\\u{61}pp.name;\n")]); + let r = check("main.jsx", b"#include 'lib.jsxinc'\n", Some(&mut m), &Limits::default()); + let found: Vec<_> = r.problems.iter().map(|p| (p.code, p.line, p.file.as_deref(), p.detail.as_deref())).collect(); + assert_eq!(found, [("newerSyntax", Some(1), Some("lib.jsxinc"), Some("\\u{61}"))]); +} + +#[test] +fn private_names_with_escaped_starts_are_single_tokens() { + assert_eq!(tokens("a #\\u0061b"), [(T::Ident, "a"), (T::Other, "#\\u0061b")]); + assert_eq!(tokens("this.#\\u0061pp"), [(T::Ident, "this"), (T::Punct, "."), (T::Other, "#\\u0061pp")]); + assert_eq!(tokens("o.#\\u{61}b"), [(T::Ident, "o"), (T::Punct, "."), (T::Other, "#\\u{61}b")]); + // An escape for a character no name starts with leaves `#` on its own. + assert_eq!(tokens("a #\\u0030b").get(..3), Some(&[(T::Ident, "a"), (T::Other, "#"), (T::Other, "\\")][..])); + let r = report("var q = this.#\\u0061pp;\nvar r = o.#\\u{61}b;\n"); + assert!(r.members.is_empty(), "{:?}", r.members); + assert_eq!( + problems(&r), + [("newerSyntax", Some(1), Some("#app")), ("newerSyntax", Some(2), Some("\\u{61}")), ("newerSyntax", Some(2), Some("#ab"))] + ); +} + +#[test] +fn private_name_reports_decoded_construct() { + let r = report("var q = this.#hid\\u0064en;\nvar r = this.#\\u0061\\u0062;\n"); + assert_eq!(problems(&r), [("newerSyntax", Some(1), Some("#hidden")), ("newerSyntax", Some(2), Some("#ab"))]); +} + +#[test] +fn private_name_at_line_start_is_not_a_directive() { + let r = report(concat!( + "class Box {\n", + " #size = 1;\n", + " #\\u0061rea = 2;\n", + " #include 'a.jsxinc'\n", + " #\\u0069nclude = 4;\n", + " #\\u{69}nclude = 5;\n", + "}\n", + "#\\u0062ox = 3;\n", + "#pragma once\n", + "#target acme\n", + )); + let directives: Vec<_> = r.directives.iter().map(|d| (d.name, d.line)).collect(); + assert_eq!(directives, [("include", 4), ("target", 10)]); + // Where the name a private name's escapes spell would start a preprocessor line, the report + // gives the name as written. + assert_eq!( + problems(&r), + [ + ("newerSyntax", Some(1), Some("class")), + ("newerSyntax", Some(2), Some("#size")), + ("newerSyntax", Some(3), Some("#area")), + ("newerSyntax", Some(5), Some("#\\u0069nclude")), + ("newerSyntax", Some(6), Some("\\u{69}")), + ("newerSyntax", Some(6), Some("#\\u{69}nclude")), + ("newerSyntax", Some(8), Some("#\\u0062ox")), + ("unknownDirective", Some(9), Some("#pragma")), + ] + ); +} + +#[test] +fn xml_code_uses_escaped_names() { + let r = report("var x = {\\u{61}pp.activeDocument};\nvar y = \\u0061pp.z \\u{61};\n"); + let members: Vec<&str> = r.members.iter().map(|m| m.name.as_str()).collect(); + assert_eq!(members, ["app.activeDocument"]); + assert_eq!(problems(&r), [("newerSyntax", Some(1), Some("\\u{61}"))]); + assert!(tokens("var x = {this.#\\u0061b};").contains(&(T::Other, "#\\u0061b"))); } #[test] @@ -1590,7 +1724,7 @@ fn include_lines_share_the_folders_of_the_include_path_before_them() { let limits = Limits::default(); let mut acc = report::Acc::new(&limits); let declared = analyze::declarations(&src, &mut acc); - let scan = analyze::scan(&src, &declared, None, std::rc::Rc::from([]), &analyze::Known::default(), &mut acc); + let scan = analyze::scan(&src, &declared, &[], None, std::rc::Rc::from([]), &analyze::Known::default(), &mut acc); let (first, rest) = scan.includes.split_first().unwrap(); assert_eq!((first.paths.len(), rest.len()), (64, 999)); assert!(rest.iter().all(|i| std::rc::Rc::ptr_eq(&i.paths, &first.paths)), "one list of folders, not a copy for each line"); diff --git a/docs/mcp.md b/docs/mcp.md index 66e1c88c90..5cc3052681 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1627,9 +1627,9 @@ control character, in the first 8 KiB) or `tooLarge` (over 8 MiB). For a script, `menuCommand` or `e4x`), each with a count and its lines. - `dom.members` lists the document-model member chains as the script writes them (`app.activeDocument.layers[].name`, with `[]` for an index and `()` for a call); a variable assigned from a chain - is replaced by that chain, and a name written with `\u` escapes is read as the name it spells. `dom.types` lists - the type names the script compares with `.typename` or creates with `new`, apart from the language's, the platform's - and the script's own functions. + is replaced by that chain, and a name written with `\uXXXX` or `\u{…}` escapes is read as the name it spells. + `dom.types` lists the type names the script compares with `.typename` or creates with `new`, apart from the + language's, the platform's and the script's own functions. - `menuCommands.strings` lists the string literals the script passes to `executeMenuCommand` (`app.executeMenuCommand('…')`), each with a count and its lines, and `menuCommands.dynamic` counts the calls whose argument isn't a string literal. @@ -1641,7 +1641,10 @@ control character, in the first 8 KiB) or `tooLarge` (over 8 MiB). For a script, template literals and XML literals that aren't closed, brackets that don't match, syntax newer than ES3 and unknown directives) and notes on its text (bytes that aren't valid UTF-8, mixed line endings, and nesting deeper than the check follows: more than 4,096 open brackets, or XML literals and the code in their braces nested more - than 4,096 levels deep). + than 4,096 levels deep). Newer syntax includes a private name (`#name`, given with its escapes read, unless the + name they spell would make the line a directive) and a `\u{…}` escape in a name (given as written). A `#` and a + name at the start of a line is a private name, not a directive, when the name starts with an escape, or when the + line is inside braces and the name is not a directive's. Each entry of `targets`, `directives`, `includes` and `problems` has the `line` it is on (a problem with the whole text has none) and, when it is in an included file, that file's path as `file`. `features`, `menuCommands.strings` diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b2ecfe76b5..6596e2a211 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -762,7 +762,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - MCP protocol: five prompts (`prompts/list` / `prompts/get`: poster, icon set, recolour, trace-and-style, export set), `completion/complete` for prompt arguments and template variables read from the live catalogues (formats, effect ids, command ids, trace presets, swatch and object ids), four resource templates (`vectorcraft://object/{id}`, `//command/{id}`, `//effect/{id}`, `//swatch/{name}`) so an agent reads one thing instead of the whole document, and `logging/setLevel` with `notifications/message`. `window.panel` ids are case-insensitive (display labels work too), `vectorcraft://object/{id}` reads through `document.node {summary: true}` (the same reply bytes from one lookup instead of summarizing the whole document: a leaf read on a 2,000-object document drops from ~20 ms to ~0.06 ms), large containers slice with `document.node {depth?, childLimit?}` (truncated levels report `childCount`), `document.inspect` and the `inspect_document` tool slice the same way so a depth-0 read is the layer skeleton (~9.7 KB instead of ~2 MB on a 2,000-object document), `document.find` locates nodes by name/kind/text with ancestor paths, and the CLI sweeps the protocol surface over stdio against the compiled binary. `docs/mcp.md` has a Protocol section covering the capability table and what is deliberately absent (subscriptions, progress, cancellation, sampling, elicitation, Streamable HTTP — all of which need the transport to interleave reads). - Headless CLI (`vectorcraft-cli run`, `convert`, `info`, `bench`, `perf`, `mcp`). A `run` step, like a `command_batch` step over MCP, can use an earlier step's result (`"$1.id"`, #785); `run --commands FILE` (or `-` for standard input) reads the steps from a JSON array or JSON Lines, past the Windows command-line limit (#3147, `apps/vectorcraft-cli/tests/it/cli.rs` `run_reads_*`, `run_refuses_malformed_commands_naming_the_step`). - File › Scripts › Check Script… reads a script written for Illustrator (`.jsx`, `.jsxinc` or a compiled `.jsxbin`) without running any of it and reports what it needs (the document model, files, dialogs, menu commands, features only Illustrator has), what it would do outside the document, the menu commands and document-model members it uses, its directives and included files, and the problems that would stop it loading (`script.check`, which also reads `.js` files); File › Open and drag-and-drop show the report for `.jsx`, `.jsxinc` and `.jsxbin` files. - - Check Script reads names by the same identifier rule as `vectorcraft-js`: Unicode 17.0 `ID_Start` characters, `$` and `_` start a name, and `ID_Continue` characters and `$` continue it (https://www.unicode.org/reports/tr31/, https://tc39.es/ecma262/#sec-names-and-keywords). U+2118, U+203F, U+00B7 and the zero width non-joiner and joiner are name characters, and U+00B2 and U+2E2F are not. Of the characters Unicode 17.0 assigned, U+088F starts a name and U+1ACF may follow its first character. A `\uXXXX` escape is read as its character only where a name may hold it, so `\u0030abc` does not become a name that starts with a digit. Tests: `crates/scriptcheck/src/tests.rs` (`names_are_made_of_the_unicode_identifier_characters`, `an_escape_stands_for_a_character_only_where_a_name_may_hold_it`), `crates/engine/tests/it/js_lexer_vs_scriptcheck.rs` (`both_lexers_take_the_same_characters_into_names`). + - Check Script reads names by the same identifier rule as `vectorcraft-js`: Unicode 17.0 `ID_Start` characters, `$` and `_` start a name, and `ID_Continue` characters and `$` continue it (https://www.unicode.org/reports/tr31/, https://tc39.es/ecma262/#sec-names-and-keywords). U+2118, U+203F, U+00B7 and the zero width non-joiner and joiner are name characters, and U+00B2 and U+2E2F are not. Of the characters Unicode 17.0 assigned, U+088F starts a name and U+1ACF may follow its first character. A `\uXXXX` escape is read as its character only where a name may hold it, so `\u0030abc` does not become a name that starts with a digit; a `\u{…}` escape takes any number of hexadecimal digits up to U+10FFFF, and one that is empty, unclosed, out of range or a surrogate is not part of a name. Each `\u{…}` escape read in a name is reported as syntax newer than ES3, which has only the four-digit form (https://ecma-international.org/wp-content/uploads/ECMA-262_3rd_edition_december_1999.pdf, section 7.6). A private name may start with an escape (`this.#\u{61}pp` is the private name `#app`), and a private name at the start of a line inside braces is not read as a directive unless its name is a directive's. Tests: `crates/scriptcheck/src/tests.rs` (`names_are_made_of_the_unicode_identifier_characters`, `an_escape_stands_for_a_character_only_where_a_name_may_hold_it`, `braced_escapes_in_names_are_read_by_their_value`, `braced_name_escape_reports_later_syntax_and_member`, `private_names_with_escaped_starts_are_single_tokens`, `private_name_at_line_start_is_not_a_directive`), `crates/engine/tests/it/js_lexer_vs_scriptcheck.rs` (`both_lexers_take_the_same_characters_into_names`). - `vectorcraft-js` parses JavaScript as ECMAScript 5.1 defines it, with the legacy octal forms, block functions and lenient regular expressions of Annex B, into a syntax tree with source spans; it reads the `#target`, `#include` and other preprocessor lines as directives without running them, and a script that breaks the grammar or an early-error rule gives a `SyntaxError` with its line and column. Nesting past the parser's depth limit (`Options::max_depth`, 200 by default) gives a `SyntaxError` instead of exhausting the stack. `cargo xtask test262` fetches the conformance suite at a pinned commit and parses its ES5 tests: 5,411 of 5,418 runs pass (the 7 others use `let` and `const`). This entry covers parsing; the next entry covers running scripts. Tests: `crates/js/src/tests.rs`, `crates/engine/tests/it/js_lexer_vs_scriptcheck.rs`. - `vectorcraft-js` identifiers, written out or as `\uXXXX` and `\u{...}` escapes, take the characters Unicode's `ID_Start` and `ID_Continue` properties allow (https://www.unicode.org/reports/tr31/), plus `$` and, at the start, `_`, as later ECMAScript editions define them (https://tc39.es/ecma262/#sec-names-and-keywords). `Other_ID_Start` characters such as U+2118 and `Other_ID_Continue` characters such as U+00B7 and the zero width non-joiner and joiner are accepted, and no `Pattern_Syntax` or `Pattern_White_Space` character is. Letter categories come from Unicode 17.0, so U+088F starts an identifier and U+1ACF may follow its first character; characters assigned after Unicode 17.0 are not yet identifier characters. Tests: `crates/js/src/tests.rs` (`identifier_characters_are_unicode_17_id_start_and_id_continue`, which checks every code point, `identifiers_take_the_characters_unicode_17_assigned`, `identifiers_take_the_other_id_start_and_continue_characters`, `identifiers_refuse_pattern_syntax_surrogates_and_bad_escapes`, `no_pattern_syntax_or_white_space_character_is_an_identifier_character`). - `vectorcraft-js`'s `Realm` runs ECMAScript 5.1 scripts: the parsed script compiles to verified bytecode that a non-recursive interpreter runs, with closures, `this`, `arguments`, `with`, direct and indirect `eval`, `new Function`, `try`/`catch`/`finally`, labeled `break` and `continue`, and `for`-`in` in insertion order, over a mark-and-sweep heap, with the built-ins the next two entries list. Every run is bounded by fuel, heap bytes, frames, nested host calls and an interrupt flag (smaller defaults in the wasm build), and a run that reaches a limit stops with a limit error that `try` cannot catch. `cargo xtask test262` also runs the suite's ES5 language tests that need only the built-ins present: 4,280 of 4,286 runs pass (4 of the others expect the completion values of later editions, and 2 expect a configurable function `length`). The app does not call it yet. Tests: `crates/js/src/interp_tests.rs`, `crates/js/tests/test262.rs`. From ae587e459f5c729d30d05cc447412df2326f09f0 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 21:52:17 -0800 Subject: [PATCH 19/43] Docs: the File formats row lists DXF export's image and hatch options --- docs/target-app-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9b7d84a030..4903c82f91 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -236,7 +236,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done; junction-returning piece survival has source-generated regressions in `image-trace-quality.md`; large images trace with a warning; Auto Color keeps rare distinct colours) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them | | Layers, artboards, document setup | 5 | 78% | Selected-artboard deletion is consistent across panel actions (all-selected retains artboard 1); history prunes removed indices while preserving valid multiselection; remaining: 30.0's artboard locking and colours and managing artboards from the canvas, Layers panel row context menus and Collect for Export (click-to-fit objects is done, #1227) | 5–8 | estimated (lowered from 82%: target moved); Artboard deletion keys keep artwork and the first of an all-selected set; Artboard Options' Show Center Mark and Show Cross Hairs draw on the canvas only (exports and print leave them out), regression-tested; Artboard Options' Show Video Safe Areas and Video Ruler Pixel Aspect Ratio (per artboard, native format) are done ([video display options](#evidence-what-ships-today-by-area)), score unchanged | | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged); document-tab artwork hover/copy and lossless transfer tested (2026-10-22; score/hours unchanged); cached canvas reprojection preserves scale during resize, rotation and DPI changes (regression and contributor original-file evidence below; scores/hours unchanged); cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission (regression evidence below) | -| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), no writing of Illustrator's editing data (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified PDF marked-text batching covered by M4 evidence (#6309); cross-line/style ActualText layout (#6165) and styled-cluster limits remain.| 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`; the distance from the path under Align to Path is measured from the top of each character's em box, half the height of the top of its H or the bottom of its font's bounding box (`tests_native::aligned_type_lies_as_far_from_its_path_as_the_file_lays_it_out`), and the fit under Gravity does not reproduce the file's layout), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. PDF ActualText preserved in editable import (#6165; bounded synthetic coverage below); score/hours unchanged. | +| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), no writing of Illustrator's editing data (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified PDF marked-text batching covered by M4 evidence (#6309); cross-line/style ActualText layout (#6165) and styled-cluster limits remain.| 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); DXF export's Include Placed Images and Include Fill Hatches options, off in CNC / CAM (`images_and_hatches_are_left_out_independently_and_the_cnc_preset_leaves_out_both`; score, gaps and hours unchanged); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`; the distance from the path under Align to Path is measured from the top of each character's em box, half the height of the top of its H or the bottom of its font's bounding box (`tests_native::aligned_type_lies_as_far_from_its_path_as_the_file_lays_it_out`), and the fit under Gravity does not reproduce the file's layout), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. PDF ActualText preserved in editable import (#6165; bounded synthetic coverage below); score/hours unchanged. | | File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects on objects other than paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports, and with live objects as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, textures, map art and shading in 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified PDF marked-text batching covered by M4 evidence (#6309); cross-line/style ActualText layout (#6165) and styled-cluster limits remain.| 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. PDF ActualText preserved in editable import (#6165; bounded synthetic coverage below); score/hours unchanged. | | File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects on objects other than paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports, and with live objects as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified PDF marked-text batching covered by M4 evidence (#6309); cross-line/style ActualText layout (#6165) and styled-cluster limits remain.| 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. PDF ActualText preserved in editable import (#6165; bounded synthetic coverage below); score/hours unchanged. | | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged); document-tab artwork hover/copy and lossless transfer tested (2026-10-22; score/hours unchanged); cached canvas reprojection preserves scale during resize, rotation and DPI changes (regression and contributor original-file evidence below; scores/hours unchanged) | From c02f95aa31c68d420594cdf8e202f1b3f32dbad4 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 21:50:23 -0800 Subject: [PATCH 20/43] Type: Convert To Point Type turns Dynamic Text off, keeping the sizes Block set Convert To Point Type left Dynamic Text on when it turned Block area type into point type. Point type has no Dynamic Text, but the flag was saved, and converting the type back to area type turned Block on again and fitted the lines to the new frame. The conversion now turns it off; the point type keeps the sizes and leading Block wrote into the characters, and the conversion is still one undo step. --- crates/engine/src/cmd/typecmd/block_tests.rs | 29 ++++++++++++++++++++ crates/engine/src/cmd/typemenu.rs | 5 +++- docs/mcp.md | 4 ++- docs/target-app-parity.md | 2 +- 4 files changed, 37 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/typecmd/block_tests.rs b/crates/engine/src/cmd/typecmd/block_tests.rs index 2dda356146..ec458a0c9b 100644 --- a/crates/engine/src/cmd/typecmd/block_tests.rs +++ b/crates/engine/src/cmd/typecmd/block_tests.rs @@ -140,6 +140,35 @@ fn block_turns_off_keeping_its_sizes_and_records_nothing_when_nothing_changes() assert_eq!(undo_len(&s), n + 2, "already off: no undo step"); } +#[test] +fn convert_to_point_type_turns_block_off_keeping_its_sizes() { + let mut s = session(); + let id = area(&mut s, 300.0, 400.0, "BIG\na much longer second line"); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let sizes = |t: &TextObject| t.runs.iter().map(|r| (r.text.clone(), r.style.size, r.style.leading)).collect::>(); + let fitted = sizes(&text(&s, id)); + let n = undo_len(&s); + s.execute("type.convertToPointType", &json!({})).unwrap(); + assert_eq!(undo_len(&s), n + 1, "one undo step"); + let t = text(&s, id); + assert!(matches!(t.kind, TextKind::Point)); + assert!(!t.dynamic_text, "point type has Dynamic Text off"); + assert_eq!(sizes(&t), fitted, "point type keeps the sizes Block set"); + assert!(serde_json::to_value(&t).unwrap().get("dynamicText").is_none(), "saved without dynamicText"); + // Back to area type, the frame is not refitted: Block stays off. + s.execute("type.convertToAreaType", &json!({})).unwrap(); + let t = text(&s, id); + assert!(matches!(t.kind, TextKind::Area { .. })); + assert!(!t.dynamic_text, "Block does not come on again"); + assert_eq!(sizes(&t), fitted); + // Undo gives the Block frame back. + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + let t = text(&s, id); + assert!(t.dynamic_text && matches!(t.kind, TextKind::Area { .. })); + assert_eq!(sizes(&t), fitted); +} + #[test] fn bad_params_and_other_type_are_refused() { let mut s = session(); diff --git a/crates/engine/src/cmd/typemenu.rs b/crates/engine/src/cmd/typemenu.rs index faaaf2145d..bb0e426c9b 100644 --- a/crates/engine/src/cmd/typemenu.rs +++ b/crates/engine/src/cmd/typemenu.rs @@ -47,7 +47,7 @@ pub fn specs() -> Vec { "Convert To Point Type", ["Type"], None, - "{ids?|id?} area type → point type; soft line wraps become line breaks, and a frame of threaded text leaves its thread with the text it holds → {ids}", + "{ids?|id?} area type → point type; soft line wraps become line breaks, a frame of threaded text leaves its thread with the text it holds, and Dynamic Text (Block) is turned off, keeping the sizes it set → {ids}", has_selection, to_point ), @@ -410,6 +410,9 @@ fn to_point(s: &mut Session, p: &Value) -> Result { Justify::Auto if lay.lines.first().is_some_and(|l| l.rtl) => x1, _ => x0, }; + // Point type has no Dynamic Text: Block is turned off, keeping the sizes it set, so + // converting back to area type does not turn it on again. + t.dynamic_text = false; t.kind = TextKind::Point; t.xf *= Affine::translate((ox, baseline)); refresh_bounds(t); diff --git a/docs/mcp.md b/docs/mcp.md index 66e1c88c90..2668d9af12 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6992,7 +6992,9 @@ the middle of its bounding box's right side, hollow on point type and filled on Type and Convert To Point Type. Either keeps the text and its styles and is one undo step; `ids` picks the objects (default: the selection). A frame of threaded text that `type.convertToPointType` converts leaves its thread with the text it holds, and the story flows through the other frames without that text; a thread left with one frame is no -longer a thread. +longer a thread. Area type with Dynamic Text (Block) on becomes point type with Dynamic Text off, at the sizes Block +set its lines to, as `type.dynamicText {on: false}` leaves them; converting it back to area type does not turn Block +on again. ```json {"name":"run_command","arguments":{"command":"type.convertToAreaType","params":{"ids":[42]}}} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b2ecfe76b5..95eea8716a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1181,7 +1181,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur 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`). - Dynamic Text on area type is Block (Type › Dynamic Text, the Contextual Task Bar, Area Type Options › Block, the Properties panel's Dynamic Text checkbox with Word Gap and Line Gap; [Dynamic Text overview](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/dynamic-text-overview.html)): each line as typed is set to the frame's width by its font size, again after every edit to the text or the frame. VectorCraft's own rules: lines are not broken again, the first character's size moves in 0.1 pt steps within 0.1 to 1,296 pt, Word Gap (0 to 1,000 %, default 100) is the Desired word spacing and Line Gap (-1,296 to 1,296 pt, default 0) is added to each size for the leading, the sizes are written into the characters, and threaded frames cannot use it (`crates/engine/src/cmd/typecmd/block_tests.rs`). + Dynamic Text on area type is Block (Type › Dynamic Text, the Contextual Task Bar, Area Type Options › Block, the Properties panel's Dynamic Text checkbox with Word Gap and Line Gap; [Dynamic Text overview](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/dynamic-text-overview.html)): each line as typed is set to the frame's width by its font size, again after every edit to the text or the frame. VectorCraft's own rules: lines are not broken again, the first character's size moves in 0.1 pt steps within 0.1 to 1,296 pt, Word Gap (0 to 1,000 %, default 100) is the Desired word spacing and Line Gap (-1,296 to 1,296 pt, default 0) is added to each size for the leading, the sizes are written into the characters, threaded frames cannot use it, and Convert To Point Type turns it off, keeping the sizes (`crates/engine/src/cmd/typecmd/block_tests.rs`). Type › Dynamic Text Presets › Circle, Arch and Bow (`type.dynamicTextPreset`) put point type or type on a path on a circular, upward-curving or downward-curving path with Dynamic Text on, with the text's position against the path (above, center, below) and its direction ([Dynamic Text overview](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/dynamic-text-overview.html)). VectorCraft's own rules: the circle is as wide as the text and the text starts at its bottom; Arch and Bow are a 120° arc whose chord is as wide as the text; above, center and below are Align to Path's Descender, Center and Ascender (`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. From 3dc597f25948f0a0675774f27a775b93c25a69db Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 21:53:50 -0800 Subject: [PATCH 21/43] Scripts: a call and new String keep their operands rooted while a rope is flattened Boxing the string receiver of a non-strict function flattens it, and reserving the copy can collect. The callee, receiver and arguments are already off the stack at that point, so they stay in the temporary roots until the frame holds them; before, an argument object could be freed and its cell reused by the wrapper. new String(value) roots the converted string the same way, so what the rope holds is still counted while its copy is reserved. --- crates/js/src/interp.rs | 42 ++++++++++++++++++++------------ crates/js/src/string_builtins.rs | 5 ++-- crates/js/src/text_tests.rs | 29 ++++++++++++++++++++++ 3 files changed, 59 insertions(+), 17 deletions(-) diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index 9801cc9047..82d0c8f9ac 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -2216,26 +2216,19 @@ impl Interp { let arguments = if code.uses_arguments { OBJECT_BYTES.saturating_add(args.len().saturating_add(4).saturating_mul(binding_bytes(10))) } else { 0 }; let need = declaration_bytes(&code).saturating_add(ENV_BYTES).saturating_add(params).saturating_add(arguments); - if self.heap.bytes.saturating_add(need) > self.limits.heap_bytes { - let mark = self.temp_roots.len(); + // The operands are off the stack. Reserving the heap collects, and so can boxing a string + // receiver, which flattens it; the operands stay rooted until the frame holds them. + let collects = self.heap.bytes.saturating_add(need) > self.limits.heap_bytes || (!code.strict && matches!(this, Value::String(_))); + let mark = self.temp_roots.len(); + if collects { self.temp_roots.push(Value::Object(callee)); self.temp_roots.push(this.clone()); self.temp_roots.extend(construct.map(Value::Object)); self.temp_roots.extend(args.iter().cloned()); - let reserved = self.reserve_heap(need); - self.temp_roots.truncate(mark); - reserved?; } - let this = if code.strict { - this - } else { - match this { - Value::Undefined | Value::Null => Value::Object(self.global), - Value::Object(_) => this, - prim => Value::Object(self.to_object(&prim)?), - } - }; - let fenv = self.instantiate_function(&code, env, &args, callee)?; + let received = self.receive_call(&code, env, this, &args, callee, need); + self.temp_roots.truncate(mark); + let (this, fenv) = received?; let base = self.stack.len(); self.frames.push(Frame { code, @@ -2253,6 +2246,25 @@ impl Interp { Ok(()) } + /// Reserves the `need` bytes of a call, boxes a primitive receiver for non-strict code and + /// binds the parameters, returning the receiver and the function's environment. + fn receive_call(&mut self, code: &Rc, env: EnvId, this: Value, args: &[Value], callee: ObjId, need: usize) -> JsResult<(Value, EnvId)> { + if self.heap.bytes.saturating_add(need) > self.limits.heap_bytes { + self.reserve_heap(need)?; + } + let this = if code.strict { + this + } else { + match this { + Value::Undefined | Value::Null => Value::Object(self.global), + Value::Object(_) => this, + prim => Value::Object(self.to_object(&prim)?), + } + }; + let fenv = self.instantiate_function(code, env, args, callee)?; + Ok((this, fenv)) + } + /// Declaration binding instantiation for function code (section 10.5). Each parameter and /// declaration takes a unit of fuel. fn instantiate_function(&mut self, code: &Rc, outer: EnvId, args: &[Value], callee: ObjId) -> JsResult { diff --git a/crates/js/src/string_builtins.rs b/crates/js/src/string_builtins.rs index ba9c79155c..85a27e3261 100644 --- a/crates/js/src/string_builtins.rs +++ b/crates/js/src/string_builtins.rs @@ -214,8 +214,9 @@ fn string_ctor(interp: &mut Interp, _: Value, args: &[Value], construct: bool) - if !construct { return Ok(Value::String(s)); } - // Flattened now, with its copy counted, so reading a character copies nothing. - flat_charged(interp, &s)?; + // Flattened now, with its copy counted, so reading a character copies nothing. The string + // stays rooted, so what it holds is still counted if reserving the copy collects. + interp.rooted(&[Value::String(s.clone())], |interp| flat_charged(interp, &s))?; let proto = interp.intrinsics.string_proto; Ok(Value::Object(interp.heap.alloc_object(Object::new(Class::String(s), Some(proto))))) } diff --git a/crates/js/src/text_tests.rs b/crates/js/src/text_tests.rs index 03b4fa4339..0c2f051f80 100644 --- a/crates/js/src/text_tests.rs +++ b/crates/js/src/text_tests.rs @@ -354,6 +354,35 @@ fn a_flattened_temporary_stays_counted_while_the_next_copy_is_made() { assert!(not_stopped.is_empty(), "not stopped by the heap limit: {not_stopped:?}"); } +#[test] +fn a_string_wrapper_counts_what_its_rope_holds_while_the_copy_is_made() { + // `mk` returns a rope whose left half is a flat string of about half the heap, held by + // nothing else. Its flattened copy is the other half, so the two don't fit together. + let build = "var s = 'x'; for (var i = 0; i < 18; i++) s = s + s; function f(c) { return s + c; } \ + function mk() { var a = f('a'); a[0]; return a + 'b'; }"; + let limits = Limits { heap_bytes: 4 * 262_146, ..Limits::default() }; + let src = format!("{build} new String({{ toString: mk }}); 0"); + let result = Realm::with_options(Options::default(), limits).run(&src); + assert!(matches!(result, Err(ref e) if e.kind == RunErrorKind::Limit(LimitKind::Heap)), "{result:?}"); +} + +#[test] +fn a_call_keeps_its_operands_while_a_string_receiver_is_boxed() { + // Boxing the rope receiver of a non-strict function flattens it, and reserving the copy + // collects the garbage string `g` left. The callee and the argument object are off the + // stack by then, and must survive the collection. + let build = "var s = 'x'; for (var i = 0; i < 17; i++) s = s + s; function f(c) { return s + c; } \ + function mk() { var a = f('a'); a[0]; return a + 'b'; } 0"; + let mut realm = Realm::with_options(Options::default(), Limits::default()); + assert_eq!(realm.run(build), Ok(JsValue::Number(0.0))); + let interp = realm.interp(); + interp.collect_garbage(); + interp.limits.heap_bytes = interp.heap.bytes + 2 * crate::heap::string_bytes(131_073) + (64 << 10); + interp.reset_gc_threshold(); + let src = "(function () { var g = f('g'); g[0]; })(); (function (o) { return o.x; }).call(mk(), { x: 1 })"; + assert_eq!(realm.run(src), Ok(JsValue::Number(1.0))); +} + #[test] fn compiling_a_pattern_counts_against_the_heap() { let src = "var s = '[\\\\s\\\\S]a*'; for (var i = 0; i < 14; i++) s = s + s; new RegExp(s).source.length"; From ad05c3b99ddb1fdf7942f1f5caf8fbb24fd9160e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 21:56:58 -0800 Subject: [PATCH 22/43] Plug-ins: an install that automation asked for is not approved in its dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An approval question that a control request, MCP, injected input, or a file dialog one of them opened leads to now keeps OK disabled and refuses every confirm, a person's click included. The dialog says to choose Object › Plug-ins › Install Plug-in…, and ui.installPlugin answers approval: "personMustStartInstall". The person's own Install Plug-in…, File › Open or drop inspects the module again and asks a new question with its half-second delay. The origin is fixed when the request is made and goes with a native file dialog's deferred answer and the web picker's answers, which now arrive apart from drops and from window.vectorcraft.openFile (always automation). A question about taking another module's place now opens in the approval dialog, and automation's replace: true no longer skips it. A module that needs no approval still installs at once. --- apps/vectorcraft-web/src/main.rs | 2 +- apps/vectorcraft-web/src/web.rs | 41 ++- crates/mcp/src/ui_catalog.rs | 2 +- crates/ui-egui/src/app/files.rs | 27 +- crates/ui-egui/src/app/links.rs | 7 + .../ui-egui/src/dialogs/export_for_screens.rs | 3 +- .../ui-egui/src/dialogs/flattener_presets.rs | 3 +- crates/ui-egui/src/dialogs/pdf_presets.rs | 3 +- .../src/dialogs/perspective_presets.rs | 3 +- crates/ui-egui/src/dialogs/plugin.rs | 49 ++- crates/ui-egui/src/dialogs/print_presets.rs | 3 +- crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/bs.tsv | 2 + crates/ui-egui/src/i18n/ca.tsv | 2 + crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/el.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/fi.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/hi.tsv | 2 + crates/ui-egui/src/i18n/hu.tsv | 2 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/ka.tsv | 2 + crates/ui-egui/src/i18n/ko.tsv | 2 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/sv.tsv | 2 + crates/ui-egui/src/i18n/tr.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/vi.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + crates/ui-egui/src/io/open.rs | 31 +- crates/ui-egui/src/lib.rs | 3 +- crates/ui-egui/src/menus/ui_commands.rs | 2 +- crates/ui-egui/src/panels/libraries.rs | 3 +- crates/ui-egui/src/panels/library_panel.rs | 5 +- crates/ui-egui/src/picks.rs | 58 +++- crates/ui-egui/src/services.rs | 13 +- crates/ui-egui/src/tests/automation.rs | 308 +++++++++++++++++- crates/ui-egui/src/tests/plugins.rs | 30 +- docs/control-protocol.md | 6 +- docs/mcp.md | 5 +- docs/plugins.md | 11 +- docs/target-app-parity.md | 1 + 51 files changed, 569 insertions(+), 100 deletions(-) diff --git a/apps/vectorcraft-web/src/main.rs b/apps/vectorcraft-web/src/main.rs index f7dd201af2..3808c281f9 100644 --- a/apps/vectorcraft-web/src/main.rs +++ b/apps/vectorcraft-web/src/main.rs @@ -7,7 +7,7 @@ //! Differences from the desktop app: //! - no TCP control server (browsers can't listen on sockets); //! - File → Open uses the browser file picker; bytes arrive asynchronously through -//! `Services::inbox`; +//! `Services::picked_inbox`; //! - Save / Export trigger a browser download; //! - dropped files are read asynchronously by `web::WebShell` and delivered through the inbox; //! - pen and finger input is read from Pointer Events by `pencil` (Apple Pencil pressure and diff --git a/apps/vectorcraft-web/src/web.rs b/apps/vectorcraft-web/src/web.rs index b4ca434d90..2de7f3b481 100644 --- a/apps/vectorcraft-web/src/web.rs +++ b/apps/vectorcraft-web/src/web.rs @@ -17,7 +17,7 @@ use vectorcraft_ui_egui::i18n; use vectorcraft_ui_egui::place::{DropTarget, PlaceArrival, PlaceInbox}; use vectorcraft_ui_egui::print::{PrintJob, PrintService, Printer}; use vectorcraft_ui_egui::stylus::StylusFeed; -use vectorcraft_ui_egui::{Services, VectorcraftApp}; +use vectorcraft_ui_egui::{PickedInbox, Services, VectorcraftApp}; use wasm_bindgen::JsCast as _; use crate::locks::WebLocks; @@ -25,6 +25,18 @@ use crate::pencil::SharedPointers; pub(crate) type Inbox = Arc)>>>; +/// The files waiting to open, by where they came from (`Services::inbox`, `picked_inbox` and +/// `page_inbox`). +#[derive(Clone, Default)] +struct OpenInboxes { + /// Dropped on the page. + dropped: Inbox, + /// The open picker's answers. + picked: PickedInbox, + /// Handed over by the page's script (`window.vectorcraft.openFile`). + page: Inbox, +} + /// Where the pointer was over the canvas during the last file drag (CSS px from its top-left) and /// whether Shift was held: where dropped files land. type DragPos = Rc>>; @@ -48,7 +60,7 @@ const MAX_RESTARTS: u32 = 3; #[derive(Clone)] struct Host { runner: eframe::WebRunner, - inbox: Inbox, + inbox: OpenInboxes, place_inbox: PlaceInbox, script_inbox: ScriptInbox, drag: DragPos, @@ -77,7 +89,7 @@ pub fn start() { crate::fonts::load_startup_fonts().await; let host = Host { runner: eframe::WebRunner::new(), - inbox: Arc::default(), + inbox: OpenInboxes::default(), place_inbox: Arc::default(), script_inbox: Arc::default(), drag: DragPos::default(), @@ -132,13 +144,13 @@ async fn run(host: Host, canvas: web_sys::HtmlCanvasElement, moving: Option { // `window.vectorcraft`: the page's control channel (crate::api). let (control_tx, control_rx) = std::sync::mpsc::channel(); - crate::api::connect(Some(control_tx), host.inbox.clone(), ctx.clone()); + crate::api::connect(Some(control_tx), host.inbox.page.clone(), ctx.clone()); if let Err(e) = crate::api::install() { log::error!("vectorcraft-web: couldn't install window.vectorcraft: {}", js_err(e)); } @@ -409,7 +421,7 @@ impl eframe::App for WebShell { for f in dropped { let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into()); let target = app.drop_target(&name, pos, shift); - let (inbox, place_inbox, repaint) = (self.host.inbox.clone(), self.host.place_inbox.clone(), ctx.clone()); + let (inbox, place_inbox, repaint) = (self.host.inbox.dropped.clone(), self.host.place_inbox.clone(), ctx.clone()); wasm_bindgen_futures::spawn_local(async move { match f.bytes_async().await { Ok(bytes) => { @@ -432,14 +444,14 @@ impl eframe::App for WebShell { } fn services( - inbox: Inbox, + inbox: OpenInboxes, place_inbox: PlaceInbox, script_inbox: ScriptInbox, ctx: egui::Context, locks: Option, stylus: StylusFeed, ) -> Services { - let open_inbox = inbox.clone(); + let open_inbox = inbox.picked.clone(); let picked = place_inbox.clone(); let place_ctx = ctx.clone(); let screen_color = eye_dropper(ctx.clone()); @@ -488,11 +500,12 @@ fn services( let dialog = fileio::open_filters().fold(rfd::AsyncFileDialog::new(), |d, (name, exts)| { d.add_filter(vectorcraft_ui_egui::i18n::tr_ctx(vectorcraft_ui_egui::i18n::current(), "file-filter", name), exts) }); - let Some(file) = dialog.pick_file().await else { - return; + // A canceled pick answers `None`. + let answer = match dialog.pick_file().await { + Some(file) => Some((file.file_name(), file.read().await)), + None => None, }; - let bytes = file.read().await; - inbox.lock().unwrap_or_else(|e| e.into_inner()).push((file.file_name(), bytes)); + inbox.lock().unwrap_or_else(|e| e.into_inner()).push(answer); ctx.request_repaint(); }); })), @@ -500,7 +513,9 @@ fn services( screen_color, // A download that couldn't be started fails the save or export it is for (#1693). download: Some(Box::new(|name: &str, bytes: &[u8]| download(name, bytes).inspect_err(|e| log::error!("download of {name} failed: {e}")))), - inbox: Some(inbox), + inbox: Some(inbox.dropped), + picked_inbox: Some(inbox.picked), + page_inbox: Some(inbox.page), recovery_store: Some(Arc::new(BrowserStore { locks })), // File → Print: the browser's print dialog. print: Some(Box::new(BrowserPrint)), diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index c6cf12857f..950adca0c8 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -1523,7 +1523,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.installPlugin", "Install Plug-in…", "", - "{path?: a .wasm file | dataBase64?: the module, name?: the file name shown as its source (with dataBase64), replace?: bool (true: the user agreed it takes the place of the plug-in with its id, as plugin.install), hash?: the module's SHA-256 (as plugin.install)} install a WebAssembly plug-in (plugin.install); without a path pick one (the web opens the browser's file picker; File › Open of a .wasm installs it too). A plug-in that lists permissions the user hasn't granted it asks the user first, showing its name, id, version, file SHA-256 and what each permission lets it do, so does a filter that asks for a heavy budget, and so does one without replace that would take the place of another module with its id and get its settings and cache (needsReplace in plugin.inspect), showing its id and file SHA-256: OK installs it if the file still has the SHA-256 shown, Cancel installs nothing. Only a person can press OK when it asks about permissions or a heavy budget: ui.dialog.confirm, answer_dialog and synthetic input are refused → plugin.install's result | {pending: \"pluginInstall\"} while it asks about permissions or a heavy budget | {pending: \"confirm\"} while it asks only about replacing", + "{path?: a .wasm file | dataBase64?: the module, name?: the file name shown as its source (with dataBase64), replace?: bool (true: the user agreed it takes the place of the plug-in with its id, as plugin.install), hash?: the module's SHA-256 (as plugin.install)} install a WebAssembly plug-in (plugin.install); without a path pick one (the web opens the browser's file picker; File › Open of a .wasm installs it too). A plug-in that lists permissions the user hasn't granted it asks the user first, showing its name, id, version, file SHA-256 and what each permission lets it do, so does a filter that asks for a heavy budget, and so does one without replace that would take the place of another module with its id and get its settings and cache (needsReplace in plugin.inspect), showing its id and file SHA-256: OK installs it if the file still has the SHA-256 shown, Cancel installs nothing. Only a person can press OK: ui.dialog.confirm, answer_dialog and synthetic input are refused. When automation asked (a control or MCP request, synthetic input, or a file dialog one of them opened, even when the person picked the file), replace: true doesn't skip the question about replacing, and nothing in the dialog installs it, the person's OK included: the person chooses Object › Plug-ins › Install Plug-in… to install it → plugin.install's result | {pending: \"pluginInstall\"} while it asks | {pending: \"pluginInstall\", approval: \"personMustStartInstall\"} while it asks what automation asked", ), ( "plugin.cancelRuns", diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index 8674942538..3bb451bfd6 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -1,4 +1,5 @@ use super::{VectorcraftApp, io, place}; +use crate::services::Inbox; impl VectorcraftApp { /// A file dialog is open off the UI thread (a sheet on macOS, [`picks`]): what asked for it runs @@ -13,13 +14,25 @@ impl VectorcraftApp { self.services.dialogs_are_sheets && self.file_dialog_open() } - pub(super) fn drain_inbox(&mut self) { - let arrived: Vec<(String, Vec)> = - self.services.inbox.as_ref().map(|q| std::mem::take(&mut *q.lock().unwrap_or_else(|e| e.into_inner()))).unwrap_or_default(); - for (name, bytes) in arrived { - if let Err(e) = io::open_bytes(self, &name, &bytes, None) { - io::report_open_error(self, &name, &e); - } + pub(crate) fn drain_inbox(&mut self) { + fn take(q: Option<&Inbox>) -> Vec<(String, Vec)> { + q.map(|q| std::mem::take(&mut *q.lock().unwrap_or_else(|e| e.into_inner()))).unwrap_or_default() + } + let answers = self.services.picked_inbox.as_ref().map(|q| std::mem::take(&mut *q.lock().unwrap_or_else(|e| e.into_inner()))); + // The web's file picker answers for automation when automation opened it; the page's script + // is automation. + let mut arrived: Vec<_> = take(self.services.inbox.as_ref()).into_iter().map(|f| (f, false)).collect(); + for answer in answers.unwrap_or_default() { + let automated = self.picks.answer_web_open(); + arrived.extend(answer.map(|f| (f, automated))); + } + arrived.extend(take(self.services.page_inbox.as_ref()).into_iter().map(|f| (f, true))); + for ((name, bytes), automated) in arrived { + crate::picks::as_automated(self, automated, |app| { + if let Err(e) = io::open_bytes(app, &name, &bytes, None) { + io::report_open_error(app, &name, &e); + } + }); } place::drain(self); } diff --git a/crates/ui-egui/src/app/links.rs b/crates/ui-egui/src/app/links.rs index 8f4ce246f5..b27bb494aa 100644 --- a/crates/ui-egui/src/app/links.rs +++ b/crates/ui-egui/src/app/links.rs @@ -36,6 +36,13 @@ impl VectorcraftApp { self.ui.answering_control || self.synthetic_frame } + /// Did automation ask for what is being handled now: [`Self::automation_is_acting`], or the + /// answer of a file dialog that automation opened ([`crate::picks::as_automated`]), even when a + /// person picked the file in it? + pub(crate) fn automation_asked(&self) -> bool { + self.automation_is_acting() || self.picks.automated() + } + /// Run a Help link command and open the URL it returns. pub fn open_link(&mut self, id: &str) { if let Ok(v) = self.run(id, serde_json::json!({})) diff --git a/crates/ui-egui/src/dialogs/export_for_screens.rs b/crates/ui-egui/src/dialogs/export_for_screens.rs index 88f9d35c80..54c98052bf 100644 --- a/crates/ui-egui/src/dialogs/export_for_screens.rs +++ b/crates/ui-egui/src/dialogs/export_for_screens.rs @@ -784,8 +784,7 @@ fn save_preset_row(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) /// ones → the names added /// (none on the web, where the picked file arrives later and imports through `io::open_bytes`). pub(crate) fn import_presets(app: &mut VectorcraftApp) -> Result, String> { - if let Some(f) = app.services.open_async.as_mut() { - f(); + if crate::picks::open_async(app) { return Ok(vec![]); } let pick = crate::FilePick { filters: vec![crate::FilePick::filter("Export presets", fileio::SCREEN_EXPORT_PRESET_EXTS)], ..Default::default() }; diff --git a/crates/ui-egui/src/dialogs/flattener_presets.rs b/crates/ui-egui/src/dialogs/flattener_presets.rs index e95d0e683a..a8bcdde6e4 100644 --- a/crates/ui-egui/src/dialogs/flattener_presets.rs +++ b/crates/ui-egui/src/dialogs/flattener_presets.rs @@ -197,8 +197,7 @@ fn run(app: &mut VectorcraftApp, d: &mut Dialog, act: Action, current: &str) -> } Action::Import => { // On the web the picked file arrives later and imports through `io::open_bytes`. - if let Some(f) = app.services.open_async.as_mut() { - f(); + if crate::picks::open_async(app) { return Ok(()); } let pick = crate::FilePick { filters: vec![crate::FilePick::filter("Flattener presets", PRESET_EXTS)], ..Default::default() }; diff --git a/crates/ui-egui/src/dialogs/pdf_presets.rs b/crates/ui-egui/src/dialogs/pdf_presets.rs index 10f7e0e451..f29b25e9e1 100644 --- a/crates/ui-egui/src/dialogs/pdf_presets.rs +++ b/crates/ui-egui/src/dialogs/pdf_presets.rs @@ -173,8 +173,7 @@ fn run(app: &mut VectorcraftApp, d: &mut Dialog, act: Action, current: &str) -> } Action::Import => { // On the web the picked file arrives later and imports through `io::open_bytes`. - if let Some(f) = app.services.open_async.as_mut() { - f(); + if crate::picks::open_async(app) { return Ok(()); } let pick = crate::FilePick { diff --git a/crates/ui-egui/src/dialogs/perspective_presets.rs b/crates/ui-egui/src/dialogs/perspective_presets.rs index 7432bd3b2d..4772fa133b 100644 --- a/crates/ui-egui/src/dialogs/perspective_presets.rs +++ b/crates/ui-egui/src/dialogs/perspective_presets.rs @@ -191,8 +191,7 @@ pub(super) fn run(app: &mut VectorcraftApp, d: &mut Dialog, act: Action, current } Action::Import => { // On the web the picked file arrives later and imports through `io::open_bytes`. - if let Some(f) = app.services.open_async.as_mut() { - f(); + if crate::picks::open_async(app) { return Ok(()); } let pick = crate::FilePick { filters: vec![crate::FilePick::filter("Perspective grid presets", PRESET_EXTS)], ..Default::default() }; diff --git a/crates/ui-egui/src/dialogs/plugin.rs b/crates/ui-egui/src/dialogs/plugin.rs index 63158b7329..706188f6da 100644 --- a/crates/ui-egui/src/dialogs/plugin.rs +++ b/crates/ui-egui/src/dialogs/plugin.rs @@ -341,6 +341,21 @@ fn import_confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result /// The dialog kind of the approval of a plug-in install. pub const INSTALL_KIND: &str = "pluginInstall"; +/// Who asked for a plug-in install, fixed when the request is made. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum InstallOrigin { + /// The person at the keyboard: Install Plug-in…, File › Open or a drop. + Person, + /// A control or MCP request, a frame of synthetic input, or a file dialog that one of them + /// opened ([`VectorcraftApp::automation_asked`]): nothing in the dialog installs it, and the + /// person starts Install Plug-in… to install it. + Automation, +} + +/// What [`ask_install`] answers, as `approval`, for a question automation asked: the person has to +/// start Install Plug-in… to install the module. +pub(crate) const PERSON_MUST_START_INSTALL: &str = "personMustStartInstall"; + /// A plug-in install waiting for a person's answer. pub(crate) struct PendingInstall { /// The question and the lines under it, in the UI language. @@ -350,6 +365,8 @@ pub(crate) struct PendingInstall { pub params: Value, /// What OK approves: the module the question is about. pub approval: InstallApproval, + /// Who asked; a question automation asked has no OK that installs. + pub origin: InstallOrigin, /// How long the question has been on screen; [`ask_install`] starts it over. pub shown: Shown, } @@ -439,27 +456,44 @@ pub(super) const INSTALL_SPEC: DialogSpec = DialogSpec { }; /// Asks a person whether to install what `pending` describes, its question not yet shown, even when -/// it reads as the one it replaces. → `{pending}`. +/// it reads as the one it replaces. → `{pending}`, and `approval: "personMustStartInstall"` when +/// automation asked. pub(crate) fn ask_install(app: &mut VectorcraftApp, mut pending: PendingInstall) -> Value { pending.shown = Shown::default(); + let automated = pending.origin == InstallOrigin::Automation; app.pending_plugin_install = Some(pending); app.ui.dialog = Some(Dialog::new(INSTALL_KIND, json!({}))); + if automated { + return json!({ "pending": INSTALL_KIND, "approval": PERSON_MUST_START_INSTALL }); + } json!({ "pending": INSTALL_KIND }) } +/// What the approval says when automation asked for the install; the error of its OK +/// ([`install_confirm`]) is the same text. +fn automation_asked_note() -> &'static str { + tl!("Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself.") +} + /// The question, the lines under it, and OK, which is disabled until the question has been shown -/// for [`APPROVAL_DELAY`] ([`Shown`]), and Cancel, which never waits. +/// for [`APPROVAL_DELAY`] ([`Shown`]), and Cancel, which never waits. When automation asked, a note +/// says so and OK stays disabled. fn install_body(app: &mut VectorcraftApp, ui: &mut egui::Ui, _: &mut Dialog) -> DialogAction { let Some(pending) = app.pending_plugin_install.as_mut() else { return DialogAction::Cancel }; - let enabled = pending.shown.drawn(ui); + let person = pending.origin == InstallOrigin::Person; + let enabled = pending.shown.drawn(ui) && person; ui.label(egui::RichText::new(&pending.message).strong()); ui.add_space(8.0); ui.label(egui::RichText::new(&pending.detail).color(crate::theme::Tokens::get(ui.ctx()).text_dim)); + if !person { + ui.add_space(8.0); + ui.label(egui::RichText::new(automation_asked_note()).strong()); + } ui.add_space(16.0); let mut action = DialogAction::None; let row = egui::vec2(ui.min_rect().width(), ui.spacing().interact_size.y); ui.allocate_ui_with_layout(row, egui::Layout::right_to_left(egui::Align::Center), |ui| { - if widgets::primary_button_enabled(ui, "OK", enabled).clicked() && pending.shown.ready() { + if widgets::primary_button_enabled(ui, "OK", enabled).clicked() && person && pending.shown.ready() { action = DialogAction::Confirm; } pending.shown.released(ui); @@ -472,9 +506,12 @@ fn install_body(app: &mut VectorcraftApp, ui: &mut egui::Ui, _: &mut Dialog) -> } /// OK: installs the module the dialog showed, if a person pressed it; automation is refused and the -/// dialog stays open. Before the question has been shown for [`APPROVAL_DELAY`] it installs nothing -/// and the dialog stays open. → `{pending}`. +/// dialog stays open, and so is any OK of a question automation asked. Before the question has been +/// shown for [`APPROVAL_DELAY`] it installs nothing and the dialog stays open. → `{pending}`. fn install_confirm(app: &mut VectorcraftApp, _: &Dialog) -> Result { + if app.pending_plugin_install.as_ref().is_some_and(|p| p.origin == InstallOrigin::Automation) { + return Err("Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself.".into()); + } if app.automation_is_acting() { return Err("Only the person at the keyboard can approve installing a plug-in".into()); } diff --git a/crates/ui-egui/src/dialogs/print_presets.rs b/crates/ui-egui/src/dialogs/print_presets.rs index a8a9f42449..3ad798d998 100644 --- a/crates/ui-egui/src/dialogs/print_presets.rs +++ b/crates/ui-egui/src/dialogs/print_presets.rs @@ -153,8 +153,7 @@ pub(super) fn run(app: &mut VectorcraftApp, d: &mut Dialog, act: Action, current } Action::Import => { // On the web the picked file arrives later and imports through `io::open_bytes`. - if let Some(f) = app.services.open_async.as_mut() { - f(); + if crate::picks::open_async(app) { return Ok(()); } let pick = crate::FilePick { filters: vec![crate::FilePick::filter("Print presets", PRESET_EXTS)], ..Default::default() }; diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 05756a1af6..69cc8939c7 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -5889,6 +5889,7 @@ axis Y Axis Y ос Version: {version} Версия: {version} Install “{name}” in place of the plug-in with the same ID? Да се инсталира ли „{name}“ на мястото на приставката със същия ID? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Този файл не е приставката, инсталирана преди с този ID. Той получава настройките и кеша, пазени за този ID. + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Автоматизация поиска инсталирането на тази приставка. За да я инсталирате, изберете Обект › Приставки › Инсталиране на приставка… сами. File SHA-256: {hash} SHA-256 на файла: {hash} Read the objects it is given Четене на обектите, които получава Change, add and delete objects in the document Промяна, добавяне и изтриване на обекти в документа diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index a9e705cfac..8e79e53757 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -5910,6 +5910,8 @@ axis Y Axis Y osa Version: {version} Verzija: {version} Install “{name}” in place of the plug-in with the same ID? Instalirati „{name}” umjesto dodatka s istim ID-om? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Ova datoteka nije dodatak ranije instaliran s ovim ID-om. Dobija postavke i predmemoriju sačuvane za taj ID. + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Automatizacija je zatražila instalaciju ovog dodatka. Da biste ga instalirali, izaberite Objekat › Dodaci › Instaliraj dodatak… sami. +@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Automatizacija je zatražila instalaciju ovog dodatka. Da biste ga instalirali, izaberite Objekat › Dodaci › Instaliraj dodatak… sami. @msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees „{name}” je drugi modul od dodatka s ID-om {_1} i dobio bi postavke i predmemoriju sačuvane za njega: instalirajte ga s `replace: true` kada korisnik pristane File SHA-256: {hash} SHA-256 datoteke: {hash} Read the objects it is given Čitati objekte koje dobije diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 5341805a73..61040a40d9 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -6122,6 +6122,8 @@ digits Malayalam Malaiàlam Version: {version} Versió: {version} Install “{name}” in place of the plug-in with the same ID? Instal·la «{name}» en lloc del complement amb el mateix ID? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Aquest fitxer no és el complement instal·lat abans amb aquest ID. Rep la configuració i la memòria cau desades per a aquest ID. + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Una automatització ha demanat d’instal·lar aquest complement. Per instal·lar-lo, tria Objecte › Complements › Instal·la el complement… tu mateix. +@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Una automatització ha demanat d’instal·lar aquest complement. Per instal·lar-lo, tria Objecte › Complements › Instal·la el complement… tu mateix. @msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees «{name}» és un mòdul diferent del complement amb l’ID {_1} i rebria la configuració i la memòria cau desades per a aquest: instal·la’l amb `replace: true` quan l’usuari hi estigui d’acord File SHA-256: {hash} SHA-256 del fitxer: {hash} Read the objects it is given Llegir els objectes que rep diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 9d6fd981e6..07ee218e20 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6075,6 +6075,8 @@ axis Y Axis osa Y Version: {version} Verze: {version} Install “{name}” in place of the plug-in with the same ID? Nainstalovat „{name}“ místo zásuvného modulu se stejným ID? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Tento soubor není zásuvný modul dříve nainstalovaný s tímto ID. Získá nastavení a mezipaměť uchovávané pro toto ID. + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. O instalaci tohoto zásuvného modulu požádala automatizace. Chcete-li ho nainstalovat, zvolte Objekt › Zásuvné moduly › Nainstalovat zásuvný modul… sami. +@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. O instalaci tohoto zásuvného modulu požádala automatizace. Chcete-li ho nainstalovat, zvolte Objekt › Zásuvné moduly › Nainstalovat zásuvný modul… sami. @msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees „{name}“ je jiný modul než zásuvný modul s ID {_1} a získal by nastavení a mezipaměť uchovávané pro něj: nainstalujte ho s `replace: true`, jakmile uživatel souhlasí File SHA-256: {hash} SHA-256 souboru: {hash} Read the objects it is given Číst objekty, které dostane diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index af95c4119d..0d5231c51e 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -5904,6 +5904,7 @@ axis Y Axis Y-akse Version: {version} Version: {version} Install “{name}” in place of the plug-in with the same ID? Installér »{name}« i stedet for det plug-in, der har samme id? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Denne fil er ikke det plug-in, der før var installeret med dette id. Den får de indstillinger og den cache, der er gemt for dette id. + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. En automatisering bad om at installere dette plug-in. Vælg selv Objekt › Plug-ins › Installér plug-in… for at installere det. File SHA-256: {hash} Filens SHA-256: {hash} Read the objects it is given Læse de objekter, den får Change, add and delete objects in the document Ændre, tilføje og slette objekter i dokumentet diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 55bc587b64..49326fa3e6 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5658,6 +5658,8 @@ axis Y Axis Y-Achse Version: {version} Version: {version} Install “{name}” in place of the plug-in with the same ID? „{name}“ anstelle des Plug-ins mit derselben ID installieren? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Diese Datei ist nicht das Plug-in, das vorher mit dieser ID installiert war. Sie erhält die Einstellungen und den Cache, die für diese ID gespeichert sind. + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Eine Automatisierung hat angefordert, dieses Plug-in zu installieren. Um es zu installieren, wählen Sie selbst „Objekt › Plug-ins › Plug-in installieren…“. +@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Eine Automatisierung hat angefordert, dieses Plug-in zu installieren. Um es zu installieren, wählen Sie selbst „Objekt › Plug-ins › Plug-in installieren…“. @msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees „{name}“ ist ein anderes Modul als das Plug-in mit der ID {_1} und würde die dafür gespeicherten Einstellungen und den Cache erhalten: Installieren Sie es mit `replace: true`, sobald der Benutzer zustimmt File SHA-256: {hash} Datei-SHA-256: {hash} Read the objects it is given Die Objekte lesen, die es erhält diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index e8732e8d34..1e09c4e1b5 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -6026,6 +6026,8 @@ axis Y Axis Άξονας Y Version: {version} Έκδοση: {version} Install “{name}” in place of the plug-in with the same ID? Να εγκατασταθεί το «{name}» στη θέση του πρόσθετου με το ίδιο αναγνωριστικό; This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Αυτό το αρχείο δεν είναι το πρόσθετο που ήταν εγκατεστημένο πριν με αυτό το αναγνωριστικό. Παίρνει τις ρυθμίσεις και την κρυφή μνήμη που διατηρούνται για αυτό το αναγνωριστικό. + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Μια αυτοματοποίηση ζήτησε να εγκατασταθεί αυτό το πρόσθετο. Για να το εγκαταστήσετε, επιλέξτε Αντικείμενο › Πρόσθετα › Εγκατάσταση προσθέτου… μόνοι σας. +@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Μια αυτοματοποίηση ζήτησε να εγκατασταθεί αυτό το πρόσθετο. Για να το εγκαταστήσετε, επιλέξτε Αντικείμενο › Πρόσθετα › Εγκατάσταση προσθέτου… μόνοι σας. @msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees το «{name}» είναι άλλη λειτουργική μονάδα από το πρόσθετο με αναγνωριστικό {_1} και θα έπαιρνε τις ρυθμίσεις και την κρυφή μνήμη που διατηρούνται για αυτό: εγκαταστήστε το με `replace: true` μόλις συμφωνήσει ο χρήστης File SHA-256: {hash} SHA-256 αρχείου: {hash} Read the objects it is given Ανάγνωση των αντικειμένων που του δίνονται diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 7eb51ee4f2..3258fbbe3b 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -6017,6 +6017,8 @@ axis Y Axis Eje Y Version: {version} Versión: {version} Install “{name}” in place of the plug-in with the same ID? ¿Instalar “{name}” en lugar del plugin con el mismo ID? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Este archivo no es el plugin instalado antes con este ID. Recibe la configuración y la caché guardadas para ese ID. + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Una automatización pidió instalar este plugin. Para instalarlo, elige Objeto › Plugins › Instalar plugin… tú mismo. +@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Una automatización pidió instalar este plugin. Para instalarlo, elige Objeto › Plugins › Instalar plugin… tú mismo. @msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees “{name}” es un módulo distinto del plugin con ID {_1} y recibiría la configuración y la caché guardadas para él: instálalo con `replace: true` cuando el usuario lo acepte File SHA-256: {hash} SHA-256 del archivo: {hash} Read the objects it is given Leer los objetos que recibe diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 433883a44f..1e160fb765 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6105,6 +6105,8 @@ axis Y Axis Y-akseli Version: {version} Versio: {version} Install “{name}” in place of the plug-in with the same ID? Asennetaanko ”{name}” samaa tunnusta käyttävän laajennuksen tilalle? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Tämä tiedosto ei ole laajennus, joka oli aiemmin asennettu tällä tunnuksella. Se saa tälle tunnukselle säilytetyt asetukset ja välimuistin. + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Automaatio pyysi tämän laajennuksen asentamista. Valitse itse Objekti › Laajennukset › Asenna laajennus…, jos haluat asentaa sen. +@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Automaatio pyysi tämän laajennuksen asentamista. Valitse itse Objekti › Laajennukset › Asenna laajennus…, jos haluat asentaa sen. @msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees ”{name}” on eri moduuli kuin laajennus, jonka tunnus on {_1}, ja se saisi sille säilytetyt asetukset ja välimuistin: asenna se asetuksella `replace: true`, kun käyttäjä hyväksyy File SHA-256: {hash} Tiedoston SHA-256: {hash} Read the objects it is given Lukea sille annetut objektit diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 6995235edc..7ba0c27836 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6070,6 +6070,8 @@ axis Y Axis Axe Y Version: {version} Version : {version} Install “{name}” in place of the plug-in with the same ID? Installer « {name} » à la place du module externe qui a le même ID ? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Ce fichier n’est pas le module externe installé auparavant avec cet ID. Il reçoit les réglages et le cache conservés pour cet ID. + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Une automatisation a demandé l’installation de ce module externe. Pour l’installer, choisissez Objet › Modules externes › Installer un module externe… vous-même. +@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Une automatisation a demandé l’installation de ce module externe. Pour l’installer, choisissez Objet › Modules externes › Installer un module externe… vous-même. @msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees « {name} » est un autre module que le module externe d’ID {_1} et recevrait les réglages et le cache conservés pour celui-ci : installez-le avec `replace: true` une fois que l’utilisateur l’accepte File SHA-256: {hash} SHA-256 du fichier : {hash} Read the objects it is given Lire les objets qui lui sont transmis diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 574229b446..2d725c3870 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -5375,6 +5375,8 @@ antiAlias Sharp शार्प Version: {version} संस्करण: {version} Install “{name}” in place of the plug-in with the same ID? समान ID वाले प्लग-इन की जगह “{name}” इंस्टॉल करें? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. यह फ़ाइल इस ID के साथ पहले इंस्टॉल किया गया प्लग-इन नहीं है। इसे उस ID के लिए रखी गई सेटिंग्स और कैश मिलते हैं। + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. एक ऑटोमेशन ने इस प्लग-इन को इंस्टॉल करने के लिए कहा। इसे इंस्टॉल करने के लिए, स्वयं ऑब्जेक्ट › प्लग-इन्स › प्लग-इन इंस्टॉल करें… चुनें। +@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. एक ऑटोमेशन ने इस प्लग-इन को इंस्टॉल करने के लिए कहा। इसे इंस्टॉल करने के लिए, स्वयं ऑब्जेक्ट › प्लग-इन्स › प्लग-इन इंस्टॉल करें… चुनें। @msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees “{name}” आईडी {_1} वाले प्लग-इन से अलग मॉड्यूल है और उसके लिए रखी गई सेटिंग्स और कैश पा लेगा: उपयोगकर्ता की सहमति मिलने पर इसे `replace: true` से इंस्टॉल करें File SHA-256: {hash} फ़ाइल SHA-256: {hash} Read the objects it is given उसे दिए गए ऑब्जेक्ट्स पढ़ना diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 2e4f100338..5ea9a53d71 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6058,6 +6058,8 @@ axis Y Axis Y tengely Version: {version} Verzió: {version} Install “{name}” in place of the plug-in with the same ID? Telepíted a(z) „{name}” bővítményt az azonos azonosítójú bővítmény helyére? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Ez a fájl nem az a bővítmény, amely korábban ezzel az azonosítóval volt telepítve. Megkapja az ehhez az azonosítóhoz tárolt beállításokat és gyorsítótárat. + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Egy automatizálás kérte ennek a bővítménynek a telepítését. A telepítéshez válaszd ki te magad az Objektum › Bővítmények › Bővítmény telepítése… parancsot. +@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Egy automatizálás kérte ennek a bővítménynek a telepítését. A telepítéshez válaszd ki te magad az Objektum › Bővítmények › Bővítmény telepítése… parancsot. @msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees a(z) „{name}” más modul, mint a(z) {_1} azonosítójú bővítmény, és megkapná az ahhoz tárolt beállításokat és gyorsítótárat: telepítsd a `replace: true` megadásával, miután a felhasználó beleegyezett File SHA-256: {hash} A fájl SHA-256 értéke: {hash} Read the objects it is given Olvashatja a kapott objektumokat diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index a1df85189e..f051e4026e 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -5904,6 +5904,7 @@ axis Y Axis Sumbu Y Version: {version} Versi: {version} Install “{name}” in place of the plug-in with the same ID? Instal “{name}” sebagai pengganti plug-in dengan ID yang sama? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. File ini bukan plug-in yang sebelumnya diinstal dengan ID ini. File ini mendapatkan pengaturan dan cache yang disimpan untuk ID tersebut. + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Otomatisasi meminta untuk menginstal plug-in ini. Untuk menginstalnya, pilih Objek › Plug-in › Instal plug-in… sendiri. File SHA-256: {hash} SHA-256 file: {hash} Read the objects it is given Membaca objek yang diberikan kepadanya Change, add and delete objects in the document Mengubah, menambah, dan menghapus objek dalam dokumen diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index c1400c91e3..288720af3d 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5692,6 +5692,8 @@ axis Y Axis Asse Y Version: {version} Versione: {version} Install “{name}” in place of the plug-in with the same ID? Installare “{name}” al posto del plug-in con lo stesso ID? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Questo file non è il plug-in installato prima con questo ID. Riceve le impostazioni e la cache conservate per quell’ID. + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Un’automazione ha chiesto di installare questo plug-in. Per installarlo, scegli Oggetto › Plug-in › Installa plug-in… di persona. +@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Un’automazione ha chiesto di installare questo plug-in. Per installarlo, scegli Oggetto › Plug-in › Installa plug-in… di persona. @msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees “{name}” è un modulo diverso dal plug-in con ID {_1} e riceverebbe le impostazioni e la cache conservate per esso: installalo con `replace: true` quando l’utente acconsente File SHA-256: {hash} SHA-256 del file: {hash} Read the objects it is given Leggere gli oggetti che riceve diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 8a9e0cedc9..4d14c6e03d 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5757,6 +5757,8 @@ axis Y Axis Y 軸 Version: {version} バージョン: {version} Install “{name}” in place of the plug-in with the same ID? 同じ ID のプラグインの代わりに「{name}」をインストールしますか? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. このファイルは、以前にこの ID でインストールされていたプラグインではありません。この ID 用に保持されている設定とキャッシュを引き継ぎます。 + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. 自動操作がこのプラグインのインストールを要求しました。インストールするには、オブジェクト › プラグイン › プラグインをインストール… をご自身で選んでください。 +@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. 自動操作がこのプラグインのインストールを要求しました。インストールするには、オブジェクト › プラグイン › プラグインをインストール… をご自身で選んでください。 @msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees 「{name}」は ID {_1} のプラグインとは別のモジュールで、そのプラグイン用に保持されている設定とキャッシュを引き継ぐことになります。ユーザーが同意したら `replace: true` を指定してインストールしてください File SHA-256: {hash} ファイルの SHA-256: {hash} Read the objects it is given 渡されたオブジェクトの読み取り diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 83196486e7..d4b1316d6d 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6026,6 +6026,8 @@ axis Y Axis Y ღერძი Version: {version} ვერსია: {version} Install “{name}” in place of the plug-in with the same ID? დავაყენოთ „{name}“ იმავე ID-ის მქონე პლაგინის ნაცვლად? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. ეს ფაილი არ არის ამ ID-ით ადრე დაყენებული პლაგინი. ის მიიღებს ამ ID-სთვის შენახულ პარამეტრებს და ქეშს. + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. ავტომატიზაციამ ამ პლაგინის დაყენება მოითხოვა. მის დასაყენებლად აირჩიეთ ობიექტი › პლაგინები › პლაგინის დაყენება… თავად. +@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. ავტომატიზაციამ ამ პლაგინის დაყენება მოითხოვა. მის დასაყენებლად აირჩიეთ ობიექტი › პლაგინები › პლაგინის დაყენება… თავად. @msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees „{name}“ სხვა მოდულია, ვიდრე პლაგინი ID-ით {_1}, და მიიღებდა მისთვის შენახულ პარამეტრებს და ქეშს: მომხმარებლის თანხმობის შემდეგ დააყენეთ ის `replace: true` პარამეტრით File SHA-256: {hash} ფაილის SHA-256: {hash} Read the objects it is given მიღებული ობიექტების წაკითხვა diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 060c421f28..92d07ede7d 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5653,6 +5653,8 @@ axis Y Axis Y축 Version: {version} 버전: {version} Install “{name}” in place of the plug-in with the same ID? 같은 ID의 플러그인 대신 “{name}”을(를) 설치하시겠습니까? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. 이 파일은 이전에 이 ID로 설치된 플러그인이 아닙니다. 이 ID에 보관된 설정과 캐시를 받습니다. + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. 자동화가 이 플러그인 설치를 요청했습니다. 설치하려면 직접 개체 › 플러그인 › 플러그인 설치…를 선택하세요. +@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. 자동화가 이 플러그인 설치를 요청했습니다. 설치하려면 직접 개체 › 플러그인 › 플러그인 설치…를 선택하세요. @msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees “{name}”은(는) ID {_1}의 플러그인과 다른 모듈이며 그 플러그인에 보관된 설정과 캐시를 받게 됩니다. 사용자가 동의하면 `replace: true`로 설치하세요 File SHA-256: {hash} 파일 SHA-256: {hash} Read the objects it is given 전달받은 개체 읽기 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index e494230f8c..7a48ded8b6 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -5955,6 +5955,7 @@ axis Y Axis Y-as Version: {version} Versie: {version} Install “{name}” in place of the plug-in with the same ID? „{name}” installeren in plaats van de plug-in met dezelfde ID? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Dit bestand is niet de plug-in die eerder met deze ID was geïnstalleerd. Het krijgt de instellingen en cache die voor die ID bewaard zijn. + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Een automatisering heeft gevraagd deze plug-in te installeren. Kies zelf Object › Plug-ins › Plug-in installeren… om hem te installeren. File SHA-256: {hash} SHA-256 van het bestand: {hash} Read the objects it is given De objecten lezen die het krijgt Change, add and delete objects in the document Objecten in het document wijzigen, toevoegen en verwijderen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 052d39351a..225d78fa0d 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5929,6 +5929,8 @@ axis Y Axis Oś Y Version: {version} Wersja: {version} Install “{name}” in place of the plug-in with the same ID? Zainstalować „{name}” zamiast wtyczki o tym samym ID? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Ten plik nie jest wtyczką zainstalowaną wcześniej z tym ID. Otrzyma ustawienia i pamięć podręczną przechowywane dla tego ID. + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Automatyzacja poprosiła o zainstalowanie tej wtyczki. Aby ją zainstalować, wybierz Obiekt › Wtyczki › Zainstaluj wtyczkę… samodzielnie. +@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Automatyzacja poprosiła o zainstalowanie tej wtyczki. Aby ją zainstalować, wybierz Obiekt › Wtyczki › Zainstaluj wtyczkę… samodzielnie. @msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees „{name}” to inny moduł niż wtyczka o ID {_1} i otrzymałby przechowywane dla niej ustawienia i pamięć podręczną: zainstaluj go z `replace: true`, gdy użytkownik się zgodzi File SHA-256: {hash} SHA-256 pliku: {hash} Read the objects it is given Odczytywać obiekty, które otrzymuje diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 2f78a08845..22782896c4 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -5990,6 +5990,8 @@ axis Y Axis Eixo Y Version: {version} Versão: {version} Install “{name}” in place of the plug-in with the same ID? Instalar “{name}” no lugar do plug-in com o mesmo ID? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Este arquivo não é o plug-in instalado antes com este ID. Ele recebe as configurações e o cache guardados para esse ID. + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Uma automação pediu para instalar este plug-in. Para instalá-lo, escolha Objeto › Plug-ins › Instalar Plug-in… você mesmo. +@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Uma automação pediu para instalar este plug-in. Para instalá-lo, escolha Objeto › Plug-ins › Instalar Plug-in… você mesmo. @msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees “{name}” é um módulo diferente do plug-in com ID {_1} e receberia as configurações e o cache guardados para ele: instale-o com `replace: true` depois que o usuário concordar File SHA-256: {hash} SHA-256 do arquivo: {hash} Read the objects it is given Ler os objetos que recebe diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index e50ebf68a0..1e2bd7ac62 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5686,6 +5686,8 @@ axis Y Axis Ось Y Version: {version} Версия: {version} Install “{name}” in place of the plug-in with the same ID? Установить «{name}» вместо модуля с тем же ID? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Этот файл не является модулем, установленным ранее с этим ID. Он получит настройки и кэш, сохранённые для этого ID. + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Автоматизация запросила установку этого модуля. Чтобы установить его, выберите «Объект › Модули › Установить модуль…» сами. +@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Автоматизация запросила установку этого модуля. Чтобы установить его, выберите «Объект › Модули › Установить модуль…» сами. @msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees «{name}» отличается от модуля с ID {_1} и получил бы сохранённые для него настройки и кэш: установите его с `replace: true`, когда пользователь согласится File SHA-256: {hash} SHA-256 файла: {hash} Read the objects it is given Читать переданные ему объекты diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 79725b4435..1118cc7a9f 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -6097,6 +6097,8 @@ axis Y Axis Y-axel Version: {version} Version: {version} Install “{name}” in place of the plug-in with the same ID? Installera ”{name}” i stället för det plugin som har samma id? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Den här filen är inte det plugin som tidigare installerades med detta id. Den får de inställningar och den cache som sparats för detta id. + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. En automatisering bad om att installera det här pluginet. Välj själv Objekt › Plugins › Installera plugin… för att installera det. +@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. En automatisering bad om att installera det här pluginet. Välj själv Objekt › Plugins › Installera plugin… för att installera det. @msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees ”{name}” är en annan modul än pluginet med id {_1} och skulle få de inställningar och den cache som sparats för det: installera det med `replace: true` när användaren har godkänt File SHA-256: {hash} Filens SHA-256: {hash} Read the objects it is given Läsa objekten den får diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 05020e1676..46abcdd61d 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6031,6 +6031,8 @@ axis Y Axis Y ekseni Version: {version} Sürüm: {version} Install “{name}” in place of the plug-in with the same ID? Aynı kimliğe sahip eklentinin yerine “{name}” yüklensin mi? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Bu dosya, daha önce bu kimlikle yüklenen eklenti değil. O kimlik için saklanan ayarları ve önbelleği alır. + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Bir otomasyon bu eklentinin yüklenmesini istedi. Yüklemek için Nesne › Eklentiler › Eklentiyi Yükle… komutunu kendiniz seçin. +@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Bir otomasyon bu eklentinin yüklenmesini istedi. Yüklemek için Nesne › Eklentiler › Eklentiyi Yükle… komutunu kendiniz seçin. @msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees “{name}”, {_1} kimlikli eklentiden farklı bir modül ve onun için saklanan ayarları ve önbelleği alacak: kullanıcı onayladıktan sonra `replace: true` ile yükleyin File SHA-256: {hash} Dosya SHA-256: {hash} Read the objects it is given Kendisine verilen nesneleri okuma diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 13e60355b5..122e92737e 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5672,6 +5672,8 @@ axis Y Axis Вісь Y Version: {version} Версія: {version} Install “{name}” in place of the plug-in with the same ID? Встановити «{name}» замість модуля з тим самим ID? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Цей файл не є модулем, установленим раніше з цим ID. Він отримає налаштування й кеш, збережені для цього ID. + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Автоматизація попросила встановити цей модуль. Щоб установити його, виберіть «Об’єкт › Модулі › Встановити модуль…» самостійно. +@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Автоматизація попросила встановити цей модуль. Щоб установити його, виберіть «Об’єкт › Модулі › Встановити модуль…» самостійно. @msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees «{name}» відрізняється від модуля з ID {_1} і отримав би збережені для нього налаштування й кеш: встановіть його з `replace: true`, коли користувач погодиться File SHA-256: {hash} SHA-256 файлу: {hash} Read the objects it is given Читати передані йому об’єкти diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 6cb5aaf727..4145d0b9b5 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5673,6 +5673,8 @@ axis Y Axis Trục Y Version: {version} Phiên bản: {version} Install “{name}” in place of the plug-in with the same ID? Cài đặt “{name}” thay cho plug-in có cùng ID? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. Tệp này không phải là plug-in đã cài đặt trước đây với ID này. Tệp sẽ nhận cài đặt và bộ nhớ đệm được lưu cho ID đó. + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Một tác vụ tự động đã yêu cầu cài đặt plug-in này. Để cài đặt, hãy tự mình chọn lệnh Đối tượng › Plug-in › Cài đặt plug-in… trong menu. +@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. Một tác vụ tự động đã yêu cầu cài đặt plug-in này. Để cài đặt, hãy tự mình chọn lệnh Đối tượng › Plug-in › Cài đặt plug-in… trong menu. @msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees “{name}” là mô-đun khác với plug-in có ID {_1} và sẽ nhận cài đặt và bộ nhớ đệm được lưu cho plug-in đó: hãy cài đặt với `replace: true` khi người dùng đồng ý File SHA-256: {hash} SHA-256 của tệp: {hash} Read the objects it is given Đọc các đối tượng được trao cho nó diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 286b3a0ca4..0ed2c7475d 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -5913,6 +5913,8 @@ axis Y Axis Y 轴 Version: {version} 版本:{version} Install “{name}” in place of the plug-in with the same ID? 要安装“{name}”来替换具有相同 ID 的插件吗? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. 此文件不是之前以此 ID 安装的插件。它将获得为该 ID 保存的设置和缓存。 + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. 自动化请求安装此增效工具。要安装它,请自行选择“对象 › 增效工具 › 安装增效工具…”。 +@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. 自动化请求安装此增效工具。要安装它,请自行选择“对象 › 增效工具 › 安装增效工具…”。 @msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees “{name}”与 ID 为 {_1} 的插件是不同的模块,并会获得为其保存的设置和缓存:请在用户同意后使用 `replace: true` 安装 File SHA-256: {hash} 文件 SHA-256:{hash} Read the objects it is given 读取交给它的对象 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index c819eccd10..ff9de758dd 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5710,6 +5710,8 @@ axis Y Axis Y 軸 Version: {version} 版本:{version} Install “{name}” in place of the plug-in with the same ID? 要安裝「{name}」來取代具有相同 ID 的增效模組嗎? This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID. 此檔案不是先前以此 ID 安裝的增效模組。它會取得為該 ID 保存的設定和快取。 + Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. 自動化要求安裝此增效模組。若要安裝,請自行選擇「物件 › 增效模組 › 安裝增效模組…」。 +@msg Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself. 自動化要求安裝此增效模組。若要安裝,請自行選擇「物件 › 增效模組 › 安裝增效模組…」。 @msg “{name}” is another module than the plug-in with id {_1} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees 「{name}」與 ID 為 {_1} 的增效模組是不同的模組,並會取得為其保存的設定和快取:請在使用者同意後以 `replace: true` 安裝 File SHA-256: {hash} 檔案 SHA-256:{hash} Read the objects it is given 讀取交給它的物件 diff --git a/crates/ui-egui/src/io/open.rs b/crates/ui-egui/src/io/open.rs index 3ca34cdd77..4bdee0a6b6 100644 --- a/crates/ui-egui/src/io/open.rs +++ b/crates/ui-egui/src/io/open.rs @@ -215,16 +215,24 @@ fn permission_words(name: &str, commands: &[String]) -> String { /// place of another one with its id and get its settings and cache (`replace`); OK installs it if /// the module still has the hash the prompt showed, Cancel installs nothing → `{pending}` while it /// asks. The `grant` and `allowHeavy` of a request are ignored: only the dialog's OK approves. +/// Every question opens the approval dialog ([`dialogs::plugin::ask_install`]). When automation +/// asked ([`VectorcraftApp::automation_asked`]), the question has no OK that installs, its +/// `replace: true` doesn't skip asking about replacing, and the answer while it asks adds +/// `approval: "personMustStartInstall"`: the person starts Install Plug-in… to install it. /// Every text from the manifest is shown with its hidden characters escaped and its length capped /// ([`permissions::display_text`]). pub fn install_module(app: &mut VectorcraftApp, module: Value) -> Result { + let origin = if app.automation_asked() { dialogs::plugin::InstallOrigin::Automation } else { dialogs::plugin::InstallOrigin::Person }; + let automated = origin == dialogs::plugin::InstallOrigin::Automation; let given = |k: &str| module.get(k).map(|v| (k.to_string(), v.clone())); let source = Value::Object(["path", "dataBase64", "name"].into_iter().filter_map(given).collect()); let mut install = Value::Object(["path", "dataBase64", "name", "replace", "hash"].into_iter().filter_map(given).collect()); let info = app.run("plugin.inspect", source)?; let (needs_grant, heavy) = (info["needsApproval"] == true, info["budget"] == "heavy"); - // `replace: false` asks for no replacing, so only a module without `replace` asks. - let asks_replace = module.get("replace").is_none_or(Value::is_null) && info["needsReplace"] == true; + // `replace: false` asks for no replacing, so only a module without `replace` asks, or one whose + // `replace: true` automation gave. + let replace = module.get("replace").filter(|v| !v.is_null()); + let asks_replace = info["needsReplace"] == true && replace.is_none_or(|r| automated && *r == true); if !needs_grant && !heavy && !asks_replace { let r = app.run("plugin.install", install)?; report_installed(app, &r); @@ -245,13 +253,8 @@ pub fn install_module(app: &mut VectorcraftApp, module: Value) -> Result Result Result Option { /// File → Open… pub fn open_dialog(app: &mut VectorcraftApp) -> Result<(), String> { - if let Some(f) = app.services.open_async.as_mut() { - f(); + if crate::picks::open_async(app) { return Ok(()); } let path = diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e636f0520a..fdb466171f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -120,7 +120,8 @@ pub use craft_ui::docking::Monitor as DockMonitor; #[cfg(test)] pub(crate) use services::browsable_url; pub use services::{ - DownloadFn, ExistsFn, FileFilter, FilePick, Inbox, NativeInputHook, OpenUrlFn, PickOpen, PickSave, ReadFn, RevealFn, Services, WriteFn, + DownloadFn, ExistsFn, FileFilter, FilePick, Inbox, NativeInputHook, OpenUrlFn, PickOpen, PickSave, PickedInbox, ReadFn, RevealFn, Services, + WriteFn, }; use state::{GeometryStamp, InputGeneration, PendingScreenshot, QueuedScreenshot}; pub(crate) use state::{InputFrame, take_frame_once}; diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 19a6a1da67..489567bf38 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -1524,7 +1524,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.installPlugin", "Install Plug-in…", "", - "{path?: a .wasm file | dataBase64?: the module, name?: the file name shown as its source (with dataBase64), replace?: bool (true: the user agreed it takes the place of the plug-in with its id, as plugin.install), hash?: the module's SHA-256 (as plugin.install)} install a WebAssembly plug-in (plugin.install); without a path pick one (the web opens the browser's file picker; File › Open of a .wasm installs it too). A plug-in that lists permissions the user hasn't granted it asks the user first, showing its name, id, version, file SHA-256 and what each permission lets it do, so does a filter that asks for a heavy budget, and so does one without replace that would take the place of another module with its id and get its settings and cache (needsReplace in plugin.inspect), showing its id and file SHA-256: OK installs it if the file still has the SHA-256 shown, Cancel installs nothing. Only a person can press OK when it asks about permissions or a heavy budget: ui.dialog.confirm, answer_dialog and synthetic input are refused → plugin.install's result | {pending: \"pluginInstall\"} while it asks about permissions or a heavy budget | {pending: \"confirm\"} while it asks only about replacing", + "{path?: a .wasm file | dataBase64?: the module, name?: the file name shown as its source (with dataBase64), replace?: bool (true: the user agreed it takes the place of the plug-in with its id, as plugin.install), hash?: the module's SHA-256 (as plugin.install)} install a WebAssembly plug-in (plugin.install); without a path pick one (the web opens the browser's file picker; File › Open of a .wasm installs it too). A plug-in that lists permissions the user hasn't granted it asks the user first, showing its name, id, version, file SHA-256 and what each permission lets it do, so does a filter that asks for a heavy budget, and so does one without replace that would take the place of another module with its id and get its settings and cache (needsReplace in plugin.inspect), showing its id and file SHA-256: OK installs it if the file still has the SHA-256 shown, Cancel installs nothing. Only a person can press OK: ui.dialog.confirm, answer_dialog and synthetic input are refused. When automation asked (a control or MCP request, synthetic input, or a file dialog one of them opened, even when the person picked the file), replace: true doesn't skip the question about replacing, and nothing in the dialog installs it, the person's OK included: the person chooses Object › Plug-ins › Install Plug-in… to install it → plugin.install's result | {pending: \"pluginInstall\"} while it asks | {pending: \"pluginInstall\", approval: \"personMustStartInstall\"} while it asks what automation asked", ), ( "plugin.cancelRuns", diff --git a/crates/ui-egui/src/panels/libraries.rs b/crates/ui-egui/src/panels/libraries.rs index ac92a42793..78ce76da32 100644 --- a/crates/ui-egui/src/panels/libraries.rs +++ b/crates/ui-egui/src/panels/libraries.rs @@ -827,8 +827,7 @@ pub(crate) fn import(app: &mut VectorcraftApp, p: Value) -> Result, filters: Vec<(&'static str, &'static [&'static str])>, ) -> Result, String> { - if path.is_none() - && let Some(f) = app.services.open_async.as_mut() - { - f(); + if path.is_none() && crate::picks::open_async(app) { return Ok(None); } path.or_else(|| { diff --git a/crates/ui-egui/src/picks.rs b/crates/ui-egui/src/picks.rs index 4122c19410..7699516d78 100644 --- a/crates/ui-egui/src/picks.rs +++ b/crates/ui-egui/src/picks.rs @@ -105,6 +105,9 @@ struct Waiting { /// The automation roots in force when it was asked for: the entry runs again under them, not /// with the desktop's access of the frame the answer comes in. scope: ScopeHandle, + /// Automation asked for it ([`VectorcraftApp::automation_asked`]): the entry runs again as + /// automation's ([`as_automated`]). + automated: bool, } /// The app's file dialogs ([`VectorcraftApp`]'s `picks`). @@ -122,6 +125,13 @@ pub(crate) struct Picks { replacing: Option, /// The file that question's OK allows to be replaced while what asked runs again. replace_ok: Option, + /// What runs now handles the answer of a file dialog automation opened ([`as_automated`]). + automated: bool, + /// The web's file pickers opened ([`open_async`]) that have not answered yet + /// (`Services::picked_inbox`). + web_opens: u32, + /// Automation opened one of them: their answers are automation's. + web_open_automated: bool, } /// A save dialog's answer that names, once the app gave it an extension, a file that is there. @@ -152,6 +162,23 @@ impl Picks { self.entry = None; self.answer = None; self.replace_ok = None; + self.automated = false; + } + + /// What runs now handles the answer of a file dialog automation opened ([`as_automated`]). + pub(crate) fn automated(&self) -> bool { + self.automated + } + + /// One of the web's file pickers answered (a file, or canceled): whether the answer is + /// automation's. Once none is waiting, the next one the person opens answers for the person. + pub(crate) fn answer_web_open(&mut self) -> bool { + let automated = self.web_open_automated; + self.web_opens = self.web_opens.saturating_sub(1); + if self.web_opens == 0 { + self.web_open_automated = false; + } + automated } /// The kind of dialog open off the UI thread (`ui.inspect`'s `fileDialog`). @@ -188,6 +215,29 @@ pub(crate) fn as_entry(app: &mut VectorcraftApp, entry: impl FnOnce() -> Entr r } +/// Run `f` as handling the answer of a file dialog automation opened when `automated` (and inside +/// one already, as that): [`VectorcraftApp::automation_asked`] is true meanwhile. +pub(crate) fn as_automated(app: &mut VectorcraftApp, automated: bool, f: impl FnOnce(&mut VectorcraftApp) -> R) -> R { + let outer = app.picks.automated; + app.picks.automated = outer || automated; + let r = f(app); + app.picks.automated = outer; + r +} + +/// Opens the web's file picker, whose file arrives later through `Services::picked_inbox`, noting +/// whether automation asked for it ([`VectorcraftApp::automation_asked`]) → false when the host has +/// none. While a picker automation opened has not answered, every answer is automation's, also one +/// to a picker the person opened. +pub(crate) fn open_async(app: &mut VectorcraftApp) -> bool { + let automated = app.automation_asked(); + let Some(f) = app.services.open_async.as_mut() else { return false }; + f(); + app.picks.web_opens += 1; + app.picks.web_open_automated |= automated; + true +} + /// [`as_entry`] for a panel's or a dialog's button that `f` handles. pub(crate) fn button(app: &mut VectorcraftApp, f: impl Fn(&mut VectorcraftApp) + 'static) { let f = Rc::new(f); @@ -319,8 +369,8 @@ fn ask(app: &mut VectorcraftApp, request: PickRequest) -> Option { match (app.picks.entry.clone(), app.services.start_pick.as_mut()) { (Some(entry), Some(start)) => { if let Some(answer) = start(request.clone()) { - let doc = entry.document(app); - app.picks.waiting = Some(Waiting { request, answer, entry, doc, scope: ScopeHandle::capture() }); + let (doc, automated) = (entry.document(app), app.automation_asked()); + app.picks.waiting = Some(Waiting { request, answer, entry, doc, scope: ScopeHandle::capture(), automated }); return None; } } @@ -367,7 +417,7 @@ pub(crate) fn poll(app: &mut VectorcraftApp, ctx: &egui::Context) { // The dialog's thread is gone: as cancelled. Err(TryRecvError::Disconnected) => vec![], }; - let Some(Waiting { request, entry, doc, scope, .. }) = app.picks.waiting.take() else { return }; + let Some(Waiting { request, entry, doc, scope, automated, .. }) = app.picks.waiting.take() else { return }; app.picks.refused = false; // Once more: what waited for the dialog (files from the Finder, the menu bar) goes on. ctx.request_repaint(); @@ -379,7 +429,7 @@ pub(crate) fn poll(app: &mut VectorcraftApp, ctx: &egui::Context) { return; } app.picks.answer = Some((request, answer)); - rerun(app, entry); + as_automated(app, automated, |app| rerun(app, entry)); // An entry that took another way this time leaves the answer unused. app.picks.answer = None; }); diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs index 901c0a3409..03f098b0d9 100644 --- a/crates/ui-egui/src/services.rs +++ b/crates/ui-egui/src/services.rs @@ -47,6 +47,9 @@ pub type ExistsFn = Box bool>; pub type Inbox = Arc)>>>; +/// The web open picker's answers in order: the picked file, or `None` when it was canceled. +pub type PickedInbox = Arc)>>>>; + /// Starts a browser download of the bytes under a file name; an error when it couldn't be started /// (the save or export then fails as a refused write does: #1693). pub type DownloadFn = Box Result<(), String>>; @@ -89,11 +92,17 @@ pub struct Services { /// save panel's name names once the app gives it an extension ([`picks::ask_to_replace`]). /// Without it, no file counts as there. pub exists: Option, - /// Files that arrived asynchronously (web open / drops): (name, bytes). + /// Files that arrived asynchronously (web drops): (name, bytes). pub inbox: Option, + /// Web: the open picker's answers (`open_async`). A file opens as automation's when automation + /// opened a picker that has not answered yet. + pub picked_inbox: Option, + /// Web: files the page's script handed over (`window.vectorcraft.openFile`). They open as + /// automation's. + pub page_inbox: Option, /// Web: trigger a browser download instead of writing a path. pub download: Option, - /// Web: start an async open (bytes arrive via `inbox`). + /// Web: start an async open (bytes arrive via `picked_inbox`). pub open_async: Option>, /// Read the system clipboard's text (desktop). Without it, pasted text only arrives with /// egui's Paste event (web, and keyboard paste everywhere). diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index ee876b2e71..df2fe29912 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -630,9 +630,11 @@ fn an_unidentifiable_stale_injected_batch_fails_closed() { let _ = std::fs::remove_dir_all(base); } -/// A plug-in's permissions are approved only by a person. A control request (or MCP, which comes -/// through it) can't pass `grant`, can't answer the approval dialog or rewrite what it installs, -/// and a frame of synthetic input can't press its OK; the person at the keyboard can. +/// A plug-in's permissions are approved only by a person who asked for the install. A control +/// request (or MCP, which comes through it) can't pass `grant`, can't answer the approval dialog or +/// rewrite what it installs, and a frame of synthetic input can't press its OK. The person can't +/// approve the question automation asked either: they start Install Plug-in… themselves, which asks +/// again, and its OK installs. #[test] fn automation_cannot_approve_a_plugin_install() { let (tx, rx) = std::sync::mpsc::channel(); @@ -650,8 +652,9 @@ fn automation_cannot_approve_a_plugin_install() { let r = request(&mut app, &tx, "ui.menu.invoke", json!({"command": "ui.installPlugin", "params": {"dataBase64": data}})); assert_eq!(r["result"]["pending"], crate::dialogs::plugin::INSTALL_KIND, "{r}"); + assert_eq!(r["result"]["approval"], crate::dialogs::plugin::PERSON_MUST_START_INSTALL, "{r}"); let r = request(&mut app, &tx, "ui.dialog.confirm", json!({})); - refused(&r, "Only the person at the keyboard"); + refused(&r, ASKED); assert!(app.ui.dialog.is_some(), "the refused answer leaves it open"); assert!(vectorcraft_plugins::registry::get(id).is_none()); @@ -662,22 +665,293 @@ fn automation_cannot_approve_a_plugin_install() { let r = request(&mut app, &tx, "ui.dialog.set", json!({"fields": [{"field": "__params", "value": {"dataBase64": "AAAA"}}]})); assert!(r.get("error").is_none(), "{r}"); let r = request(&mut app, &tx, "ui.dialog.confirm", json!({})); - refused(&r, "Only the person at the keyboard"); + refused(&r, ASKED); assert!(app.ui.dialog.is_some() && vectorcraft_plugins::registry::get(id).is_none()); - // Shown for long enough for a person's OK, it still refuses automation. + // Shown for long enough for a person's OK, it still refuses automation, and the person too. + super::plugins::show_approval(&mut app); + let r = request(&mut app, &tx, "ui.dialog.confirm", json!({})); + refused(&r, ASKED); + app.synthetic_frame = true; + assert!(crate::dialogs::confirm(&mut app).unwrap_err().contains(ASKED)); + assert!(vectorcraft_plugins::registry::get(id).is_none()); + app.synthetic_frame = false; + assert!(crate::dialogs::confirm(&mut app).unwrap_err().contains(ASKED), "a person's OK on automation's question"); + assert!(app.ui.dialog.is_some() && vectorcraft_plugins::registry::get(id).is_none() && vectorcraft_plugins::registry::granted(id).is_none()); + + // The person starts Install Plug-in… with the file: a new question, which a person's OK approves + // once it was shown for the delay, and which automation still can't. + let r = app.run("ui.installPlugin", json!({"dataBase64": data})).unwrap(); + assert_eq!(r, json!({"pending": crate::dialogs::plugin::INSTALL_KIND}), "the person's question"); + assert_eq!(crate::dialogs::confirm(&mut app).unwrap()["pending"], crate::dialogs::plugin::INSTALL_KIND, "not shown yet"); super::plugins::show_approval(&mut app); let r = request(&mut app, &tx, "ui.dialog.confirm", json!({})); refused(&r, "Only the person at the keyboard"); - app.synthetic_frame = true; - assert!(crate::dialogs::confirm(&mut app).unwrap_err().contains("Only the person at the keyboard")); - assert!(vectorcraft_plugins::registry::get(id).is_none()); - app.synthetic_frame = false; - crate::dialogs::confirm(&mut app).unwrap(); assert_eq!(vectorcraft_plugins::registry::granted(id).map(|g| g.hash), Some(vectorcraft_plugins::permissions::sha256_hex(&bytes))); app.run("plugin.remove", json!({"id": id})).unwrap(); } +/// The refusal of an OK on an install question automation asked. +const ASKED: &str = "Automation asked to install this plug-in. To install it, choose Object › Plug-ins › Install Plug-in… yourself."; + +/// The approval module `id` that lists `doc.read`, and its bytes as base64. +fn approval_module(id: &str) -> (Vec, String) { + let manifest = format!(r#"{{"id":"{id}","name":"Approval","version":"1","kind":"filter","permissions":["doc.read"]}}"#); + let bytes = wat::parse_str(vectorcraft_plugins::wat::returning(&manifest, r#"{"objects":[]}"#)).unwrap(); + let data = vectorcraft_format::base64_encode(&bytes); + (bytes, data) +} + +/// An install question that a control request asked keeps OK disabled in the person's window, even +/// once it was shown for the delay: a person's click, Enter or Space install nothing, and the dialog +/// says to start Install Plug-in…. Cancel still closes it at once. +#[test] +fn a_person_cannot_approve_an_install_question_automation_asked() { + use super::plugins::{at, click_ok, ok_enabled, waiting, window}; + use egui_kittest::kittest::Queryable; + let id = "test.ui.approval.asked"; + let (tx, rx) = std::sync::mpsc::channel(); + let app = VectorcraftApp::new(Session::new(), Default::default()).with_control(rx); + let mut h = window(app); + let (_, data) = approval_module(id); + let r = request(h.state_mut(), &tx, "ui.menu.invoke", json!({"command": "ui.installPlugin", "params": {"dataBase64": data}})); + assert_eq!(r["result"]["approval"], crate::dialogs::plugin::PERSON_MUST_START_INSTALL, "{r}"); + at(&mut h, 10.0); + at(&mut h, 10.0); + at(&mut h, 10.6); + assert!(!ok_enabled(&h), "OK stays disabled past the delay"); + assert!(h.query_by_label(ASKED).is_some(), "the dialog says what to do"); + click_ok(&mut h, 10.7); + at(&mut h, 10.8); + assert!(waiting(h.state(), id), "a person's click"); + for (key, time) in [(egui::Key::Enter, 10.9), (egui::Key::Space, 11.0)] { + h.key_press(key); + at(&mut h, time); + assert!(waiting(h.state(), id), "{key:?}"); + } + assert!(crate::dialogs::confirm(h.state_mut()).unwrap_err().contains(ASKED), "a confirm that skips the button"); + assert!(waiting(h.state(), id)); + h.get_by_label("Cancel").click(); + at(&mut h, 11.1); + assert!(h.state().ui.dialog.is_none() && h.state().pending_plugin_install.is_none(), "Cancel"); + assert!(vectorcraft_plugins::registry::get(id).is_none() && vectorcraft_plugins::registry::granted(id).is_none()); +} + +/// A question automation asks in place of the one a person asked can't be approved: it keeps +/// automation's origin, though the person's question was shown and ready. +#[test] +fn automation_replacing_a_persons_install_question_does_not_take_its_approval() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut app = VectorcraftApp::new(Session::new(), Default::default()).with_control(rx); + let (mine, theirs) = ("test.ui.approval.mine", "test.ui.approval.theirs"); + app.run("ui.installPlugin", json!({"dataBase64": approval_module(mine).1})).unwrap(); + super::plugins::show_approval(&mut app); + let r = request(&mut app, &tx, "ui.menu.invoke", json!({"command": "ui.installPlugin", "params": {"dataBase64": approval_module(theirs).1}})); + assert_eq!(r["result"]["approval"], crate::dialogs::plugin::PERSON_MUST_START_INSTALL, "{r}"); + super::plugins::show_approval(&mut app); + assert!(crate::dialogs::confirm(&mut app).unwrap_err().contains(ASKED)); + for id in [mine, theirs] { + assert!(vectorcraft_plugins::registry::get(id).is_none() && vectorcraft_plugins::registry::granted(id).is_none(), "{id}"); + } + // Asked again by automation with the same file, it is still automation's. + let r = request(&mut app, &tx, "engine.execute", json!({"command": "ui.installPlugin", "params": {"dataBase64": approval_module(mine).1}})); + assert_eq!(r["result"]["approval"], crate::dialogs::plugin::PERSON_MUST_START_INSTALL, "{r}"); + super::plugins::show_approval(&mut app); + assert!(crate::dialogs::confirm(&mut app).unwrap_err().contains(ASKED)); + assert!(vectorcraft_plugins::registry::get(mine).is_none()); +} + +/// A file a person picks in the file dialog that a control request opened (Install Plug-in… or +/// File › Open with no path) is automation's request: its question can't be approved. The file +/// dialog a person opens asks a question they can approve. On the desktop the dialog answers later +/// ([`crate::picks`]); on the web the file arrives through the picker's inbox, apart from drops and +/// the page's script, which is automation. +#[test] +fn a_file_picked_in_a_dialog_automation_opened_stays_automations() { + let base = std::env::temp_dir().join(format!("vc-ui-approval-picked-{}", std::process::id())); + std::fs::create_dir_all(&base).unwrap(); + let (desktop, web) = ("test.ui.approval.picked", "test.ui.approval.picked.web"); + let (bytes, _) = approval_module(desktop); + let wasm = base.join("picked.wasm"); + std::fs::write(&wasm, &bytes).unwrap(); + let mut services = Services::default(); + let (shown, _) = super::picks::off_the_ui_thread(&mut services); + services.read = Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))); + let (tx, rx) = std::sync::mpsc::channel(); + let mut app = VectorcraftApp::new(Session::new(), services).with_control(rx); + let pick = |app: &mut VectorcraftApp| { + shown.answer(&[&s(wasm.clone())]); + crate::picks::poll(app, &egui::Context::default()); + }; + for command in ["ui.installPlugin", "file.open"] { + let r = request(&mut app, &tx, "ui.menu.invoke", json!({"command": command, "params": {}})); + assert!(app.file_dialog_open(), "{command}: {r}"); + pick(&mut app); + assert!(super::plugins::waiting(&app, desktop), "{command}: {:?}", app.ui.status); + super::plugins::show_approval(&mut app); + assert!(crate::dialogs::confirm(&mut app).unwrap_err().contains(ASKED), "{command}"); + crate::dialogs::cancel(&mut app); + } + let _ = app.run("ui.installPlugin", json!({})); + assert!(app.file_dialog_open()); + pick(&mut app); + super::plugins::show_approval(&mut app); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(vectorcraft_plugins::registry::granted(desktop).map(|g| g.hash), Some(vectorcraft_plugins::permissions::sha256_hex(&bytes))); + app.run("plugin.remove", json!({"id": desktop})).unwrap(); + + // The web: the browser's file picker answers through its own inbox, apart from the files a + // person drops and the ones the page's script hands over. + let (dropped, page) = <(crate::services::Inbox, crate::services::Inbox)>::default(); + let picked = crate::services::PickedInbox::default(); + let services = Services { + open_async: Some(Box::new(|| {})), + inbox: Some(dropped.clone()), + picked_inbox: Some(picked.clone()), + page_inbox: Some(page.clone()), + ..Default::default() + }; + let (tx, rx) = std::sync::mpsc::channel(); + let mut app = VectorcraftApp::new(Session::new(), services).with_control(rx); + let (by_drop, by_page) = ("test.ui.approval.picked.dropped", "test.ui.approval.picked.page"); + let file = |id: &str| (format!("{id}.wasm"), approval_module(id).0); + let hand_over = |app: &mut VectorcraftApp, inbox: &crate::services::Inbox, id: &str| { + inbox.lock().unwrap().push(file(id)); + app.drain_inbox(); + }; + // The picker answers with a file, or `None` when it is canceled. + let answer = |app: &mut VectorcraftApp, id: Option<&str>| { + picked.lock().unwrap().push(id.map(file)); + app.drain_inbox(); + }; + let r = request(&mut app, &tx, "ui.menu.invoke", json!({"command": "ui.installPlugin", "params": {}})); + assert!(r.get("error").is_none(), "{r}"); + // A file the person drops before the picker answers stays the person's. + hand_over(&mut app, &dropped, by_drop); + super::plugins::show_approval(&mut app); + crate::dialogs::confirm(&mut app).unwrap(); + assert!(vectorcraft_plugins::registry::granted(by_drop).is_some()); + answer(&mut app, Some(web)); + super::plugins::show_approval(&mut app); + assert!(crate::dialogs::confirm(&mut app).unwrap_err().contains(ASKED), "the web"); + crate::dialogs::cancel(&mut app); + app.run("ui.installPlugin", json!({})).unwrap(); + answer(&mut app, Some(web)); + super::plugins::show_approval(&mut app); + crate::dialogs::confirm(&mut app).unwrap(); + assert!(vectorcraft_plugins::registry::granted(web).is_some()); + // Opening the picker again as the person before it answers automation leaves the answer + // automation's. + let (again, after) = ("test.ui.approval.picked.again", "test.ui.approval.picked.after"); + let r = request(&mut app, &tx, "ui.menu.invoke", json!({"command": "ui.installPlugin", "params": {}})); + assert!(r.get("error").is_none(), "{r}"); + app.run("ui.installPlugin", json!({})).unwrap(); + answer(&mut app, Some(again)); + super::plugins::show_approval(&mut app); + assert!(crate::dialogs::confirm(&mut app).unwrap_err().contains(ASKED), "opened again"); + crate::dialogs::cancel(&mut app); + // Once the other picker is canceled, the next one the person opens answers for them again. + answer(&mut app, None); + app.run("ui.installPlugin", json!({})).unwrap(); + answer(&mut app, Some(after)); + super::plugins::show_approval(&mut app); + crate::dialogs::confirm(&mut app).unwrap(); + assert!(vectorcraft_plugins::registry::granted(after).is_some()); + // The page's script is automation. + hand_over(&mut app, &page, by_page); + super::plugins::show_approval(&mut app); + assert!(crate::dialogs::confirm(&mut app).unwrap_err().contains(ASKED), "the page's script"); + crate::dialogs::cancel(&mut app); + assert!(vectorcraft_plugins::registry::granted(by_page).is_none()); + for id in [web, by_drop, after] { + app.run("plugin.remove", json!({"id": id})).unwrap(); + } + let _ = std::fs::remove_dir_all(base); +} + +/// A `.wasm` that a control request opens by path (`app.open`) asks a question that can't be +/// approved; one the person opens can be. +#[test] +fn a_plugin_automation_opened_by_path_asks_a_question_no_one_approves() { + let base = std::env::temp_dir().join(format!("vc-ui-approval-open-{}", std::process::id())); + std::fs::create_dir_all(&base).unwrap(); + let id = "test.ui.approval.opened"; + let (bytes, _) = approval_module(id); + let wasm = base.join("opened.wasm"); + std::fs::write(&wasm, &bytes).unwrap(); + let services = Services { read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))), ..Default::default() }; + let (tx, rx) = std::sync::mpsc::channel(); + let mut app = VectorcraftApp::new(Session::new(), services).with_control(rx); + let r = request(&mut app, &tx, "app.open", json!({"path": s(wasm.clone())})); + assert!(r.get("error").is_none(), "{r}"); + assert!(super::plugins::waiting(&app, id)); + super::plugins::show_approval(&mut app); + assert!(crate::dialogs::confirm(&mut app).unwrap_err().contains(ASKED)); + crate::dialogs::cancel(&mut app); + crate::io::open_path(&mut app, &s(wasm.clone())).unwrap(); + super::plugins::show_approval(&mut app); + crate::dialogs::confirm(&mut app).unwrap(); + assert!(vectorcraft_plugins::registry::granted(id).is_some()); + app.run("plugin.remove", json!({"id": id})).unwrap(); + let _ = std::fs::remove_dir_all(base); +} + +/// A module that takes the place of another with its id, and asks for nothing else: automation's +/// `replace: true` doesn't skip the question and its question can't be approved. The question a +/// person asked keeps what it installs out of reach of `ui.dialog.set`, refuses a control confirm, +/// and the person's own OK replaces. +#[test] +fn automation_cannot_approve_replacing_a_plugin() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut app = VectorcraftApp::new(Session::new(), Default::default()).with_control(rx); + let id = "test.ui.approval.replaced"; + let plain = |output: &str| { + wat::parse_str(vectorcraft_plugins::wat::returning(&format!(r#"{{"id":"{id}","name":"Plain","kind":"filter"}}"#), output)).unwrap() + }; + let (first, other) = (plain(r#"{"objects":[]}"#), plain(r#"{"objects":[],"error":null}"#)); + let installed = || vectorcraft_plugins::registry::get(id).map(|p| p.hash().to_string()); + let first_hash = vectorcraft_plugins::permissions::sha256_hex(&first); + let data = vectorcraft_format::base64_encode(&other); + app.run("ui.installPlugin", json!({"dataBase64": vectorcraft_format::base64_encode(&first)})).unwrap(); + assert_eq!(installed(), Some(first_hash.clone())); + + for params in [json!({"dataBase64": data}), json!({"dataBase64": data, "replace": true})] { + let r = request(&mut app, &tx, "ui.menu.invoke", json!({"command": "ui.installPlugin", "params": params})); + assert_eq!(r["result"]["pending"], crate::dialogs::plugin::INSTALL_KIND, "{params}: {r}"); + assert_eq!(r["result"]["approval"], crate::dialogs::plugin::PERSON_MUST_START_INSTALL, "{params}: {r}"); + let pending = app.pending_plugin_install.as_ref().unwrap(); + assert_eq!(pending.message, "Install “Plain” in place of the plug-in with the same ID?"); + super::plugins::show_approval(&mut app); + assert!(crate::dialogs::confirm(&mut app).unwrap_err().contains(ASKED), "{params}"); + let r = request(&mut app, &tx, "ui.dialog.confirm", json!({})); + assert!(r["error"].as_str().is_some_and(|e| e.contains(ASKED)), "{r}"); + crate::dialogs::cancel(&mut app); + assert_eq!(installed(), Some(first_hash.clone()), "{params}"); + } + + // The person's question: a control request can't point its OK at other bytes, nor press it. + let r = app.run("ui.installPlugin", json!({"dataBase64": data})).unwrap(); + assert_eq!(r, json!({"pending": crate::dialogs::plugin::INSTALL_KIND})); + let evil = vectorcraft_format::base64_encode(&plain(r#"{"objects":[{"name":"x"}]}"#)); + let r = request(&mut app, &tx, "ui.dialog.set", json!({"fields": [{"field": "__params", "value": {"dataBase64": evil, "replace": true}}]})); + assert!(r.get("error").is_none(), "{r}"); + super::plugins::show_approval(&mut app); + let r = request(&mut app, &tx, "ui.dialog.confirm", json!({})); + assert!(r["error"].as_str().is_some_and(|e| e.contains("Only the person at the keyboard")), "{r}"); + assert_eq!(installed(), Some(first_hash.clone())); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(installed(), Some(vectorcraft_plugins::permissions::sha256_hex(&other)), "the bytes the question showed"); + app.run("plugin.remove", json!({"id": id})).unwrap(); + // A module that asks for nothing still installs at once when automation asks. + let free = "test.ui.approval.free"; + let module = wat::parse_str(vectorcraft_plugins::wat::returning(&format!(r#"{{"id":"{free}","name":"Free","kind":"filter"}}"#), "{}")).unwrap(); + let params = json!({"dataBase64": vectorcraft_format::base64_encode(&module)}); + let r = request(&mut app, &tx, "ui.menu.invoke", json!({"command": "ui.installPlugin", "params": params})); + assert!(r["result"]["pending"].is_null() && app.ui.dialog.is_none(), "{r}"); + assert!(vectorcraft_plugins::registry::get(free).is_some()); + app.run("plugin.remove", json!({"id": free})).unwrap(); +} + type DialogHarness = egui_kittest::Harness<'static, VectorcraftApp>; /// Runs one UI pass of `h` for each batch of synthetic input the control path queued, each taken @@ -702,10 +976,10 @@ fn still_waiting(h: &DialogHarness, id: &str) -> bool { && vectorcraft_plugins::registry::granted(id).is_none() } -/// Keys and pointer input sent through the control path approve nothing at the open install -/// approval: Enter and Space press nothing with no button focused, and with OK focused they and a -/// `ui.click` on it press it and are refused. `ui.pointer` drives the canvas. A person's click on -/// the same button approves. +/// Keys and pointer input sent through the control path approve nothing at the install approval a +/// person asked for: Enter and Space press nothing with no button focused, and with OK focused they +/// and a `ui.click` on it press it and are refused. `ui.pointer` drives the canvas. A person's click +/// on the same button approves. #[test] fn injected_keys_and_pointer_input_cannot_approve_a_plugin_install() { use egui_kittest::kittest::{NodeT, Queryable}; @@ -717,8 +991,8 @@ fn injected_keys_and_pointer_input_cannot_approve_a_plugin_install() { let manifest = format!(r#"{{"id":"{id}","name":"Approval","version":"1","kind":"filter","permissions":["doc.read"]}}"#); let bytes = wat::parse_str(vectorcraft_plugins::wat::returning(&manifest, r#"{"objects":[]}"#)).unwrap(); let data = vectorcraft_format::base64_encode(&bytes); - let r = request(&mut app, &tx, "ui.menu.invoke", json!({"command": "ui.installPlugin", "params": {"dataBase64": data}})); - assert_eq!(r["result"]["pending"], crate::dialogs::plugin::INSTALL_KIND, "{r}"); + let r = app.run("ui.installPlugin", json!({"dataBase64": data})).unwrap(); + assert_eq!(r, json!({"pending": crate::dialogs::plugin::INSTALL_KIND}), "the person's question"); let mut fonts = false; let mut h: DialogHarness = egui_kittest::Harness::builder().with_size(egui::vec2(900.0, 800.0)).build_ui_state( move |ui, app: &mut VectorcraftApp| { diff --git a/crates/ui-egui/src/tests/plugins.rs b/crates/ui-egui/src/tests/plugins.rs index 105c223261..0a80254db0 100644 --- a/crates/ui-egui/src/tests/plugins.rs +++ b/crates/ui-egui/src/tests/plugins.rs @@ -45,11 +45,11 @@ pub(super) fn show_approval(app: &mut VectorcraftApp) { panic!("the approval's OK never took input"); } -type Window = egui_kittest::Harness<'static, VectorcraftApp>; +pub(super) type Window = egui_kittest::Harness<'static, VectorcraftApp>; /// The dialog layer of `app` in a window, each frame drawn at the time [`at`] gives it. Building it /// draws frames at times of its own, so a test opens the dialog after. -fn window(app: VectorcraftApp) -> Window { +pub(super) fn window(app: VectorcraftApp) -> Window { let mut fonts = false; egui_kittest::Harness::builder().with_size(egui::vec2(900.0, 800.0)).build_ui_state( move |ui, app: &mut VectorcraftApp| { @@ -64,26 +64,26 @@ fn window(app: VectorcraftApp) -> Window { } /// Draws a frame of `h` at `time` seconds. -fn at(h: &mut Window, time: f64) { +pub(super) fn at(h: &mut Window, time: f64) { h.input_mut().time = Some(time); h.step(); } /// Whether the approval's OK is enabled, as a screen reader reads it. -fn ok_enabled(h: &Window) -> bool { +pub(super) fn ok_enabled(h: &Window) -> bool { use egui_kittest::kittest::{NodeT, Queryable}; !h.get_by_label("OK").accesskit_node().is_disabled() } /// Clicks the approval's OK as a person does, on a frame at `time` seconds. -fn click_ok(h: &mut Window, time: f64) { +pub(super) fn click_ok(h: &mut Window, time: f64) { use egui_kittest::kittest::Queryable; h.get_by_label("OK").click(); at(h, time); } /// The approval is open and plug-in `id` neither installed nor granted. -fn waiting(app: &VectorcraftApp, id: &str) -> bool { +pub(super) fn waiting(app: &VectorcraftApp, id: &str) -> bool { app.ui.dialog.as_ref().is_some_and(|d| d.kind == dialogs::plugin::INSTALL_KIND) && vectorcraft_plugins::registry::get(id).is_none() && vectorcraft_plugins::registry::granted(id).is_none() @@ -184,7 +184,7 @@ fn plugin_effects_open_the_effect_dialog_with_their_schema() { /// A filter `id` named `name` whose manifest lists `permissions` (a JSON list), at a version with /// a bidirectional override, a tab and a bell in it. -fn asking(id: &str, name: &str, permissions: &str) -> Vec { +pub(super) fn asking(id: &str, name: &str, permissions: &str) -> Vec { let manifest = json!({"id": id, "name": name, "version": "3.0\u{202E}\t\u{0007}", "kind": "filter"}).to_string(); let manifest = format!(r#"{},"permissions":{permissions}}}"#, manifest.trim_end_matches('}')); wat::parse_str(tpl::returning(&manifest, r#"{"objects":[]}"#)).unwrap() @@ -522,9 +522,8 @@ fn the_install_approval_waits_again_when_the_focus_leaves_and_comes_back_between h.state_mut().run("plugin.remove", json!({"id": id})).unwrap(); } -/// A module with other bytes than the one installed with its id asks before taking its place: in -/// the ordinary confirm when it needs nothing that only a person can approve, and in the approval -/// dialog, with its id and file hash, when it also asks for a heavy budget. +/// A module with other bytes than the one installed with its id asks before taking its place, in +/// the approval dialog, with its id and file hash, also when it asks for a heavy budget. #[test] fn installing_a_plugin_over_another_module_asks_first() { let mut app = app(); @@ -538,10 +537,13 @@ fn installing_a_plugin_over_another_module_asks_first() { let other = plain(r#"{"objects":[],"error":null}"#); let other_hash = vectorcraft_plugins::permissions::sha256_hex(&other); let r = app.run("ui.installPlugin", module(&other)).unwrap(); - assert_eq!(r["pending"], dialogs::confirm::KIND); - let d = app.ui.dialog.as_ref().unwrap(); - assert_eq!(d.str("message"), "Install “Plain” in place of the plug-in with the same ID?"); - assert_eq!(d.str("detail"), format!("ID: {id}\nFile SHA-256: {other_hash}\n\n{takes_place}")); + assert_eq!(r, json!({"pending": dialogs::plugin::INSTALL_KIND})); + let pending = app.pending_plugin_install.as_ref().unwrap(); + assert_eq!(pending.message, "Install “Plain” in place of the plug-in with the same ID?"); + assert_eq!(pending.detail, format!("ID: {id}\nFile SHA-256: {other_hash}\n\n{takes_place}")); + assert_eq!(pending.params["replace"], json!(true), "OK replaces the one installed"); + assert_eq!(dialogs::confirm(&mut app).unwrap()["pending"], dialogs::plugin::INSTALL_KIND, "it waits like any other question"); + show_approval(&mut app); dialogs::confirm(&mut app).unwrap(); assert!(app.ui.dialog.is_none()); assert_eq!(vectorcraft_plugins::registry::get(id).map(|p| p.hash().to_string()), Some(other_hash)); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index dbaf2b48ab..bf9ac19bca 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -217,7 +217,9 @@ export's own parameters); `ui.dialog.confirm` writes the file. An importer plug- `pluginImport` dialog when `file.openWith` is given no `params`: one field per parameter, `__plugin`, and `__open` (`path` and `importer`); `ui.dialog.confirm` opens the file. `ui.installPlugin {path?}` installs a `.wasm` (without a path it asks for one); one that needs approval opens the `pluginInstall` dialog, which only a person can confirm -(`ui.dialog.confirm` and injected input are refused); see [plugins.md](plugins.md). +(`ui.dialog.confirm` and injected input are refused). When a control request opened it, directly or through a file +dialog, the reply adds `approval: "personMustStartInstall"` and nobody can confirm it: the person chooses Object › +Plug-ins › Install Plug-in… to install the plug-in; see [plugins.md](plugins.md). Overprint Black: the Edit → Edit Colors → Overprint Black… menu item opens a `command` parameter dialog for `edit.colors.overprintBlack` (fields `remove`, `percentage`, `fill`, `stroke`, `includeCmyBlacks`, @@ -1059,7 +1061,7 @@ app: | `vectorcraft.docInspect(params)` / `vectorcraft.uiInspect(params)` | `document.inspect` / `ui.inspect` | | `vectorcraft.inspect()` | `ui.inspect` (the name before `uiInspect`) | | `vectorcraft.screenshot(params)` | `ui.screenshot` (no `path`: the PNG comes back inline, as `data` and as `pngBase64`) | -| `vectorcraft.openFile(name, bytes)` | opens the bytes the way a dropped file opens (returns `true` once queued) | +| `vectorcraft.openFile(name, bytes)` | opens the bytes the way a dropped file opens, as automation: a plug-in install it asks for has to be started by the person (returns `true` once queued) | | `vectorcraft.captureDownloads(on)` | while on, a save or export is kept in the page instead of downloaded | | `vectorcraft.files()` / `vectorcraft.readFile(name)` | the kept files (`{path, size}`) / one of them as a `Uint8Array` | diff --git a/docs/mcp.md b/docs/mcp.md index 66e1c88c90..a32c7e5b9b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -8009,7 +8009,10 @@ needs a person's approval too, and `plugin.install` refuses `grant: [permission] app connected, `ui.installPlugin` opens the approval dialog and returns `{pending: "pluginInstall"}`; `answer_dialog`, `ui.dialog.confirm` and injected input can't confirm it. Its OK takes input only once the question has been on screen in the focused window for half a second, counted again for every new question and after the window gets the focus -back. A `vectorcraft-cli mcp` started with `--allow-plugin-grants` +back. A question that a request asked, directly or through a file dialog it opened, can't be approved by the person +either: the reply adds `approval: "personMustStartInstall"`, OK stays disabled, and the person installs the plug-in +with Object › Plug-ins › Install Plug-in… themselves. A request's `replace: true` doesn't skip the question about +taking another module's place. A `vectorcraft-cli mcp` started with `--allow-plugin-grants` accepts `grant` and `allowHeavy` from any request, in that session only (headless; it can't be combined with `--connect`, `--automation-read-root` or `--automation-write-root`). A server confined to folders has no plug-in store, so it reads no `grants.json`, and a module that lists permissions or asks for a heavy budget does not install diff --git a/docs/plugins.md b/docs/plugins.md index dacf59b218..410b20719c 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -217,7 +217,16 @@ capability model proposed for PhotoCraft plug-ins https://chromium.googlesource.com/chromium/src/+/refs/heads/main/docs/security/security-considerations-for-browser-ui.md gives for dialog boxes), so a click, Enter or Space, or a press begun before then, installs nothing. The half second starts over for every new question, even one that reads the same, and when the window gets the focus back. Cancel - and Escape work at once. + and Escape work at once. A module that would take the place of the installed plug-in with its id asks in the same + dialog. +- **An install that automation asked for is not approved in its dialog.** When a control request, MCP, injected input, + or a file dialog that one of them opened (even when the person picks the file there) leads to the approval dialog, its + OK stays disabled, a confirm is refused even after the half second, and the dialog says that automation asked for the + install. `ui.installPlugin` returns `{pending: "pluginInstall", approval: "personMustStartInstall"}`. Cancel still + closes it. To install the plug-in, the person chooses Object › Plug-ins › Install Plug-in… and picks the file, or + opens it with File › Open or drops it on the window: the module is inspected again and the new question waits its + half second. A request's `replace: true` does not skip asking about taking another module's place. A module that + needs no approval still installs at once from any request. This is VectorCraft's own rule. - `plugin.install` refuses the module, naming the permissions it needs, unless this module already has a recorded grant (`plugin.inspect`'s `needsApproval` tells it beforehand). A request's `grant` is refused too: `plugin.install` takes it only with the approval the dialog gives. A `vectorcraft-cli mcp` or `run` started with `--allow-plugin-grants` diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b2ecfe76b5..99c991d493 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1695,6 +1695,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **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. - **Only an approval records a plug-in grant (G17):** `vectorcraft_plugins::registry::grant` takes a `GrantToken`, which the engine makes after an approval and when it loads `grants.json`, and `registry::install`, `install_bytes` and folder loads refuse a module that lists permissions no recorded grant gives this very module (`Error::NeedsApproval`), leaving the plug-in under its id and the revision as they were. A module without `permissions`, or with an empty list, installs as before. `docs/plugins.md` says what the approval does not protect against (native code linked into the app, another program running as the same user, the unsigned `grants.json`). Regressions: `crates/plugins/src/registry.rs`, `crates/plugins/tests/grant_sites.rs`, `crates/engine/src/tests/plugins.rs`. Scores and hours unchanged. - **Plug-in approval waits before OK (G17):** the approval dialog's OK stays disabled until its question has been on screen in the focused window for half a second, VectorCraft's own value; a click, Enter, Space, or a press begun before then installs nothing, and the dialog stays open. The wait starts over for every new question, even one that reads the same, and when the window gets the focus back, so a question swapped in just before a click is not approved by it. Cancel works at once. Regressions: `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. +- **An install automation asked for needs the person to start it (G17):** an approval question that a control request, MCP, injected input or a file dialog one of them opened leads to keeps OK disabled and refuses every confirm, a person's click included; it says to choose Object › Plug-ins › Install Plug-in…, and `ui.installPlugin` answers `approval: "personMustStartInstall"`. The person's own Install Plug-in…, File › Open or drop inspects the module again and asks a new question. Automation's `replace: true` no longer skips the question about taking another module's place, and that question now opens in the approval dialog for everyone. VectorCraft's own rule. Regressions: `crates/ui-egui/src/tests/automation.rs`, `crates/ui-egui/src/tests/plugins.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. - **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. From 512627b39f013dc14816482030abab91daecfa04 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 21:59:27 -0800 Subject: [PATCH 23/43] Docs: the DXF CNC / CAM preset is listed as shipped docs/gaps.md, docs/file-format-parity.md and docs/ui-parity.md still listed the CNC preset as open after #5500 added it. They now name DXF Options' Preset menu and dxfPreset: "cnc" on document.exportDxf; VCarve QA stays open. The ui-parity row also no longer lists native CIRCLE/ARC export as pending. --- docs/file-format-parity.md | 2 +- docs/gaps.md | 4 ++-- docs/ui-parity.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index fa682c0272..60cd24ace1 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -290,7 +290,7 @@ Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors, | EPS | open, place, save | yes (own PostScript L3 interpreter, Illustrator EPS editing data structure) | yes (L2/L3, TIFF previews) | CIDFonts the file doesn't carry not drawn, but for those over a predefined Unicode CMap (`UniJIS-UCS2-H`), set as type in the font of the CIDFont's name; name-keyed fonts in a FontSet set as type by name; #505; images encoded with `CCITTFaxDecode` (ITU-T T.4 Group 3 and T.6 Group 4, with `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1`) draw, and damaged data keeps the rows decoded before its first damaged row; Type 42 (TrueType) fonts set type at its size (#1553); an embedded Type 1 font's encrypted part is read for its glyphs, which space its type, outline it and draw glyphs without a character, in plain and composite fonts; a Type 42 font that carries its TrueType font gives each code its character by its glyph name or, for names that say nothing (`/g12`), by the glyph's entry in the font's own character map, spaces its type by its own widths and draws glyphs that stand for no character from its outlines; the show operators' spacing along the baseline (`ashow`, `widthshow`, `xshow`, `yshow`, `xyshow`, and the moves of `kshow`'s procedure, run between each two characters) is kept on live type as tracking, so type in a font that isn't installed keeps the file's line lengths (spacing that moves characters off the baseline, as `yshow`, `xyshow` or a `kshow` procedure can, places each character as its own text object); composite (Type 0) fonts with the 8/8 mapping, as PDF-to-PostScript converters write two-byte fonts, draw a Type 42 descendant's glyphs from the TrueType outlines it carries and an embedded Type 1 descendant's from its charstrings, and set other descendants' characters as type at the composite font's size (other mappings are still set as type in the font of their name); CID-keyed fonts (`composefont`, `FMapType` 9 over a CMap built with the CIDInit operators or `Identity-H`/`Identity-V`; `bfchar` and `bfrange` mappings give a base-font descendant its codes or glyph names) draw the glyphs of the Type 0 CIDFonts (Type 1 charstrings in their `StartData` binary data or `GlyphDirectory`) and Type 2 CIDFonts (TrueType glyphs through `CIDMap`) they carry, and the CID-keyed fonts of a FontSet's Compact Font Format data (Type 2 charstrings); a clip only counts as covering the page when its shape does (#1542); an EPS another app copied pastes as editable vector art (`clipboard.importEps`, #1499); a stitched shading keeps its hard colour changes on import (#1654); sampled functions (type 0) interpolate between their samples, multilinearly or by cubics with `Order 3`, and a sampled axial or radial gradient has a stop at each sample; a gradient the page paints as a row of abutting one-colour strips (as Level 2 writers draw smooth shades) imports as one linear gradient, without the seams the strips leave when each is drawn alone; CMYK images, CMYK JPEGs included, keep their ink amounts on import, as from PDF, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask; ruzstd 0.9.1 for compressed editing data (#1807, RUSTSEC-2026-0344) | | PostScript `.ps` | open, place | yes (the EPS reader reads its first page; `.epsf` files open and place as EPS) | via Print to PostScript | `tests_epsimport.rs` | | SVG / SVGZ | open, place, save | yes | yes (SVG Options, Preserve Editing, symbols, filters, rich text) | strongest format; tests in `crates/svg/tests`; text on a path follows basic shapes as their SVG 2 equivalent paths (#1527); CSS transforms and the `font` shorthand (#1565), percentage lengths in symbols (#1596), Inkscape flowed text as area type (#1568) and Inkscape pages as artboards (#1601) import; an unsupported filter is named in its warning (#1598); an artboard exported on its own (Export As with Use Artboards, Export for Screens, Asset Export) has the artboard's name as ``, Save the document's (#3544); attributes in other applications' XML namespaces (`plotter:pen="2"`) kept on their objects from import to export, as `data-*` attributes are (`crates/svg/tests/it/foreign_attributes.rs`); simple shapes export as `<rect>`, `<circle>`, `<ellipse>`, `<line>`, `<polygon>` and `<polyline>` where they draw the same as the path (#4652, `crates/svg/tests/it/basic_shapes.rs`); the regions of exported masks and filters (opacity masks, inverted masks, knockout groups) cover the art they apply to, and over type keep a region that covers any artwork (#6401, `crates/svg/tests/it/blend_export.rs`) | -| DXF | open, export | yes | yes (R12–2018) | R13–2018 closed SPLINE flags and control-point closure on import verified (#1299); circular cubic paths export as native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE while non-circular curves keep the existing fallback (`crates/cad/src/tests.rs`); [Autodesk group 70](https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-DXF/files/GUID-E1F884F8-AA90-4864-A215-3182D47A9C74.htm). DXF 2000–2018 LAYOUT objects write the paper image origin as 148/149 (was a stray 158), the shade plot codes (2004+) and the model type flag, and the plot style placeholder carries no subclass marker (#3085; structure test `r2000_and_later_files_have_the_structure_cad_apps_require`, checked with ezdxf's audit; not yet confirmed in Autodesk Fusion). CNC preset and VCarve QA remain open | +| DXF | open, export | yes | yes (R12–2018) | R13–2018 closed SPLINE flags and control-point closure on import verified (#1299); circular cubic paths export as native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE while non-circular curves keep the existing fallback (`crates/cad/src/tests.rs`); [Autodesk group 70](https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-DXF/files/GUID-E1F884F8-AA90-4864-A215-3182D47A9C74.htm). DXF 2000–2018 LAYOUT objects write the paper image origin as 148/149 (was a stray 158), the shade plot codes (2004+) and the model type flag, and the plot style placeholder carries no subclass marker (#3085; structure test `r2000_and_later_files_have_the_structure_cad_apps_require`, checked with ezdxf's audit; not yet confirmed in Autodesk Fusion). DXF Options' Preset menu and `document.exportDxf`'s `dxfPreset: "cnc"` set the CNC / CAM options (#5500); VCarve QA remains open | | HP-GL/2 `.plt`, `.hpgl`, `.hgl`, `.hpg` | — | yes (pen moves, pens and widths, line types and attributes, circles, arcs, wedges and Bézier curves, filled and edged rectangles, wedges and polygons, encoded polylines, scaling and rotation; labels, symbol mode, raster images and hatching aren't drawn) | no | recognized by its first instructions after any printer escapes, under its own extensions or one no other format reads; `crates/cad/src/hpgl/tests.rs`, `tests_hpgl.rs`, `import_fuzz.rs` | | DWG | open, export | no | no | no open spec; DXF instead | | EMF / WMF | open, export | yes | yes | an oversized embedded PNG or JPEG is refused from its header, before any pixels are decoded (#1776) | diff --git a/docs/gaps.md b/docs/gaps.md index 1ce258a111..3082873bb4 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -323,8 +323,8 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi - **Done:** Numeric fields keep a text drag's start when the pointer gains focus and movement exceeds the click threshold (#1326, in part). A completed focus click selects the whole value; keyboard focus, double-click and numeric stepping remain regression-tested. Short movements still classified as clicks can select all; that case remains open. -- **Done:** DXF reads control-point SPLINE closure (#1299 follow-up after #1320); open curves keep their open state. DXF export now recognizes circular cubic paths conservatively and writes native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE, and a non-circular ellipse keeps the existing fallback. Alter Paths for Appearance outlines only the strokes a CAD line can't draw (aligned, profiled, offset dashes, wider than 2.11 mm, or in R13 and R14), so a 1 pt circle or arc stays a CIRCLE or ARC. CNC preset and VCarve QA remain open. Escape leaves Artboard editing for Selection; during a drag it first cancels the gesture. Headless egui key events cover idle Escape, active-drag cancellation and a subsequent Escape in `tests/distortkeys.rs`. -- **Done:** DXF reads control-point SPLINE closure (#1299 follow-up after #1320); open curves keep their open state. DXF export now recognizes circular cubic paths conservatively and writes native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE. A closed four-cubic ellipse, or a circle scaled out of proportion, is written as a native ELLIPSE in R13 and later; R12 keeps the existing fallback. CNC preset and VCarve QA remain open. Escape leaves Artboard editing for Selection; during a drag it first cancels the gesture. Headless egui key events cover idle Escape, active-drag cancellation and a subsequent Escape in `tests/distortkeys.rs`. +- **Done:** DXF reads control-point SPLINE closure (#1299 follow-up after #1320); open curves keep their open state. DXF export now recognizes circular cubic paths conservatively and writes native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE, and a non-circular ellipse keeps the existing fallback. Alter Paths for Appearance outlines only the strokes a CAD line can't draw (aligned, profiled, offset dashes, wider than 2.11 mm, or in R13 and R14), so a 1 pt circle or arc stays a CIRCLE or ARC. DXF Options' Preset menu and `document.exportDxf`'s `dxfPreset: "cnc"` set the CNC / CAM options (#5500); VCarve QA remains open. Escape leaves Artboard editing for Selection; during a drag it first cancels the gesture. Headless egui key events cover idle Escape, active-drag cancellation and a subsequent Escape in `tests/distortkeys.rs`. +- **Done:** DXF reads control-point SPLINE closure (#1299 follow-up after #1320); open curves keep their open state. DXF export now recognizes circular cubic paths conservatively and writes native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE. A closed four-cubic ellipse, or a circle scaled out of proportion, is written as a native ELLIPSE in R13 and later; R12 keeps the existing fallback. DXF Options' Preset menu and `document.exportDxf`'s `dxfPreset: "cnc"` set the CNC / CAM options (#5500); VCarve QA remains open. Escape leaves Artboard editing for Selection; during a drag it first cancels the gesture. Headless egui key events cover idle Escape, active-drag cancellation and a subsequent Escape in `tests/distortkeys.rs`. - **Done:** Simplify and Add Anchor Points clear stale component selections on rewritten paths; Simplify previews and reports original/new point counts (#1272). - **Done:** Object › Path › Simplify opens its slider at the Auto-Simplify level for the selection and previews that result, as the [manual-simplify page](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/manually-simplify-paths.html) describes; simplifying only a Direct Selection part of a path is done too (G1). diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 58e5276573..1471590da0 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -436,7 +436,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po | Window › Help Bar shows or hides the tool-help strip apart from the status bar; kept with the preferences and in saved workspaces; Presentation Mode hides both bars | done | `window.helpBar`; `help_bar_toggle_is_independent_and_saved_in_workspaces`, `help_bar_and_status_bar_render_independently`, `legacy_help_bar_visibility_follows_each_layouts_status_bar` | | Background Export: a progress dialog with the file name, the artboard count and a bar (busy for one artboard and for formats made in one go), Cancel (Esc) and Continue in Background; it opens after 0.4 s and never over another dialog. A cancelled export writes no file | done | #2564; on the shared `vectorcraft_doc::cancel` token (#2546), so a cancelled render keeps no half-drawn pattern tile or effect raster. The bar moves between artboards, not within one. Save As, Export for Screens and Export Selection don't show it yet | | Swatch, graphic style and symbol library panels: each library opens in a panel of its own, several at once, that floats, groups, docks and closes like any panel; opening an open library brings its panel forward; Previous / Next Library change the library in place; the panel menu's Persistent reopens the panel at startup with its view and find field (a library loaded from a file is read again; when its file is missing the panel reopens showing the file's path with Locate Library…, and the status bar names the file) | done | #2603; panel ids `library:<kind>:<id>`, `ui.libraryPanel {panel, library?, view?, showFind?, persistent?}`, `ui.libraryPanel.locate {panel, path?}`; the library panels have no sort option, as before | -| DXF export/import preserves intended curve closure | done | #1299; R13–2018 closed SPLINE bit set/read; an open curve ending at its start stays open; knots and control points unchanged; native CIRCLE/ARC export and CNC preset remain pending | +| DXF export/import preserves intended curve closure | done | #1299; R13–2018 closed SPLINE bit set/read; an open curve ending at its start stays open; knots and control points unchanged; circular cubic paths export as native CIRCLE/ARC where stroke semantics permit; DXF Options' Preset menu and `document.exportDxf`'s `dxfPreset: "cnc"` set the CNC / CAM options (#5500) | | macOS save sheet: choose the format from a menu | done | #866; the save panel is VectorCraft's own `NSSavePanel` with its Format menu set before the sheet opens (`apps/vectorcraft/src/mac_save_format.rs`); #1855 had withdrawn the menu that was added after the sheet began, which made Save As, Save a Copy and the first Save show no sheet | | Preferences: every option acts | partial | 2 stored but unused (#394, audited 2026-10-30): Scratch Disks, Legacy vector clipboard; the rest act (Units › East Asian Type since 2026-10-31; Open Documents As Tabs: cleared, new documents open in floating windows). General › Corner Radius drives the Rounded Rectangle tool and follows its ↑/↓ (2026-10-24) | | Preferences › General › Constrain Angle | done | arrow keys and Shift drags along the turned axes; the Rectangle, Rounded Rectangle, Ellipse and grid tools, point and area type and the shape size dialogs draw along them with the bounding box turned; Shift-drawn polygons and stars keep the turned base orientation; the Line Segment tool's Shift steps count from it (#394, <https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/rotate-objects-by-specific-angles.html>); arcs, spirals, flares and Pen paths are not turned | From da97956b451e899be8d7d78561f94f9a6e940782 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 22:03:57 -0800 Subject: [PATCH 24/43] Plug-ins: a wrongly shaped `commands` value gets an error every catalog translates A manifest whose `commands` value is not a list, whose entry is not an object, or whose command `id` or `label` is not a string was refused with serde's own text ("invalid type: ..., expected command [0] as {id, label, params?}"), and no catalog had rows for it or for the reader's other `commands` messages. The reader now gives its own message for each of these cases, and the complete-message catalogs translate them, together with "missing field", "duplicate field" and the manifest prefix with its line and column. --- crates/plugins/src/manifest.rs | 83 +++++++++++++++++++++++++---- crates/ui-egui/src/i18n/ca.tsv | 6 +++ crates/ui-egui/src/i18n/cs.tsv | 6 +++ crates/ui-egui/src/i18n/de.tsv | 6 +++ crates/ui-egui/src/i18n/el.tsv | 6 +++ crates/ui-egui/src/i18n/es.tsv | 6 +++ crates/ui-egui/src/i18n/fi.tsv | 6 +++ crates/ui-egui/src/i18n/fr.tsv | 6 +++ crates/ui-egui/src/i18n/hi.tsv | 5 ++ crates/ui-egui/src/i18n/hu.tsv | 6 +++ crates/ui-egui/src/i18n/it.tsv | 6 +++ crates/ui-egui/src/i18n/ja.tsv | 6 +++ crates/ui-egui/src/i18n/ka.tsv | 6 +++ crates/ui-egui/src/i18n/ko.tsv | 6 +++ crates/ui-egui/src/i18n/pl.tsv | 6 +++ crates/ui-egui/src/i18n/pt-br.tsv | 6 +++ crates/ui-egui/src/i18n/ru.tsv | 6 +++ crates/ui-egui/src/i18n/sv.tsv | 5 ++ crates/ui-egui/src/i18n/tests.rs | 32 +++++++++++ crates/ui-egui/src/i18n/tr.tsv | 6 +++ crates/ui-egui/src/i18n/uk.tsv | 6 +++ crates/ui-egui/src/i18n/vi.tsv | 6 +++ crates/ui-egui/src/i18n/zh-hans.tsv | 6 +++ crates/ui-egui/src/i18n/zh-hant.tsv | 6 +++ 24 files changed, 235 insertions(+), 10 deletions(-) diff --git a/crates/plugins/src/manifest.rs b/crates/plugins/src/manifest.rs index a16bd8a4f9..b6e17e3ad0 100644 --- a/crates/plugins/src/manifest.rs +++ b/crates/plugins/src/manifest.rs @@ -736,10 +736,41 @@ fn input_names<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Ve /// Reads the `commands` list one entry at a time from the manifest itself, so each command's /// `params` keep their declaration order and a repeated parameter is refused. A malformed entry /// is an error that names it (its id when the id comes before the fault, otherwise its place in -/// the list), and a list is refused at its first entry past [`MAX_COMMANDS`]. +/// the list), and a list is refused at its first entry past [`MAX_COMMANDS`]. A `commands` value, +/// an entry, an `id` or a `label` of the wrong type gets a fixed message of this function's own. fn ordered_commands<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Vec<Command>, D::Error> { use serde::de::{Error as _, IgnoredAny, MapAccess, SeqAccess, Visitor}; + /// A visitor's methods for a boolean, a number and a string: each is the error its type's + /// `wrong()` gives. + macro_rules! refuse_scalars { + () => { + fn visit_bool<E: serde::de::Error>(self, _: bool) -> std::result::Result<Self::Value, E> { + Err(E::custom(self.wrong())) + } + fn visit_i64<E: serde::de::Error>(self, _: i64) -> std::result::Result<Self::Value, E> { + Err(E::custom(self.wrong())) + } + fn visit_u64<E: serde::de::Error>(self, _: u64) -> std::result::Result<Self::Value, E> { + Err(E::custom(self.wrong())) + } + fn visit_f64<E: serde::de::Error>(self, _: f64) -> std::result::Result<Self::Value, E> { + Err(E::custom(self.wrong())) + } + fn visit_str<E: serde::de::Error>(self, _: &str) -> std::result::Result<Self::Value, E> { + Err(E::custom(self.wrong())) + } + }; + } + + /// A string field of an entry: anything else is the error "`{key}` must be a string". + fn string_field<'de, A: MapAccess<'de>>(map: &mut A, key: &str) -> std::result::Result<std::result::Result<String, String>, A::Error> { + Ok(match map.next_value::<Value>()? { + Value::String(s) => Ok(s), + _ => Err(format!("`{key}` must be a string")), + }) + } + /// A command's `params`, read in declaration order. struct Params(Vec<(String, Param)>); impl<'de> Deserialize<'de> for Params { @@ -750,6 +781,11 @@ fn ordered_commands<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Resu /// The entry at place `.0` in the list. struct Entry(usize); + impl Entry { + fn wrong(&self) -> String { + format!("command {} must be an object with an `id` and a `label`", command_name(self.0, "")) + } + } impl<'de> serde::de::DeserializeSeed<'de> for Entry { type Value = Command; fn deserialize<D: serde::Deserializer<'de>>(self, d: D) -> std::result::Result<Command, D::Error> { @@ -759,21 +795,29 @@ fn ordered_commands<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Resu impl<'de> Visitor<'de> for Entry { type Value = Command; fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - write!(f, "command [{}] as {{id, label, params?}}", self.0) + f.write_str(&self.wrong()) + } + refuse_scalars!(); + fn visit_unit<E: serde::de::Error>(self) -> std::result::Result<Command, E> { + Err(E::custom(self.wrong())) + } + fn visit_seq<A: SeqAccess<'de>>(self, _: A) -> std::result::Result<Command, A::Error> { + Err(A::Error::custom(self.wrong())) } fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> std::result::Result<Command, A::Error> { let (mut id, mut label, mut params) = (None::<String>, None::<String>, None::<Params>); let named = |id: &Option<String>, e: &dyn std::fmt::Display| { A::Error::custom(format!("command {}: {e}", command_name(self.0, id.as_deref().unwrap_or_default()))) }; - while let Some(key) = map.next_key::<String>().map_err(|e| named(&id, &e))? { + // A key and an ignored value can only fail as JSON syntax, which keeps serde's own error. + while let Some(key) = map.next_key::<String>()? { let repeated = match key.as_str() { "id" if id.is_none() => { - id = Some(map.next_value().map_err(|e| named(&id, &e))?); + id = Some(string_field(&mut map, "id")?.map_err(|e| named(&id, &e))?); false } "label" if label.is_none() => { - label = Some(map.next_value().map_err(|e| named(&id, &e))?); + label = Some(string_field(&mut map, "label")?.map_err(|e| named(&id, &e))?); false } "params" if params.is_none() => { @@ -782,7 +826,7 @@ fn ordered_commands<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Resu } "id" | "label" | "params" => true, _ => { - map.next_value::<IgnoredAny>().map_err(|e| named(&id, &e))?; + map.next_value::<IgnoredAny>()?; false } }; @@ -801,16 +845,22 @@ fn ordered_commands<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Resu } struct List; + impl List { + fn wrong(&self) -> &'static str { + "`commands` must be a list of commands, each with an `id` and a `label`" + } + } impl<'de> Visitor<'de> for List { type Value = Vec<Command>; fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - f.write_str("`commands` as a list of {id, label, params?}") + f.write_str(self.wrong()) } + refuse_scalars!(); fn visit_unit<E: serde::de::Error>(self) -> std::result::Result<Self::Value, E> { Ok(Vec::new()) } fn visit_map<A: MapAccess<'de>>(self, _: A) -> std::result::Result<Self::Value, A::Error> { - Err(A::Error::custom("`commands` must be a list of {id, label, params?}")) + Err(A::Error::custom(self.wrong())) } fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> std::result::Result<Self::Value, A::Error> { let mut out = Vec::new(); @@ -1215,14 +1265,27 @@ mod tests { r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"x","label":"A","params":{"r":{"type":"bool"},"r":{"type":"int"}}}]}"#, "command `x`: parameter `r` is declared twice", ), - (r#"{"id":"a","name":"x","kind":"filter","commands":[1]}"#, "command [0]"), - (r#"{"id":"a","name":"x","kind":"filter","commands":{"id":"x"}}"#, "must be a list"), + (r#"{"id":"a","name":"x","kind":"filter","commands":[1]}"#, "command [0] must be an object with an `id` and a `label`"), + (r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"x","label":"A"},null]}"#, "command [1] must be an object"), + (r#"{"id":"a","name":"x","kind":"filter","commands":[["x"]]}"#, "command [0] must be an object"), + (r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":1,"label":"A"}]}"#, "command [0]: `id` must be a string"), + (r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"x","label":true}]}"#, "command `x`: `label` must be a string"), + (r#"{"id":"a","name":"x","kind":"filter","commands":[{"id":"x","id":"y","label":"A"}]}"#, "command `x`: duplicate field `id`"), + ( + r#"{"id":"a","name":"x","kind":"filter","commands":{"id":"x"}}"#, + "`commands` must be a list of commands, each with an `id` and a `label`", + ), + (r#"{"id":"a","name":"x","kind":"filter","commands":"x"}"#, "`commands` must be a list of commands"), + (r#"{"id":"a","name":"x","kind":"filter","commands":7}"#, "`commands` must be a list of commands"), + (r#"{"id":"a","name":"x","kind":"filter","commands":true}"#, "`commands` must be a list of commands"), (r#"{"id":"a","name":"x","kind":"exporter","extensions":["plt"],"commands":[{"id":"x","label":"A"}]}"#, "only a filter or a live effect"), (r#"{"id":"a","name":"x","kind":"filter","budget":"heavy","commands":[{"id":"x","label":"A"}]}"#, "heavy filter"), (too_many.as_str(), "at most 32 `commands`"), ] { let e = Manifest::parse(bad.as_bytes()).unwrap_err().to_string(); assert!(e.contains(says), "{bad}: {e}"); + // The message is the manifest reader's own, with serde's place in the manifest once. + assert!(!e.contains("invalid type") && !e.contains("expected") && e.matches(" at line ").count() <= 1, "{bad}: {e}"); } let long_id = format!(r#"{{"id":"a","name":"x","kind":"filter","commands":[{{"id":"{}","label":"A"}}]}}"#, "c".repeat(MAX_COMMAND_ID + 1)); assert!(Manifest::parse(long_id.as_bytes()).is_err()); diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 1567e0e8b5..fbc3eeaa4a 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -6168,6 +6168,12 @@ digits Malayalam Malaiàlam @msg command {name}: `label` must be 1-64 printable characters ordre {name}: `label` ha de tenir 1-64 caràcters imprimibles @msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter ordre {name}: fes servir un id d’1-{MAX_COMMAND_ID} caràcters d’a-z 0-9 _ que comenci per una lletra @msg command {name}: {e} ordre {name}: {e} +@msg manifest: {e} manifest: {e} +@msg manifest: {e} at line {line} column {column} manifest: {e} (línia {line}, columna {column}) +@msg `commands` must be a list of commands, each with an `id` and a `label` `commands` ha de ser una llista d’ordres, cadascuna amb `id` i `label` +@msg command {name} must be an object with an `id` and a `label` l’ordre {name} ha de ser un objecte amb `id` i `label` +@msg missing field `{key}` falta el camp `{key}` +@msg duplicate field `{key}` el camp `{key}` està repetit @msg only a filter or a live effect declares `commands` només un filtre o un efecte dinàmic declara `commands` @msg “{name}” › {label}: {e} «{name}» › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed el mòdul no té el SHA-256 que indica `hash`: no s’ha instal·lat res diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index e9df0812f2..7e6266300b 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6121,6 +6121,12 @@ axis Y Axis osa Y @msg command {name}: `label` must be 1-64 printable characters příkaz {name}: `label` musí mít 1-64 tisknutelných znaků @msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter příkaz {name}: použijte id o 1-{MAX_COMMAND_ID} znacích z a-z 0-9 _, které začíná písmenem @msg command {name}: {e} příkaz {name}: {e} +@msg manifest: {e} manifest: {e} +@msg manifest: {e} at line {line} column {column} manifest: {e} (řádek {line}, sloupec {column}) +@msg `commands` must be a list of commands, each with an `id` and a `label` `commands` musí být seznam příkazů, každý s `id` a `label` +@msg command {name} must be an object with an `id` and a `label` příkaz {name} musí být objekt s `id` a `label` +@msg missing field `{key}` chybí pole `{key}` +@msg duplicate field `{key}` pole `{key}` je uvedeno dvakrát @msg only a filter or a live effect declares `commands` `commands` deklaruje jen filtr nebo živý efekt @msg “{name}” › {label}: {e} „{name}“ › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed modul nemá SHA-256 uvedený v `hash`: nic nebylo nainstalováno diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index abfaaa13a7..8acfb4e263 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5704,6 +5704,12 @@ axis Y Axis Y-Achse @msg command {name}: `label` must be 1-64 printable characters Befehl {name}: `label` muss 1-64 druckbare Zeichen haben @msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter Befehl {name}: Verwenden Sie eine ID aus 1-{MAX_COMMAND_ID} Zeichen aus a-z 0-9 _, die mit einem Buchstaben beginnt @msg command {name}: {e} Befehl {name}: {e} +@msg manifest: {e} Manifest: {e} +@msg manifest: {e} at line {line} column {column} Manifest: {e} (Zeile {line}, Spalte {column}) +@msg `commands` must be a list of commands, each with an `id` and a `label` `commands` muss eine Liste von Befehlen sein, jeder mit `id` und `label` +@msg command {name} must be an object with an `id` and a `label` der Befehl {name} muss ein Objekt mit `id` und `label` sein +@msg missing field `{key}` fehlendes Feld `{key}` +@msg duplicate field `{key}` doppeltes Feld `{key}` @msg only a filter or a live effect declares `commands` nur ein Filter oder ein Live-Effekt deklariert `commands` @msg “{name}” › {label}: {e} „{name}“ › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed das Modul hat nicht den SHA-256-Wert aus `hash`: Es wurde nichts installiert diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 4611904ff0..f44b227720 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -6072,6 +6072,12 @@ axis Y Axis Άξονας Y @msg command {name}: `label` must be 1-64 printable characters εντολή {name}: το `label` πρέπει να έχει 1-64 εκτυπώσιμους χαρακτήρες @msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter εντολή {name}: χρησιμοποιήστε αναγνωριστικό 1-{MAX_COMMAND_ID} χαρακτήρων από a-z 0-9 _ που αρχίζει με γράμμα @msg command {name}: {e} εντολή {name}: {e} +@msg manifest: {e} αρχείο δηλώσεων: {e} +@msg manifest: {e} at line {line} column {column} αρχείο δηλώσεων: {e} (γραμμή {line}, στήλη {column}) +@msg `commands` must be a list of commands, each with an `id` and a `label` το `commands` πρέπει να είναι λίστα εντολών, καθεμία με `id` και `label` +@msg command {name} must be an object with an `id` and a `label` η εντολή {name} πρέπει να είναι αντικείμενο με `id` και `label` +@msg missing field `{key}` λείπει το πεδίο `{key}` +@msg duplicate field `{key}` το πεδίο `{key}` εμφανίζεται δύο φορές @msg only a filter or a live effect declares `commands` μόνο ένα φίλτρο ή ένα δυναμικό εφέ δηλώνει `commands` @msg “{name}” › {label}: {e} «{name}» › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed η λειτουργική μονάδα δεν έχει το SHA-256 που δίνει το `hash`: δεν εγκαταστάθηκε τίποτα diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index b91d89b8c0..516d36cad4 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -6063,6 +6063,12 @@ axis Y Axis Eje Y @msg command {name}: `label` must be 1-64 printable characters comando {name}: `label` debe tener 1-64 caracteres imprimibles @msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter comando {name}: usa un id de 1-{MAX_COMMAND_ID} caracteres de a-z 0-9 _ que empiece por una letra @msg command {name}: {e} comando {name}: {e} +@msg manifest: {e} manifiesto: {e} +@msg manifest: {e} at line {line} column {column} manifiesto: {e} (línea {line}, columna {column}) +@msg `commands` must be a list of commands, each with an `id` and a `label` `commands` debe ser una lista de comandos, cada uno con `id` y `label` +@msg command {name} must be an object with an `id` and a `label` el comando {name} debe ser un objeto con `id` y `label` +@msg missing field `{key}` falta el campo `{key}` +@msg duplicate field `{key}` el campo `{key}` está repetido @msg only a filter or a live effect declares `commands` solo un filtro o un efecto interactivo declara `commands` @msg “{name}” › {label}: {e} “{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed el módulo no tiene el SHA-256 que indica `hash`: no se instaló nada diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index e296459895..2ddd2458cc 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6151,6 +6151,12 @@ axis Y Axis Y-akseli @msg command {name}: `label` must be 1-64 printable characters komento {name}: `label`-kentässä on oltava 1-64 tulostettavaa merkkiä @msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter komento {name}: käytä 1-{MAX_COMMAND_ID} merkin tunnusta merkeistä a-z 0-9 _, joka alkaa kirjaimella @msg command {name}: {e} komento {name}: {e} +@msg manifest: {e} manifesti: {e} +@msg manifest: {e} at line {line} column {column} manifesti: {e} (rivi {line}, sarake {column}) +@msg `commands` must be a list of commands, each with an `id` and a `label` `commands`-kohdan on oltava luettelo komennoista, joilla kullakin on `id` ja `label` +@msg command {name} must be an object with an `id` and a `label` komennon {name} on oltava objekti, jossa on `id` ja `label` +@msg missing field `{key}` kenttä `{key}` puuttuu +@msg duplicate field `{key}` kenttä `{key}` on kahdesti @msg only a filter or a live effect declares `commands` vain suodin tai live-tehoste määrittelee `commands`-kohdan @msg “{name}” › {label}: {e} ”{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed moduulilla ei ole `hash`-kentän SHA-256-arvoa: mitään ei asennettu diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index ff206b9317..48e3e5f884 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6116,6 +6116,12 @@ axis Y Axis Axe Y @msg command {name}: `label` must be 1-64 printable characters commande {name} : `label` doit compter 1-64 caractères imprimables @msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter commande {name} : utilisez un id de 1-{MAX_COMMAND_ID} caractères parmi a-z 0-9 _ commençant par une lettre @msg command {name}: {e} commande {name} : {e} +@msg manifest: {e} manifeste : {e} +@msg manifest: {e} at line {line} column {column} manifeste : {e} (ligne {line}, colonne {column}) +@msg `commands` must be a list of commands, each with an `id` and a `label` `commands` doit être une liste de commandes, chacune avec `id` et `label` +@msg command {name} must be an object with an `id` and a `label` la commande {name} doit être un objet avec `id` et `label` +@msg missing field `{key}` champ `{key}` manquant +@msg duplicate field `{key}` champ `{key}` en double @msg only a filter or a live effect declares `commands` seul un filtre ou un effet dynamique déclare `commands` @msg “{name}” › {label}: {e} « {name} » › {label} : {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed le module n’a pas le SHA-256 indiqué par `hash` : rien n’a été installé diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 6698f10a4b..5a706ff7b6 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -5419,6 +5419,11 @@ antiAlias Sharp शार्प @msg command {name}: `label` must be 1-64 printable characters कमांड {name}: `label` में 1-64 प्रिंट करने योग्य वर्ण होने चाहिए @msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter कमांड {name}: a-z 0-9 _ के 1-{MAX_COMMAND_ID} वर्णों वाली ऐसी id इस्तेमाल करें जो अक्षर से शुरू हो @msg command {name}: {e} कमांड {name}: {e} +@msg manifest: {e} at line {line} column {column} प्रकटन: {e} (लाइन {line}, कॉलम {column}) +@msg `commands` must be a list of commands, each with an `id` and a `label` `commands` कमांड की एक सूची होनी चाहिए, हर एक में `id` और `label` हो +@msg command {name} must be an object with an `id` and a `label` कमांड {name} एक ऑब्जेक्ट होना चाहिए जिसमें `id` और `label` हों +@msg missing field `{key}` फ़ील्ड `{key}` नहीं है +@msg duplicate field `{key}` फ़ील्ड `{key}` दो बार है @msg only a filter or a live effect declares `commands` केवल फ़िल्टर या लाइव इफ़ेक्ट ही `commands` घोषित करता है @msg “{name}” › {label}: {e} “{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed मॉड्यूल का SHA-256 वह नहीं है जो `hash` बताता है: कुछ भी इंस्टॉल नहीं हुआ diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index a8d02ca4c4..889dda0557 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6104,6 +6104,12 @@ axis Y Axis Y tengely @msg command {name}: `label` must be 1-64 printable characters {name} parancs: a `label` 1-64 nyomtatható karakter legyen @msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter {name} parancs: használj 1-{MAX_COMMAND_ID} karakteres, a-z 0-9 _ karakterekből álló, betűvel kezdődő azonosítót @msg command {name}: {e} {name} parancs: {e} +@msg manifest: {e} leíró: {e} +@msg manifest: {e} at line {line} column {column} leíró: {e} ({line}. sor, {column}. oszlop) +@msg `commands` must be a list of commands, each with an `id` and a `label` `commands`: parancsok listája kell, mindegyikben `id` és `label` értékkel +@msg command {name} must be an object with an `id` and a `label` a(z) {name} parancs csak `id` és `label` mezőt tartalmazó objektum lehet +@msg missing field `{key}` hiányzó mező: `{key}` +@msg duplicate field `{key}` ismétlődő mező: `{key}` @msg only a filter or a live effect declares `commands` `commands` értéket csak szűrő vagy élő effektus deklarálhat @msg “{name}” › {label}: {e} „{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed a modul SHA-256 értéke nem az, amit a `hash` megad: semmi sem lett telepítve diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 82827964fe..dc980a96b1 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5738,6 +5738,12 @@ axis Y Axis Asse Y @msg command {name}: `label` must be 1-64 printable characters comando {name}: `label` deve avere 1-64 caratteri stampabili @msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter comando {name}: usa un id di 1-{MAX_COMMAND_ID} caratteri tra a-z 0-9 _ che inizi con una lettera @msg command {name}: {e} comando {name}: {e} +@msg manifest: {e} manifest: {e} +@msg manifest: {e} at line {line} column {column} manifest: {e} (riga {line}, colonna {column}) +@msg `commands` must be a list of commands, each with an `id` and a `label` `commands` deve essere un elenco di comandi, ciascuno con `id` e `label` +@msg command {name} must be an object with an `id` and a `label` il comando {name} deve essere un oggetto con `id` e `label` +@msg missing field `{key}` campo `{key}` mancante +@msg duplicate field `{key}` campo `{key}` duplicato @msg only a filter or a live effect declares `commands` solo un filtro o un effetto dinamico dichiara `commands` @msg “{name}” › {label}: {e} “{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed il modulo non ha lo SHA-256 indicato da `hash`: non è stato installato nulla diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 42a4aa3cf2..2c27451848 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5803,6 +5803,12 @@ axis Y Axis Y 軸 @msg command {name}: `label` must be 1-64 printable characters コマンド {name}: `label` は印刷可能な 1-64 文字にしてください @msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter コマンド {name}: a-z 0-9 _ からなり英字で始まる 1-{MAX_COMMAND_ID} 文字の ID を使用してください @msg command {name}: {e} コマンド {name}: {e} +@msg manifest: {e} マニフェスト: {e} +@msg manifest: {e} at line {line} column {column} マニフェスト: {e} ({line} 行目、{column} 列目) +@msg `commands` must be a list of commands, each with an `id` and a `label` `commands` は、それぞれ `id` と `label` を持つコマンドのリストにしてください +@msg command {name} must be an object with an `id` and a `label` コマンド {name} は `id` と `label` を持つオブジェクトにしてください +@msg missing field `{key}` フィールド `{key}` がありません +@msg duplicate field `{key}` フィールド `{key}` が重複しています @msg only a filter or a live effect declares `commands` `commands` を宣言できるのはフィルターかライブ効果だけです @msg “{name}” › {label}: {e} 「{name}」 › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed モジュールの SHA-256 が `hash` の値と一致しません。何もインストールされませんでした diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 30b0abd6bf..928057376c 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6072,6 +6072,12 @@ axis Y Axis Y ღერძი @msg command {name}: `label` must be 1-64 printable characters ბრძანება {name}: `label` უნდა შედგებოდეს 1-64 დასაბეჭდი სიმბოლოსგან @msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter ბრძანება {name}: გამოიყენეთ 1-{MAX_COMMAND_ID} სიმბოლოიანი id a-z 0-9 _ სიმბოლოებიდან, რომელიც ასოთი იწყება @msg command {name}: {e} ბრძანება {name}: {e} +@msg manifest: {e} მანიფესტი: {e} +@msg manifest: {e} at line {line} column {column} მანიფესტი: {e} (სტრიქონი {line}, სვეტი {column}) +@msg `commands` must be a list of commands, each with an `id` and a `label` `commands` უნდა იყოს ბრძანებების სია, თითოეული `id`-ით და `label`-ით +@msg command {name} must be an object with an `id` and a `label` ბრძანება {name} უნდა იყოს ობიექტი `id`-ით და `label`-ით +@msg missing field `{key}` აკლია ველი `{key}` +@msg duplicate field `{key}` ველი `{key}` ორჯერაა @msg only a filter or a live effect declares `commands` `commands`-ს მხოლოდ ფილტრი ან ცოცხალი ეფექტი აცხადებს @msg “{name}” › {label}: {e} „{name}“ › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed მოდულს არ აქვს `hash`-ში მითითებული SHA-256: არაფერი დაყენებულა diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 3bc8360b49..1afa4ae7be 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5699,6 +5699,12 @@ axis Y Axis Y축 @msg command {name}: `label` must be 1-64 printable characters 명령 {name}: `label`은 인쇄 가능한 문자 1-64자여야 합니다 @msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter 명령 {name}: a-z 0-9 _로 이루어지고 문자로 시작하는 1-{MAX_COMMAND_ID}자 ID를 사용하세요 @msg command {name}: {e} 명령 {name}: {e} +@msg manifest: {e} 매니페스트: {e} +@msg manifest: {e} at line {line} column {column} 매니페스트: {e} ({line}행 {column}열) +@msg `commands` must be a list of commands, each with an `id` and a `label` `commands`는 각각 `id`와 `label`이 있는 명령 목록이어야 합니다 +@msg command {name} must be an object with an `id` and a `label` 명령 {name}은(는) `id`와 `label`이 있는 객체여야 합니다 +@msg missing field `{key}` `{key}` 필드가 없습니다 +@msg duplicate field `{key}` `{key}` 필드가 중복되었습니다 @msg only a filter or a live effect declares `commands` 필터나 라이브 효과만 `commands`를 선언합니다 @msg “{name}” › {label}: {e} “{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed 모듈의 SHA-256이 `hash`에 지정된 값과 다릅니다. 아무것도 설치되지 않았습니다 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 3647b56f54..6e0e81e119 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5975,6 +5975,12 @@ axis Y Axis Oś Y @msg command {name}: `label` must be 1-64 printable characters polecenie {name}: `label` musi mieć 1-64 drukowalnych znaków @msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter polecenie {name}: użyj id z 1-{MAX_COMMAND_ID} znaków spośród a-z 0-9 _, zaczynającego się od litery @msg command {name}: {e} polecenie {name}: {e} +@msg manifest: {e} manifest: {e} +@msg manifest: {e} at line {line} column {column} manifest: {e} (wiersz {line}, kolumna {column}) +@msg `commands` must be a list of commands, each with an `id` and a `label` `commands` musi być listą poleceń, każde z `id` i `label` +@msg command {name} must be an object with an `id` and a `label` polecenie {name} musi być obiektem z `id` i `label` +@msg missing field `{key}` brak pola `{key}` +@msg duplicate field `{key}` zduplikowane pole `{key}` @msg only a filter or a live effect declares `commands` `commands` deklaruje tylko filtr lub efekt na żywo @msg “{name}” › {label}: {e} „{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed moduł nie ma skrótu SHA-256 podanego w `hash`: nic nie zainstalowano diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 530a62719a..17a9a9ad16 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -6036,6 +6036,12 @@ axis Y Axis Eixo Y @msg command {name}: `label` must be 1-64 printable characters comando {name}: `label` deve ter 1-64 caracteres imprimíveis @msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter comando {name}: use um id de 1-{MAX_COMMAND_ID} caracteres de a-z 0-9 _ que comece com uma letra @msg command {name}: {e} comando {name}: {e} +@msg manifest: {e} manifesto: {e} +@msg manifest: {e} at line {line} column {column} manifesto: {e} (linha {line}, coluna {column}) +@msg `commands` must be a list of commands, each with an `id` and a `label` `commands` deve ser uma lista de comandos, cada um com `id` e `label` +@msg command {name} must be an object with an `id` and a `label` o comando {name} deve ser um objeto com `id` e `label` +@msg missing field `{key}` falta o campo `{key}` +@msg duplicate field `{key}` campo `{key}` duplicado @msg only a filter or a live effect declares `commands` só um filtro ou um efeito dinâmico declara `commands` @msg “{name}” › {label}: {e} “{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed o módulo não tem o SHA-256 indicado em `hash`: nada foi instalado diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 341dc196f4..e54259e447 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5732,6 +5732,12 @@ axis Y Axis Ось Y @msg command {name}: `label` must be 1-64 printable characters команда {name}: `label` должен содержать 1-64 печатаемых символа @msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter команда {name}: используйте id из 1-{MAX_COMMAND_ID} символов a-z 0-9 _, начинающийся с буквы @msg command {name}: {e} команда {name}: {e} +@msg manifest: {e} манифест: {e} +@msg manifest: {e} at line {line} column {column} манифест: {e} (строка {line}, столбец {column}) +@msg `commands` must be a list of commands, each with an `id` and a `label` `commands` должно быть списком команд, у каждой из которых есть `id` и `label` +@msg command {name} must be an object with an `id` and a `label` команда {name} должна быть объектом с `id` и `label` +@msg missing field `{key}` нет поля `{key}` +@msg duplicate field `{key}` поле `{key}` указано дважды @msg only a filter or a live effect declares `commands` `commands` объявляет только фильтр или динамический эффект @msg “{name}” › {label}: {e} «{name}» › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed у модуля не тот SHA-256, что указан в `hash`: ничего не установлено diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index ed59d98113..df5afb54a8 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -6140,6 +6140,11 @@ axis Y Axis Y-axel @msg command {name}: `label` must be 1-64 printable characters kommando {name}: `label` måste ha 1-64 utskrivbara tecken @msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter kommando {name}: använd ett id med 1-{MAX_COMMAND_ID} tecken av a-z 0-9 _ som börjar med en bokstav @msg command {name}: {e} kommando {name}: {e} +@msg manifest: {e} at line {line} column {column} manifest: {e} (rad {line}, kolumn {column}) +@msg `commands` must be a list of commands, each with an `id` and a `label` `commands` måste vara en lista med kommandon, vart och ett med `id` och `label` +@msg command {name} must be an object with an `id` and a `label` kommandot {name} måste vara ett objekt med `id` och `label` +@msg missing field `{key}` fältet `{key}` saknas +@msg duplicate field `{key}` fältet `{key}` förekommer två gånger @msg only a filter or a live effect declares `commands` bara ett filter eller en live-effekt deklarerar `commands` @msg “{name}” › {label}: {e} ”{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed modulen har inte den SHA-256 som `hash` anger: ingenting installerades diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index c63e000795..ea815092f7 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -2006,6 +2006,38 @@ fn plugin_limit_errors_are_translated() { } } +/// A manifest whose `commands` value has the wrong shape is refused with an error that every +/// language of [`COMPLETE_MESSAGES`] translates whole, the command's name and the place in the +/// manifest included. +#[test] +fn wrongly_shaped_plugin_commands_errors_are_translated() { + let many: Vec<String> = (0..=32).map(|i| format!(r#"{{"id":"c{i}","label":"C"}}"#)).collect(); + let commands = [ + r#"{"id":"x"}"#.to_string(), + r#""x""#.to_string(), + "[1]".to_string(), + r#"[{"id":1,"label":"A"}]"#.to_string(), + r#"[{"id":"x","label":true}]"#.to_string(), + r#"[{"label":"B"}]"#.to_string(), + r#"[{"id":"x"}]"#.to_string(), + r#"[{"id":"x","id":"y","label":"A"}]"#.to_string(), + format!("[{}]", many.join(",")), + ]; + let english = ["must", "list", "missing", "field", "duplicate", "object", "line", "column", "each", "with", "invalid", "expected"]; + for value in &commands { + let manifest = format!(r#"{{"id":"a","name":"x","kind":"filter","commands":{value}}}"#); + let e = vectorcraft_plugins::manifest::Manifest::parse(manifest.as_bytes()).unwrap_err().to_string(); + let numbers: Vec<&str> = e.split(|c: char| !c.is_ascii_digit()).filter(|n| !n.is_empty()).collect(); + for code in COMPLETE_MESSAGES { + let shown = message(Lang::from_code(code).expect("registered"), &e); + // Backticked names stay in English; the words around them are translated. + let words: String = shown.split('`').step_by(2).collect(); + assert!(words.split(|c: char| !c.is_alphabetic()).all(|w| !english.contains(&w)), "{code}: {e} -> {shown}"); + assert!(numbers.iter().all(|n| shown.contains(n)), "{code}: {e} -> {shown}"); + } + } +} + /// The warnings of an Image Trace with keepColors, which the status bar shows and the scan above /// doesn't read, are translated in each language of [`COMPLETE_MESSAGES`], with the colors kept. #[test] diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index dc89f33802..7c0ca14583 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6077,6 +6077,12 @@ axis Y Axis Y ekseni @msg command {name}: `label` must be 1-64 printable characters {name} komutu: `label` 1-64 yazdırılabilir karakter olmalıdır @msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter {name} komutu: a-z 0-9 _ karakterlerinden oluşan ve harfle başlayan 1-{MAX_COMMAND_ID} karakterlik bir kimlik kullanın @msg command {name}: {e} {name} komutu: {e} +@msg manifest: {e} bildirim: {e} +@msg manifest: {e} at line {line} column {column} bildirim: {e} (satır {line}, sütun {column}) +@msg `commands` must be a list of commands, each with an `id` and a `label` `commands`, her biri `id` ve `label` içeren bir komut listesi olmalıdır +@msg command {name} must be an object with an `id` and a `label` {name} komutu `id` ve `label` içeren bir nesne olmalıdır +@msg missing field `{key}` `{key}` alanı eksik +@msg duplicate field `{key}` `{key}` alanı yinelenmiş @msg only a filter or a live effect declares `commands` `commands` yalnızca bir filtre veya canlı efekt tarafından bildirilir @msg “{name}” › {label}: {e} “{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed modül `hash` içinde verilen SHA-256 değerine sahip değil: hiçbir şey yüklenmedi diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 8d67846ecd..328ba0c7d1 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5718,6 +5718,12 @@ axis Y Axis Вісь Y @msg command {name}: `label` must be 1-64 printable characters команда {name}: `label` має містити 1-64 друковані символи @msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter команда {name}: використайте id з 1-{MAX_COMMAND_ID} символів a-z 0-9 _, що починається з літери @msg command {name}: {e} команда {name}: {e} +@msg manifest: {e} маніфест: {e} +@msg manifest: {e} at line {line} column {column} маніфест: {e} (рядок {line}, стовпець {column}) +@msg `commands` must be a list of commands, each with an `id` and a `label` `commands` має бути списком команд, кожна з `id` і `label` +@msg command {name} must be an object with an `id` and a `label` команда {name} має бути об'єктом з `id` і `label` +@msg missing field `{key}` немає поля `{key}` +@msg duplicate field `{key}` поле `{key}` вказано двічі @msg only a filter or a live effect declares `commands` `commands` оголошує лише фільтр або динамічний ефект @msg “{name}” › {label}: {e} «{name}» › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed модуль має не той SHA-256, що вказаний у `hash`: нічого не встановлено diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 8f1ea434fd..1cc66addad 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5719,6 +5719,12 @@ axis Y Axis Trục Y @msg command {name}: `label` must be 1-64 printable characters lệnh {name}: `label` phải có 1-64 ký tự in được @msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter lệnh {name}: hãy dùng id gồm 1-{MAX_COMMAND_ID} ký tự từ a-z 0-9 _ và bắt đầu bằng một chữ cái @msg command {name}: {e} lệnh {name}: {e} +@msg manifest: {e} manifest: {e} +@msg manifest: {e} at line {line} column {column} manifest: {e} (dòng {line}, cột {column}) +@msg `commands` must be a list of commands, each with an `id` and a `label` `commands` phải là một danh sách lệnh, mỗi lệnh có `id` và `label` +@msg command {name} must be an object with an `id` and a `label` lệnh {name} phải là một đối tượng có `id` và `label` +@msg missing field `{key}` thiếu trường `{key}` +@msg duplicate field `{key}` trường `{key}` bị lặp @msg only a filter or a live effect declares `commands` chỉ bộ lọc hoặc hiệu ứng trực tiếp mới khai báo `commands` @msg “{name}” › {label}: {e} “{name}” › {label}: {e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed mô-đun không có SHA-256 mà `hash` cho biết: không có gì được cài đặt diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 05c6539bf7..66dfb5565d 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -5959,6 +5959,12 @@ axis Y Axis Y 轴 @msg command {name}: `label` must be 1-64 printable characters 命令 {name}:`label` 必须是 1-64 个可打印字符 @msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter 命令 {name}:请使用由 a-z 0-9 _ 组成、以字母开头的 1-{MAX_COMMAND_ID} 个字符的 ID @msg command {name}: {e} 命令 {name}:{e} +@msg manifest: {e} 清单:{e} +@msg manifest: {e} at line {line} column {column} 清单:{e}(第 {line} 行,第 {column} 列) +@msg `commands` must be a list of commands, each with an `id` and a `label` `commands` 必须是命令列表,每个命令都有 `id` 和 `label` +@msg command {name} must be an object with an `id` and a `label` 命令 {name} 必须是包含 `id` 和 `label` 的对象 +@msg missing field `{key}` 缺少字段 `{key}` +@msg duplicate field `{key}` 字段 `{key}` 重复 @msg only a filter or a live effect declares `commands` 只有滤镜或实时效果才能声明 `commands` @msg “{name}” › {label}: {e} “{name}” › {label}:{e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed 模块的 SHA-256 与 `hash` 给出的不符:未安装任何内容 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index cb8a61362e..de9bd3d0ad 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5756,6 +5756,12 @@ axis Y Axis Y 軸 @msg command {name}: `label` must be 1-64 printable characters 指令 {name}:`label` 必須是 1-64 個可列印字元 @msg command {name}: use an id of 1-{MAX_COMMAND_ID} characters from a-z 0-9 _ that starts with a letter 指令 {name}:請使用由 a-z 0-9 _ 組成、以字母開頭的 1-{MAX_COMMAND_ID} 個字元的 ID @msg command {name}: {e} 指令 {name}:{e} +@msg manifest: {e} 資訊清單:{e} +@msg manifest: {e} at line {line} column {column} 資訊清單:{e}(第 {line} 行,第 {column} 欄) +@msg `commands` must be a list of commands, each with an `id` and a `label` `commands` 必須是指令清單,每個指令都有 `id` 和 `label` +@msg command {name} must be an object with an `id` and a `label` 指令 {name} 必須是包含 `id` 和 `label` 的物件 +@msg missing field `{key}` 找不到欄位 `{key}` +@msg duplicate field `{key}` 欄位 `{key}` 重複 @msg only a filter or a live effect declares `commands` 只有濾鏡或即時效果才能宣告 `commands` @msg “{name}” › {label}: {e} 「{name}」 › {label}:{e} @msg the module doesn't have the SHA-256 `hash` gives: nothing was installed 模組的 SHA-256 與 `hash` 提供的不符:未安裝任何內容 From a755082607c4507ff39c209749bf427e84c3ab97 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 22:06:52 -0800 Subject: [PATCH 25/43] MCP: an error from Export for Screens into a preset folder outside the read roots names that folder by its name document.exportForScreens without a folder writes into the folder its exportPreset keeps. When that folder is outside the client's read roots and the export fails, for example because the folder is also outside the write roots, the error now names the folder by its name, as the reply of a successful export leaves it out. A granted root in the same message stays whole, and a folder the client gives stays whole in the error. --- crates/engine/src/cmd/fileio/screens.rs | 62 ++++++++++++++++++- crates/engine/src/cmd/fileio/tests_screens.rs | 35 +++++++++++ docs/mcp.md | 3 +- docs/target-app-parity.md | 2 +- 4 files changed, 98 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/fileio/screens.rs b/crates/engine/src/cmd/fileio/screens.rs index 3974a574b1..bcc4bcb178 100644 --- a/crates/engine/src/cmd/fileio/screens.rs +++ b/crates/engine/src/cmd/fileio/screens.rs @@ -647,7 +647,11 @@ pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result<Value> { // A folder the client gave, as against the one its `exportPreset` keeps. let given = p.get("folder").is_some(); let p = with_preset(s, p)?; - let mut r = export(s, &p)?; + let hidden = if given { None } else { str_param(&p, "folder").filter(|f| !f.is_empty() && !super::access::shown(f)) }; + let mut r = export(s, &p).map_err(|e| match hidden { + Some(folder) => leave_out_folder_from_error(e, folder), + None => e, + })?; remember(s, &p); if !given && let Some(folder) = str_param(&p, "folder").filter(|f| !f.is_empty()) { leave_out_unreadable_folder(&mut r, folder); @@ -655,6 +659,41 @@ pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result<Value> { Ok(r) } +/// An error of an export into `folder`, a folder outside the read roots that the client did not +/// give: `folder` as the path the error is about names it by its own name +/// ([`super::access::shown_path`]), and the rest of the message stays. +fn leave_out_folder_from_error(e: EngineError, folder: &str) -> EngineError { + let name = super::access::shown_path(folder); + match e { + EngineError::Other(msg) => EngineError::Other(leave_out_folder_from_message(&msg, folder, &name)), + EngineError::BadParams { cmd, msg } => EngineError::BadParams { cmd, msg: leave_out_folder_from_message(&msg, folder, &name) }, + e => e, + } +} + +/// `msg` with `folder` given as `name` where it begins a path the message is about: at the start +/// of `msg` (`<path>: <reason>`), after a `: ` (`... outside the write root <root>: <path>`) or +/// after `cannot resolve `, followed by the end, a separator, a `:` or a space. A root the message +/// names stays whole, even one inside `folder`. +fn leave_out_folder_from_message(msg: &str, folder: &str, name: &str) -> String { + const BEFORE: [&str; 2] = [": ", "cannot resolve "]; + let mut out = String::with_capacity(msg.len()); + let mut rest = msg; + loop { + if let Some(after) = rest.strip_prefix(folder) + && after.chars().next().is_none_or(|c| matches!(c, '/' | '\\' | ':' | ' ')) + { + out.push_str(name); + rest = after; + } + let Some((i, len)) = BEFORE.iter().filter_map(|b| rest.find(b).map(|i| (i, b.len()))).min() else { break }; + out.push_str(&rest[..i + len]); + rest = &rest[i + len..]; + } + out.push_str(rest); + out +} + /// The reply `r` of an export into `folder`, a folder the client did not give, as the request being /// served may see it: when `folder` is outside the read roots ([`super::access::shown`]), the ZIP's /// `path` is left out and `files` names each file written relative to `folder` (`2x/Logo@2x.png`), @@ -1436,7 +1475,26 @@ pub(super) fn export_settings(s: &mut Session, _: &Value) -> Result<Value> { #[cfg(test)] mod tests { - use super::{Output, ScreenSize, safe_file_name}; + use super::{Output, ScreenSize, leave_out_folder_from_message, safe_file_name}; + + /// A hidden export folder is named by its name where a message is about it, and a root that + /// starts with the same text, or a longer path, stays whole. + #[test] + fn a_hidden_folder_is_named_by_its_name_in_a_message() { + let f = "/work/out"; + let cases = [ + ( + "automation path rejected: outside the write root /work/out/in: /work/out", + "automation path rejected: outside the write root /work/out/in: out", + ), + ("automation path rejected: cannot resolve /work/out: denied", "automation path rejected: cannot resolve out: denied"), + ("/work/out/2x/a.png: disk full", "out/2x/a.png: disk full"), + ("automation path rejected: outside the write root /a: /work/outer", "automation path rejected: outside the write root /a: /work/outer"), + ]; + for (msg, want) in cases { + assert_eq!(leave_out_folder_from_message(msg, f, "out"), want, "{msg}"); + } + } /// Files given one name are numbered in turn, each numbering going on from the last one (so /// thousands of them don't each try every number used before), and a file asking again for diff --git a/crates/engine/src/cmd/fileio/tests_screens.rs b/crates/engine/src/cmd/fileio/tests_screens.rs index b3e0642245..86ee164998 100644 --- a/crates/engine/src/cmd/fileio/tests_screens.rs +++ b/crates/engine/src/cmd/fileio/tests_screens.rs @@ -700,6 +700,41 @@ fn a_presets_folder_outside_the_read_root_is_left_out_of_the_reply() { assert!(r["path"].as_str().is_some_and(|p| p.starts_with(&format!("{folder}/")) && p.ends_with(".zip")), "{r}"); } +/// An export into the folder an export preset keeps, refused because that folder is outside the +/// client's read and write roots: the error names the folder by its name only. A folder the client +/// gives stays whole in the error. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn a_refused_export_into_a_presets_folder_outside_the_read_root_names_it_by_its_name() { + use crate::file_access::{AutomationRoots, confine}; + + let mut s = session(1); + let tmp = Folder::new("preset-refused"); + let (inside, out) = (tmp.0.join("in"), tmp.0.join("kept-out")); + for f in [&inside, &out] { + std::fs::create_dir_all(f).unwrap(); + } + let folder = out.to_string_lossy().replace('\\', "/"); + let settings = json!({"formats": [{"format": "png"}], "folder": folder}); + s.execute("document.exportPresets.save", &json!({"name": "Kept", "settings": settings})).unwrap(); + let confined = AutomationRoots::new(&[inside.as_path()], &[inside.as_path()]).unwrap(); + let mut run = |p: Value| confine(confined.as_ref(), || s.execute("document.exportForScreens", &p)).unwrap_err().to_string(); + + let e = run(json!({"exportPreset": "Kept"})); + assert!(e.contains("outside the write root") && e.contains("kept-out") && !e.contains(&folder), "{e}"); + let e = run(json!({"exportPreset": "Kept", "folder": folder})); + assert!(e.contains(&folder), "{e}"); + assert_eq!(std::fs::read_dir(&out).unwrap().count(), 0, "nothing written"); + // A preset folder that holds the write root: the root stays whole in the error. + let parent = tmp.0.to_string_lossy().replace('\\', "/"); + let settings = json!({"formats": [{"format": "png"}], "folder": parent}); + s.execute("document.exportPresets.save", &json!({"name": "Parent", "settings": settings})).unwrap(); + let e = confine(confined.as_ref(), || s.execute("document.exportForScreens", &json!({"exportPreset": "Parent"}))).unwrap_err().to_string(); + let root = inside.display().to_string(); + let name = tmp.0.file_name().unwrap().to_string_lossy().into_owned(); + assert!(e.contains(&format!("root {root}: {name}")), "{e}"); +} + /// The export settings the document keeps and the saved presets, read by a client whose read roots /// don't hold their folder: `document.exportSettings`, `document.exportPresets.list` and /// `document.exportPresets.export` (as data and as a file) leave out that folder and keep the rest. diff --git a/docs/mcp.md b/docs/mcp.md index 5f02c87f2e..52fd2247c9 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -128,7 +128,8 @@ the suite) keep the agent to the folders you give it: a `path` write the document's own file: the reply then has no `path`, and `files` and `linked` name the files written beside it by their file names. A save without a path known (`file.saveAs` and `file.saveCopy` without one, a document never saved) leaves out the suggested `folder`. `document.exportForScreens` into the `folder` - of its `exportPreset` leaves out the ZIP's `path` and lists `files` relative to that folder. + of its `exportPreset` leaves out the ZIP's `path` and lists `files` relative to that folder, and an error + for that export names the folder by its name alone. `document.exportSettings`, `document.exportPresets.list` and `document.exportPresets.export` (as data and as a file) leave out a kept `folder` outside the read roots. `file.package` names the document's file and the linked files outside the read roots by their file names in `warnings`, in diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 56b960dedb..680469b7fe 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -749,7 +749,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Scripts: `#strict on` (and `//@strict on`) runs the script with the checks the [`$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/) help page gives for `$.strict`: an assignment to a read-only property throws a `TypeError`, and so does a new property on an object that takes none. VectorCraft's own rules: a getter-only property counts as read-only, the objects that take no new properties are the non-extensible ones (`Object.preventExtensions`, `seal`, `freeze`, and the frozen `vc` and `$` objects), the last `#strict` line of the script and its includes sets the whole run, a file `$.evalFile` runs keeps its own line's checks for its code, and the rest of ES5 strict mode stays with `'use strict'`. Without the line, runs are unchanged. `crates/engine/src/tests/script_include.rs` (`strict_on_*`, `strict_mode_in_eval_file_is_scoped`), `crates/js/src/interp_tests.rs` (`strict_writes_are_separate_from_es5_strict`). - Scripts folder: File › Scripts lists the `.js` and `.jsx` files of the `Scripts` folder next to the preferences, each named after its file without the extension, with each subfolder as a submenu, above Check Script… and Other Script… (https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/install-and-run-scripts.html). VectorCraft's own rules: the folder is read again when the window becomes active and when the File menu opens (in the macOS menu bar, when any of its menus opens, and the new listing shows the next time the menu opens), links are not followed, and one read stops at 2,048 entries, 512 scripts, 128 subfolders, 4 levels or 250 ms. `script.listInstalled` returns the listing. `crates/engine/src/tests/script_folder.rs`, `crates/ui-egui/src/tests/scripts_menu.rs`. - Open Recent Files under `--automation-read-root`: the control channel's `ui.menu.list` and `file.recentFiles` leave out the recent files outside the read roots, the other rows keep their ids, and `file.openRecentN` for one outside them answers `no such recent file`, as an empty slot does. `crates/ui-egui/src/tests/automation.rs`. - - Saves, Export for Screens and Package under `--automation-read-root`: a save without a path leaves the document's own file out of its reply, and a save without a path known leaves out the suggested folder, when they are outside the read roots; Export for Screens into an export preset's folder outside them lists its files relative to that folder, and the export settings and saved presets leave out a folder outside them when read or exported; Package's report and warnings, and Document Info's linked images, name a file outside them by its file name. A path the client gives stays in the reply. `crates/engine/src/tests/save.rs`, `crates/engine/src/cmd/fileio/tests_screens.rs`, `crates/engine/src/tests/package.rs`, `crates/ui-egui/src/tests/automation.rs`. + - Saves, Export for Screens and Package under `--automation-read-root`: a save without a path leaves the document's own file out of its reply, and a save without a path known leaves out the suggested folder, when they are outside the read roots; Export for Screens into an export preset's folder outside them lists its files relative to that folder and names that folder by its name in an error, and the export settings and saved presets leave out a folder outside them when read or exported; Package's report and warnings, and Document Info's linked images, name a file outside them by its file name. A path the client gives stays in the reply. `crates/engine/src/tests/save.rs`, `crates/engine/src/cmd/fileio/tests_screens.rs`, `crates/engine/src/tests/package.rs`, `crates/ui-egui/src/tests/automation.rs`. - `ui.inspect` under `--automation-read-root`: the documents still opening, the recent files, library panels' files and folder preferences (saved, and in an open Preferences dialog) outside the read roots are left out of the reply, a library loaded from outside them shows by its file name in `ui.inspect` and `ui.docking`, and the app's state is not changed. `crates/ui-egui/src/tests/automation.rs` (`ui_inspect_leaves_out_paths_outside_the_read_root`). - Swatch, brush, symbol and graphic style library lists under `--automation-read-root`: library files and the user library folder outside the read roots are left out, a library loaded from outside them is listed and found by its file name, and the app's own list is not changed. `crates/engine/src/tests/library_roots.rs`. - Scripts: `alert`, `confirm` and `prompt` open dialogs during a run, each with a Stop button that ends the run and puts every document back. In the app the run waits at the call for the person's answer; a run over MCP or the command line takes its answers in order from `script.run`'s `answers`, and the report lists each dialog with its answer. `crates/engine/src/tests/script_dialogs.rs`, `crates/ui-egui/src/tests/script_dialogs.rs`. From 5be501e47f10cec005566eab257c239bd5181e04 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 22:09:14 -0800 Subject: [PATCH 26/43] DXF: an open path's fill outline is closed, as the fill is A filled open path wrote its outline as an open polyline, so with fill hatches left out (and in R12, and in CNC / CAM) the area lost its closing edge. Fill outlines now close each open subpath that can enclose an area; strokes of the same path stay open. --- crates/cad/src/scene.rs | 31 +++++++++++++++++++++++++++++-- crates/cad/src/tests.rs | 24 ++++++++++++++++++++++++ docs/mcp.md | 2 +- docs/target-app-parity.md | 2 +- 4 files changed, 55 insertions(+), 4 deletions(-) diff --git a/crates/cad/src/scene.rs b/crates/cad/src/scene.rs index 300407eed6..7c5d8b6755 100644 --- a/crates/cad/src/scene.rs +++ b/crates/cad/src/scene.rs @@ -342,6 +342,32 @@ fn subpaths(bp: &BezPath) -> Vec<Sub> { out } +/// `bp` with each open subpath that can enclose an area closed, as a fill closes it: two or more +/// segments, or one curve. A single straight segment stays open. +fn closed_fill(bp: &BezPath) -> BezPath { + let mut out = BezPath::new(); + let (mut segs, mut curved) = (0usize, false); + let close = |out: &mut BezPath, segs: usize, curved: bool| { + if segs >= 2 || (segs == 1 && curved) { + out.close_path(); + } + }; + for el in bp.elements() { + match *el { + PathEl::MoveTo(_) => { + close(&mut out, segs, curved); + (segs, curved) = (0, false); + } + PathEl::ClosePath => (segs, curved) = (0, false), + PathEl::LineTo(_) => segs += 1, + PathEl::QuadTo(..) | PathEl::CurveTo(..) => (segs, curved) = (segs + 1, true), + } + out.push(*el); + } + close(&mut out, segs, curved); + out +} + /// The most pixels of an image written (it is held decoded, as RGBA and encoded at once). const MAX_IMAGE_PIXELS: u64 = 64 << 20; @@ -887,7 +913,7 @@ impl<'a> Scene<'a> { fn area(&mut self, bp: &BezPath, rule: FillRule, style: &EntityStyle, clean: bool) { if !self.o.version.handles() { self.warn(NO_FILLS); - self.lines(bp, style, 0.0); + self.lines(&closed_fill(bp), style, 0.0); return; } let several = bp.elements().iter().filter(|e| matches!(e, PathEl::MoveTo(_))).count() > 1; @@ -901,7 +927,8 @@ impl<'a> Scene<'a> { if loops.is_empty() { return; } - self.lines(outline, style, 0.0); + // The outlines close as the fill does, so an open path's fill keeps its closing edge. + self.lines(&closed_fill(outline), style, 0.0); if !self.o.include_hatches { self.warn(HATCHES_OFF); return; diff --git a/crates/cad/src/tests.rs b/crates/cad/src/tests.rs index 2ffa594f44..9d2a519e1d 100644 --- a/crates/cad/src/tests.rs +++ b/crates/cad/src/tests.rs @@ -925,6 +925,30 @@ fn fills_without_hatches_keep_their_outlines() { assert!(!off.warnings.iter().any(|w| w == HATCHES_OFF)); } +#[test] +fn an_open_path_s_fill_outline_keeps_its_closing_edge() { + let mut bp = vectorcraft_geom::BezPath::new(); + bp.move_to((10.0, 10.0)); + bp.line_to((90.0, 10.0)); + bp.line_to((50.0, 80.0)); + let open = PathData::from_bezpath(&bp); + let d = doc_with(|d| vec![path(d, open.clone(), Paint::solid(Color::BLACK), Paint::solid(Color::BLACK), 1.0)]); + let closed = |e: &Pairs| get(e, 70).and_then(|v| v.trim().parse::<i64>().ok()).is_some_and(|v| v & 1 == 1); + for include_hatches in [false, true] { + let out = export(&d, &DxfOptions { include_hatches, ..opts(&d) }).unwrap(); + let e: Vec<Pairs> = entities(&out).into_iter().filter(|x| kind(x) == "LWPOLYLINE").collect(); + assert_eq!(e.len(), 2, "the fill's outline and the stroke: {:?}", kinds(&out)); + assert!(closed(&e[0]), "the fill's outline closes as the fill does (hatches {include_hatches})"); + assert!(!closed(&e[1]), "the stroke stays open (hatches {include_hatches})"); + } + // R12 writes the outline alone, closed too. + let fill_only = doc_with(|d| vec![path(d, open.clone(), Paint::solid(Color::BLACK), Paint::None, 0.0)]); + let r12 = export(&fill_only, &DxfOptions { version: DxfVersion::R12, ..opts(&fill_only) }).unwrap(); + let e = entities(&r12); + assert_eq!(kind(&e[0]), "POLYLINE", "{:?}", kinds(&r12)); + assert!(closed(&e[0]), "R12's outline closes as the fill does"); +} + #[test] fn images_can_be_left_out_with_their_files() { let d = image_and_fill_doc(Paint::None); diff --git a/docs/mcp.md b/docs/mcp.md index ac3e1feec1..381c177075 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5862,7 +5862,7 @@ DXF layer (hidden layers switched off, locked ones locked, non-printing ones not straight paths become polylines (an open path of one straight segment becomes a line, except for a stroke weight before 2000), curved ones cubic splines through every anchor, fills their outlines followed by solid hatches (laser and cutter software reads the outlines and skips hatches; R12 has no hatches: the outlines -alone), strokes lines with their lineweight and a linetype per dash pattern, and placed images image entities linked +alone; an open path's fill outline is closed, as the fill is), strokes lines with their lineweight and a linetype per dash pattern, and placed images image entities linked to PNG or JPEG files (`rasterFormat`) written next to the drawing (`linked` in the result). `includeImages: false` leaves placed images out, with no image entity, no linked file and no decoding of their pixels, and `includeHatches: false` writes each filled area as its outlines alone, without its solid hatch (both default to `true`; these two diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 4903c82f91..0b3c7b03f8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1501,7 +1501,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - EPS and PostScript import reads image data through `CCITTFaxDecode`: Group 4 (ITU-T T.6) and Group 3 one-dimensional and mixed (ITU-T T.4) data, with the filter's `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1` parameters, decodes to 1-bit rows for `image` and `imagemask`. Damaged data keeps the rows decoded before it (damage in the first row is a PostScript `ioerror`), and parameters out of range or past the 256 MiB limit on decoded data are refused (`crates/eps/src/import/tests_images.rs`). Reading such a file stopped at an error naming the filter, and the file opened from a fallback, such as its preview or the art drawn before the image, or did not open. - EPS and PostScript `.ai` import undoes the TIFF predictor (`/Predictor 2`) of `FlateDecode` and `LZWDecode` data at 1, 2, 4 and 16 bits a component as well as 8: each component adds the same component of the pixel to its left within its bit width, 16-bit components add as big-endian numbers, and each row starts again (ISO 32000-1 §7.4.4.4; `crates/eps/src/import/tests_images.rs`, `crates/engine/src/cmd/fileio/tests_epsimport.rs`). At those depths the image drew the stored differences instead of its samples. - DXF control-point SPLINE import honors its closed flag (#1299 follow-up after #1320 fixed export), including rational curves; open curves whose endpoints meet stay open. Own-fixture regressions cover controls/knots, fit-point fallback and all export versions (`crates/cad/src/import/tests.rs`, `crates/cad/src/tests.rs`); native save/read checked. Circular cubic paths export as native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE, and a non-circular ellipse keeps the existing fallback (`crates/cad/src/tests.rs`); an open path of one straight segment exports as a LINE (R12 to R14 strokes with a weight stay polylines, which carry the width) and reads back as the same segment (`crates/cad/src/tests.rs`, `crates/cad/src/import/tests.rs`); DXF Options' Preset menu and `document.exportDxf`'s `dxfPreset: "cnc"` set the CNC / CAM options (version 2000, 1 mm = 1 unit, 256 colors, Maximize Editability with Alter Paths off, Outline Text, placed images and fill hatches left out), and the menu shows Custom once a field changes (`the_cnc_preset_writes_version_2000_in_millimeters_with_type_as_paths_and_strokes_as_lines`, `the_cnc_preset_sets_the_options_that_are_not_given`, `the_cnc_cam_preset_sets_the_fields_and_a_change_shows_custom`); VCarve QA remains pending. - - DXF export's Include Placed Images and Include Fill Hatches (`includeImages`, `includeHatches`; VectorCraft's own options, on by default and off in CNC / CAM) leave out placed images with their linked files, without decoding them, and the solid hatch after each filled area's outlines, each on its own, with one note in `warnings` when something was left out; R12 keeps its own notes (`fills_without_hatches_keep_their_outlines`, `images_can_be_left_out_with_their_files`, `images_and_hatches_are_left_out_independently_and_the_cnc_preset_leaves_out_both`, `placed_images_and_fill_hatches_can_be_left_out`, `include_placed_images_and_fill_hatches_follow_the_preset_and_a_change_shows_custom`). + - DXF export's Include Placed Images and Include Fill Hatches (`includeImages`, `includeHatches`; VectorCraft's own options, on by default and off in CNC / CAM) leave out placed images with their linked files, without decoding them, and the solid hatch after each filled area's outlines, each on its own, with one note in `warnings` when something was left out; R12 keeps its own notes. An open path's fill outline is closed, as the fill is, in every version (`an_open_path_s_fill_outline_keeps_its_closing_edge`, `fills_without_hatches_keep_their_outlines`, `images_can_be_left_out_with_their_files`, `images_and_hatches_are_left_out_independently_and_the_cnc_preset_leaves_out_both`, `placed_images_and_fill_hatches_can_be_left_out`, `include_placed_images_and_fill_hatches_follow_the_preset_and_a_change_shows_custom`). - DXF export writes a path of straight segments and circular arcs (a slot, a D shape, a rounded rectangle, a pie) as one polyline whose vertices carry a bulge (group 42) for each arc: an LWPOLYLINE in R14 and later, a POLYLINE with VERTEX entities in R12 and R13, closed when the path is closed, and it reads back as the same outline (#1299, `crates/cad/src/tests.rs`). Such a path was written as one SPLINE (a flattened polyline in R12); a path with any other curve still is. A shallow curve becomes an ARC or a bulge only when it lies on the circle within 5e-4 of its own extent as well as of the radius, and the drawing extents (`$EXTMIN`, `$EXTMAX`) reach the furthest points of every ARC and bulge. - Affinity documents in (AF.1–AF.4): `.af` (Affinity 3) and `.afdesign`/`.afphoto`/`.afpub` (Affinity 1 and 2, container versions 8–12) open and place as editable art: layers and sublayers, groups (pass-through or isolated), artboards (with their names and backgrounds) and Publisher pages side by side, curves with live corners, parametric shapes (rectangles with corner radii, ellipses, polygons, stars, pies, triangles, trapezoids), compound shapes, solid/linear/elliptical gradient fills and strokes (weight, dashes, alignment, scale-with-object), clipping by nesting, vector and pixel masks, artistic and frame text with fonts, sizes, tracking, leading and alignment, placed images (their original JPEG/PNG) and pixel layers (8/16-bit RGB, CMYK), opacity, visibility, locking and blend modes, at the document's resolution in points. Effects, adjustments, brushes and pressure profiles, transparency gradients, master pages, symbol instances (imported as their cached picture), special shapes (cloud, heart, cog…, imported as ellipses) and Affinity-only blend modes are named in the import warning. A file whose native data can't be read opens as its embedded preview with the reason, and that preview can't be placed. Save never writes back over the Affinity file and there is no Affinity export. AF.5 (from DesignCraft's import, storytold/designcraft#291) adds master pages (on a locked layer behind the pages that show them), tables (area type per cell and a line per edge), each paragraph's alignment, indents, spacing and leading, horizontal text scale, outlined text, frame text sized by its text scale, drop shadows and outer glows as live effects (inner shadows as inner glows), outline effects as strokes, colour and gradient overlays baked into the paint, embedded Affinity documents as editable content (their artboards don't become artboards), pixel layers over 64 MP at a reduced resolution and HEIC photos from their stored pixels; automatic leading (size plus the font's line gap) fitted on thumbnails; underlines and strikethrough; strokes whose style is None (every shape stores one) left out, which also brings 6 public files closer to their thumbnails; line breaks inside a paragraph; table cell text centred as Affinity centres it, also where it is taller than its cell; CMYK pixel layers converted through the document's embedded CMYK profile (relative colorimetric with black-point compensation, as each layer's colour settings name), or kept as CMYK images when there is none; outline effects on stroked shapes around their stroke; artistic text whose paragraphs align differently aligned within its longest line, a frame's first baseline at cap height, and frames whose last lines' descenders hang out made tall enough to show them (all measured on a local document's own PDF export: baselines match to 0.1 pt); a local 56-artboard document's mean thumbnail difference went from 16.2 to 11.4, and its pages' mean difference from the owner's PDF export from 12.9 to 9.3. AF.4 adds Affinity 3 artboard recognition and actual-outline clipping, source-backed JPEG/PNG raster tiles, Unicode text-run recovery, stored fallback/exact installed fonts, OpenType feature flags and compound-geometry limits. 51 public CC0/MIT/Apache-2.0 files (33 current `.af`), pinned and sha256-verified (`cargo xtask corpus --affinity`), test native retention and every-board native/SVG/PDF/PSD output for current files; selected files compare their saved thumbnails. No Affinity reopen or independent paired PSD oracle was available; [source audit and complex-font gaps](affinity-validation.md); [scope, provenance and limits](../crates/affinity/README.md). - Placed documents: File › Place of a `.vectorcraft` file links to it by default (`file.place`'s `link`): one locked object showing an artboard of the document (or its art's bounds), read the way placing it without Link reads it, its symbols, patterns and images kept apart from the document's; without Link (or from bytes, not a file) it is an editable copy as before. It stays vectors in every output (SVG, PDF, EPS, EMF, DXF), and so do placed documents inside it, read from their own files up to 8 levels deep (documents that place each other stop there). On screen they draw from bitmaps cached per file and zoom step, shared by every copy and made in the background (a placeholder until ready), so a sheet of them redraws in about a millisecond; exports and prints draw them exactly. Like linked images, a save keeps only a preview of each (JPEG when opaque) unless Include Linked Files; output, and zooming past the preview, read the file again, falling back to the preview with a warning when it is gone or changed. Saves for older versions write them as plain art. Links check, update, relink, Update Links on open, Link Info (the artboard's size), Placement Options, Break Link (also Embed, Object › Expand: the editable copy placing without Link gives, its resources joining the document), Edit Original (the document in a new tab) and the Links panel cover them; saving a document updates the open documents that place it. The native format (v4) keeps data of 8 KB or more after the JSON instead of base64 in it. From bcc99014197a270cc1cb671fcf0520b1016e6d6e Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 22:09:58 -0800 Subject: [PATCH 27/43] Brushes: drag a brush onto the canvas to place a copy of its art A scatter, art or pattern brush dragged from the Brushes panel onto an empty part of the canvas places a copy of its art there to edit, centered where it is dropped (brush.placeArt). Dropped on a path, a brush is still applied to it. A pattern brush is placed as its tiles in a row, 10 pt apart, each in a group named for its tile, with an unpainted rectangle at the back of a tile that has a repeat frame. Calligraphic and bristle brushes have no art and are refused. A symbol instance in the art is measured by its symbol's art. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/engine/src/cmd/brushsym.rs | 13 +- crates/engine/src/cmd/brushsym/brushes.rs | 87 ++++++++++++++ crates/engine/src/cmd/plugin_call/reviewed.rs | 1 + crates/engine/src/tests/brushsym.rs | 112 ++++++++++++++++++ crates/ui-egui/src/canvas/context.rs | 19 ++- crates/ui-egui/src/canvas/tests.rs | 43 +++++++ crates/ui-egui/src/i18n/bg.tsv | 2 + crates/ui-egui/src/i18n/bs.tsv | 2 + crates/ui-egui/src/i18n/ca.tsv | 2 + crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/da.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/el.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/fi.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/hi.tsv | 2 + crates/ui-egui/src/i18n/hu.tsv | 2 + crates/ui-egui/src/i18n/id.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/ka.tsv | 2 + crates/ui-egui/src/i18n/ko.tsv | 2 + crates/ui-egui/src/i18n/nl.tsv | 2 + crates/ui-egui/src/i18n/pl.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/sv.tsv | 2 + crates/ui-egui/src/i18n/tr.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/vi.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + crates/ui-egui/src/panels/brushes.rs | 3 +- crates/ui-egui/src/widgets/drag.rs | 3 +- docs/mcp.md | 9 ++ docs/target-app-parity.md | 1 + 37 files changed, 339 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index b4b231a22b..a44b532028 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -33,8 +33,8 @@ mod symbols; pub(crate) use brushes::{apply_brush, brush_targets, stroke_paint}; use brushes::{ - brush_apply, brush_delete, brush_duplicate, brush_freehand, brush_get, brush_list, brush_move, brush_new, brush_options, brush_remove, - brush_selection_art, brush_set_current, brush_unused, expand_brush, + brush_apply, brush_delete, brush_duplicate, brush_freehand, brush_get, brush_list, brush_move, brush_new, brush_options, brush_place_art, + brush_remove, brush_selection_art, brush_set_current, brush_unused, expand_brush, }; pub(crate) use dynamic::{edit_part, part_only, selected_part}; use dynamic::{symbol_instance_reset, symbol_instance_select_part, symbol_instance_set_appearance}; @@ -67,6 +67,15 @@ pub fn specs() -> Vec<CommandSpec> { has_doc, brush_selection_art ), + cmd!( + "brush.placeArt", + "Place Brush Art", + [], + None, + "{name, x?, y?} place a copy of the art a scatter, art or pattern brush is made of, centered on (x, y) (default: the center of the first artboard), and select it, as dragging the brush from the Brushes panel onto an empty part of the canvas does; a pattern brush places its tiles left to right (side, outer corner, inner corner, start, end; the ones it has art for) 10 pt apart, each in a group named Side Tile, Outer Corner Tile, Inner Corner Tile, Start Tile or End Tile, with an unpainted rectangle at the back of a tile that has a repeat frame marking that frame; a calligraphic or bristle brush is refused → {id}", + has_doc, + brush_place_art + ), cmd!( "brush.apply", "Apply Brush", diff --git a/crates/engine/src/cmd/brushsym/brushes.rs b/crates/engine/src/cmd/brushsym/brushes.rs index cf5dedb6bd..4d0cb028bc 100644 --- a/crates/engine/src/cmd/brushsym/brushes.rs +++ b/crates/engine/src/cmd/brushsym/brushes.rs @@ -483,6 +483,93 @@ pub(super) fn brush_options(s: &mut Session, p: &Value) -> Result<Value> { Ok(json!({ "name": out })) } +/// The name `brush.placeArt` gives each pattern brush tile it places, by tile, in the order it +/// places them. +const TILE_NAMES: [&str; 5] = ["Side Tile", "Outer Corner Tile", "Inner Corner Tile", "Start Tile", "End Tile"]; + +/// The space between the pattern brush tiles `brush.placeArt` places, in points. +const TILE_GAP: f64 = 10.0; + +/// The message for a brush that is not made of art. +fn no_art(name: &str) -> String { + format!("`{name}` has no art (only scatter, art and pattern brushes do)") +} + +/// Tile `i` of `p` ([`TILE_NAMES`] order): its art and its repeat frame, when it has art. +fn tile(p: &brush::PatternBrush, i: usize) -> Option<(&Node, Option<vectorcraft_geom::Rect>)> { + match i { + 0 => Some((&p.side, p.side_frame)), + 1 => p.outer_corner.as_ref().map(|n| (n, p.outer_corner_frame)), + 2 => p.inner_corner.as_ref().map(|n| (n, p.inner_corner_frame)), + 3 => p.start.as_ref().map(|n| (n, p.start_frame)), + _ => p.end.as_ref().map(|n| (n, p.end_frame)), + } +} + +/// The art `brush.placeArt` places for brush `b`, in the brush's own coordinates: a scatter or art +/// brush's art, or a pattern brush's tiles in a row, each in a group named for its tile +/// ([`TILE_NAMES`]), with an unpainted rectangle at the back of a tile that has a repeat frame +/// marking that frame. `None` for a brush that is not made of art. Symbol instances in the art are +/// measured by their symbols' art in `symbols`. +fn placed_art(b: &Brush, symbols: &vectorcraft_doc::SymbolBoxes) -> Option<Node> { + match &b.kind { + BrushKind::Scatter(sc) => Some(sc.art.clone()), + BrushKind::Art(a) => Some(a.art.clone()), + BrushKind::Pattern(pb) => { + let mut tiles = vec![]; + let mut x = 0.0; + for (i, tile_name) in TILE_NAMES.iter().enumerate() { + let Some((art, frame)) = tile(pb, i) else { continue }; + let mut children = vec![]; + if let Some(f) = frame { + let marker = Node::path( + NodeId(0), + vectorcraft_geom::shapes::rectangle(f), + vectorcraft_doc::Appearance::basic(Paint::None, Paint::None, 1.0), + ); + children.push(Arc::new(marker)); + } + children.push(Arc::new(art.clone())); + let mut g = Node::group(NodeId(0), children); + g.name = Some((*tile_name).to_string()); + let Some(r) = g.geometric_bounds_with(symbols) else { continue }; + // Each tile sits right of the one before it, centered on the line y = 0. + g.transform(vectorcraft_geom::Affine::translate((x - r.x0, -r.center().y)), false); + x += r.width() + TILE_GAP; + tiles.push(Arc::new(g)); + } + Some(Node::group(NodeId(0), tiles)) + } + BrushKind::Calligraphic(_) | BrushKind::Bristle(_) => None, + } +} + +/// `brush.placeArt`: a copy of the art brush `name` is made of, centered on (`x`, `y`) (default: the +/// center of the first artboard) in the layer new art goes into, and selected, as one undo step. +/// Scatter and art brushes place their art; pattern brushes place their tiles ([`placed_art`]). +pub(super) fn brush_place_art(s: &mut Session, p: &Value) -> Result<Value> { + const C: &str = "brush.placeArt"; + let name = name_param(p, C)?.to_string(); + let d = &s.doc()?.doc; + let center = d.artboards.first().map(|a| a.rect.center()).unwrap_or(super::Point::ZERO); + let at = super::Point::new(super::number_or(p, "x", center.x, C)?, super::number_or(p, "y", center.y, C)?); + let b = brush::find(d, &name).ok_or_else(|| bad(C, format!("no brush named `{name}`")))?; + let symbols = d.symbol_boxes(); + let mut art = placed_art(&b, &symbols).ok_or_else(|| bad(C, no_art(&name)))?; + if let Some(r) = art.geometric_bounds_with(&symbols) { + art.transform(vectorcraft_geom::Affine::translate(at - r.center()), false); + } + let parent = s.doc()?.target_parent()?; + let id = s.edit("Place Brush Art", |d, sel| { + let art = d.reid(&art); + let id = art.id; + d.insert(parent, usize::MAX, art)?; + sel.set([id]); + Ok(id) + })?; + Ok(json!({ "id": id.0 })) +} + pub(super) fn expand_brush(s: &mut Session, p: &Value) -> Result<Value> { let ids = brush_targets(&s.doc()?.doc, &roots(s, p)?); let ids: Vec<NodeId> = ids.into_iter().filter(|id| s.doc().ok().and_then(|st| st.doc.node(*id)).is_some_and(brush::has_brush)).collect(); diff --git a/crates/engine/src/cmd/plugin_call/reviewed.rs b/crates/engine/src/cmd/plugin_call/reviewed.rs index 51470e742d..ff1b1e4d3e 100644 --- a/crates/engine/src/cmd/plugin_call/reviewed.rs +++ b/crates/engine/src/cmd/plugin_call/reviewed.rs @@ -108,6 +108,7 @@ pub(super) const ALLOWED: &[&str] = &[ "brush.move", "brush.new", "brush.options", + "brush.placeArt", "brush.remove", "brush.selectionArt", "brush.setCurrent", diff --git a/crates/engine/src/tests/brushsym.rs b/crates/engine/src/tests/brushsym.rs index fd1ac70476..d678815715 100644 --- a/crates/engine/src/tests/brushsym.rs +++ b/crates/engine/src/tests/brushsym.rs @@ -253,6 +253,118 @@ fn selection_art_is_the_art_new_brush_takes() { assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "only brush.new is an undo step"); } +fn undo_steps(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +fn selected(s: &Session) -> Vec<NodeId> { + s.doc().unwrap().selection.objects.clone() +} + +/// The JSON of object `id`, as `brush.options` params take art. +fn art_json(s: &mut Session, id: NodeId) -> Value { + s.execute("brush.selectionArt", &json!({"ids": [id.0]})).unwrap()["art"].clone() +} + +/// `brush.placeArt` places a copy of an art or scatter brush's art centered where it is dropped +/// (by default on the first artboard's center), selected, in one undo step; brushes without art, +/// unknown brushes and a position that is not a number are refused. +#[test] +fn place_art_puts_a_copy_of_the_brush_art_where_it_is_dropped() { + let mut s = session(); + let r = rect(&mut s, 10.0, 20.0, 40.0, 8.0); + s.execute("brush.new", &json!({"type": "art", "name": "Bar", "ids": [r.0]})).unwrap(); + let steps = undo_steps(&s); + let id = NodeId(s.execute("brush.placeArt", &json!({"name": "Bar", "x": 100, "y": 80})).unwrap()["id"].as_u64().unwrap()); + assert_eq!(undo_steps(&s), steps + 1); + assert_eq!(selected(&s), vec![id]); + assert_ne!(id, r, "a copy, not the art the brush was made from"); + assert!(close(bounds(&s, id), Rect::new(80.0, 76.0, 120.0, 84.0), 1e-6), "{:?}", bounds(&s, id)); + // Without a position: the first artboard's center (the document is 400 × 300). + let scatter = NodeId(s.execute("brush.placeArt", &json!({"name": "Dots"})).unwrap()["id"].as_u64().unwrap()); + let c = bounds(&s, scatter).center(); + assert!((c.x - 200.0).abs() < 1e-6 && (c.y - 150.0).abs() < 1e-6, "{c:?}"); + let steps = undo_steps(&s); + for (p, msg) in [ + (json!({"name": "3 pt. Round"}), "`3 pt. Round` has no art (only scatter, art and pattern brushes do)"), + (json!({"name": "Bristle Round"}), "`Bristle Round` has no art (only scatter, art and pattern brushes do)"), + (json!({"name": "Nope"}), "no brush named `Nope`"), + ] { + let e = s.execute("brush.placeArt", &p).unwrap_err(); + assert!(e.to_string().contains(msg), "{p}: {e}"); + } + assert!(s.execute("brush.placeArt", &json!({"name": "Bar", "x": "100"})).is_err()); + assert_eq!(undo_steps(&s), steps, "a refused call adds no undo step"); +} + +/// Brush art holding an instance of a symbol whose art lies away from the symbol's origin (as +/// imported symbols can) is centered by that art, not by the box an instance has without its +/// document. +#[test] +fn placed_art_with_a_symbol_instance_is_centered_by_the_symbols_art() { + let mut s = session(); + let r = rect(&mut s, 300.0, 10.0, 100.0, 40.0); + let art = node(&s, r); + s.execute("edit.clear", &json!({"ids": [r.0]})).unwrap(); + let st = s.doc_mut().unwrap(); + let d = std::sync::Arc::make_mut(&mut st.doc); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Off".into(), art: std::sync::Arc::new(art) }); + let layer = d.layers[0].id; + let inst = d.alloc_id(); + let xf = vectorcraft_geom::Affine::translate((50.0, 50.0)); + d.insert(Some(layer), usize::MAX, Node::new(inst, NodeKind::SymbolInstance { symbol: "Off".into(), xf })).unwrap(); + let inst = inst.0; + s.execute("brush.new", &json!({"type": "art", "name": "Stamp", "ids": [inst]})).unwrap(); + let id = NodeId(s.execute("brush.placeArt", &json!({"name": "Stamp", "x": 200, "y": 150})).unwrap()["id"].as_u64().unwrap()); + let st = s.doc().unwrap(); + let b = st.doc.bounds_of(&[id], false).unwrap(); + assert!((b.center().x - 200.0).abs() < 1e-6 && (b.center().y - 150.0).abs() < 1e-6, "{b:?}"); + assert!((b.width() - 100.0).abs() < 1e-6, "{b:?}"); +} + +/// A pattern brush with side, outer corner and start tiles, the side tile with a repeat frame +/// wider than its art: → its name. +fn framed_pattern_brush(s: &mut Session) -> String { + let side = rect(s, 0.0, 0.0, 20.0, 10.0); + let corner = rect(s, 0.0, 40.0, 10.0, 10.0); + let start = rect(s, 0.0, 80.0, 6.0, 10.0); + let name = s.execute("brush.new", &json!({"type": "pattern", "name": "Rail", "ids": [side.0]})).unwrap()["name"].as_str().unwrap().to_string(); + let params = json!({ + "side": art_json(s, side), + "side_frame": Rect::new(-2.0, 0.0, 22.0, 10.0), + "outer_corner": art_json(s, corner), + "start": art_json(s, start), + }); + s.execute("brush.options", &json!({"name": name, "params": params})).unwrap(); + name +} + +/// `brush.placeArt` lays a pattern brush's tiles out left to right, each in a group named for its +/// tile, with an unpainted rectangle at the back of a tile with a repeat frame marking that frame. +#[test] +fn pattern_brushes_place_their_tiles_in_a_row() { + let mut s = session(); + let name = framed_pattern_brush(&mut s); + let id = NodeId(s.execute("brush.placeArt", &json!({"name": name, "x": 200, "y": 150})).unwrap()["id"].as_u64().unwrap()); + let placed = node(&s, id); + let tiles: Vec<Node> = placed.children().unwrap().iter().map(|c| (**c).clone()).collect(); + let names: Vec<&str> = tiles.iter().map(|t| t.name.as_deref().unwrap_or("")).collect(); + assert_eq!(names, ["Side Tile", "Outer Corner Tile", "Start Tile"]); + let boxes: Vec<Rect> = tiles.iter().map(|t| t.geometric_bounds().unwrap()).collect(); + assert!(boxes.windows(2).all(|w| (w[1].x0 - w[0].x1 - 10.0).abs() < 1e-6), "10 pt apart, left to right: {boxes:?}"); + // The side tile: the frame's rectangle, unpainted, behind the art. + let side = tiles[0].children().unwrap(); + assert_eq!(side.len(), 2); + let marker = &side[0]; + assert!(marker.appearance.items.iter().all(|i| match i { + vectorcraft_doc::AppearanceItem::Fill(f) => f.paint.is_none(), + vectorcraft_doc::AppearanceItem::Stroke(st) => st.paint.is_none(), + })); + let frame = marker.geometric_bounds().unwrap(); + assert!((frame.width() - 24.0).abs() < 1e-6 && (frame.height() - 10.0).abs() < 1e-6, "{frame:?}"); + assert_eq!(tiles[1].children().unwrap().len(), 1, "no frame, no rectangle"); +} + #[test] fn brush_options_rename_duplicate_and_delete_update_strokes() { let mut s = session(); diff --git a/crates/ui-egui/src/canvas/context.rs b/crates/ui-egui/src/canvas/context.rs index c5bbe90882..cd98a214b7 100644 --- a/crates/ui-egui/src/canvas/context.rs +++ b/crates/ui-egui/src/canvas/context.rs @@ -53,7 +53,10 @@ pub(super) fn context_menu(app: &mut VectorcraftApp, resp: &egui::Response, xf: /// to its active proxy (`paint.setFill`/`paint.setStroke` with its `ids`; a gradient fits it), the /// Appearance panel's thumbnail gives the object (the topmost one hit) the appearance it carries /// (`appearance.copyFrom`), a graphic style is applied to it (`graphicStyle.apply`; with Alt, on -/// top of its appearance). A chip follows the pointer meanwhile. +/// top of its appearance), and a brush is applied to it (`brush.apply`, `brush.addFromLibrary` +/// for a brush library's). A Brushes panel scatter, art or pattern +/// brush dropped where there is no art places a copy of its art there (`brush.placeArt`). A chip +/// follows the pointer meanwhile. pub(super) fn panel_drop(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, xf: &Xf) { crate::panels::swatches::drag_preview(app, ui.ctx()); let Some(pos) = ui.input(|i| i.pointer.interact_pos()) else { return }; @@ -86,7 +89,19 @@ pub(super) fn panel_drop(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Respons } return; } - let Some(hit) = hit_at(app, xf.to_doc(pos), xf.zoom) else { return }; + let hit = hit_at(app, xf.to_doc(pos), xf.zoom); + // A scatter, art or pattern brush from the Brushes panel dropped where there is no art: a copy + // of its art, centered where it is dropped, to edit and Alt-drag back onto the brush. + if let (None, widgets::PanelDrag::Brush { name, def }) = (&hit, &*d) + && matches!(def["type"].as_str(), Some("scatter" | "art" | "pattern")) + { + let at = xf.to_doc(pos); + if let Err(e) = app.run("brush.placeArt", json!({"name": name, "x": at.x, "y": at.y})) { + app.status(e); + } + return; + } + let Some(hit) = hit else { return }; let Some(st) = app.session.active() else { return }; let (cmd, params) = match &*d { widgets::PanelDrag::Paint { params, .. } => { diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 4f962fdb5d..cfde5e78c8 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -6980,6 +6980,49 @@ fn a_brush_dropped_on_a_path_is_applied_to_it() { assert_eq!(brush(&app).as_deref(), Some("Arrow")); } +#[test] +fn a_brush_with_art_dropped_off_the_art_places_a_copy_of_its_art_there() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.session.execute("path.create", &json!({"anchors": [{"x": 100, "y": 150}, {"x": 300, "y": 150}]})).unwrap(); + app.session.execute("select.none", &json!({})).unwrap(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let drop = |app: &mut VectorcraftApp, name: &str, at: Point| { + let def = app.session.execute("brush.get", &json!({"name": name})).unwrap(); + let at = xf.to_screen(at); + egui::DragAndDrop::set_payload(&ctx, widgets::PanelDrag::Brush { name: name.into(), def }); + let up = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }; + frame(app, &ctx, vec![egui::Event::PointerMoved(at), up]); + }; + let undo = app.session.active().unwrap().history.undo.len(); + // A calligraphic brush has no art: nothing. + drop(&mut app, "3 pt. Round", Point::new(200.0, 250.0)); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo); + // An art brush: a copy of its art, centered where it lands, selected. + drop(&mut app, "Arrow", Point::new(200.0, 250.0)); + let st = app.session.active().unwrap(); + assert_eq!(st.history.undo.len(), undo + 1); + assert_eq!(st.history.undo.last().unwrap().label, "Place Brush Art"); + let [placed] = st.selection.objects.as_slice() else { panic!("one object selected: {:?}", st.selection.objects) }; + let b = st.doc.node(*placed).unwrap().geometric_bounds().unwrap(); + assert!((b.center() - Point::new(200.0, 250.0)).hypot() < 0.1, "{b:?}"); + // A scatter brush and a pattern brush (its tiles, in one group) place their art too. + for (i, name) in ["Dots", "Chain"].into_iter().enumerate() { + let at = Point::new(80.0 + 200.0 * i as f64, 60.0); + drop(&mut app, name, at); + let st = app.session.active().unwrap(); + assert_eq!(st.history.undo.len(), undo + 2 + i, "{name}"); + let [placed] = st.selection.objects.as_slice() else { panic!("{name}: one object selected: {:?}", st.selection.objects) }; + let n = st.doc.node(*placed).unwrap(); + assert!((n.geometric_bounds().unwrap().center() - at).hypot() < 0.1, "{name}"); + if name == "Chain" { + assert_eq!(n.children().unwrap()[0].name.as_deref(), Some("Side Tile")); + } + } +} + #[test] fn a_library_brush_dropped_on_a_path_is_added_and_applied_in_one_step() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index bc67d8fd87..15cd68c631 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -6364,6 +6364,8 @@ breakText Glyphs Глифи Delete the selected brushes? Да се изтрият избраните четки? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Щрихи използват една или повече от тези четки. Да се премахнат ли четките от тези щрихи и да се изтрият четките? Remove Strokes Премахване на щрихи + Place Brush Art Поставяне на графика на четка +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` няма графика (имат я само разпръснатите и артистичните четки и четките с мостра) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Инструменти за подреждане Arrange in Grid… Подреждане в мрежа… diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 6454cffcc9..7f5fe8b469 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -6414,6 +6414,8 @@ shape Open Otvoren Delete the selected brushes? Izbrisati odabrane četkice? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Potezi koriste jednu ili više ovih četkica. Ukloniti četkice s tih poteza i izbrisati četkice? Remove Strokes Ukloni poteze + Place Brush Art Postavi crtež četkice +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` nema crtež (imaju ga samo raspršene i umjetničke četkice i četkice uzorka) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Alati za raspoređivanje Arrange in Grid… Rasporedi u mrežu… diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 1567e0e8b5..16ec9e744b 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -6620,6 +6620,8 @@ breakText Glyphs Glifs Delete the selected brushes? Voleu suprimir els pinzells seleccionats? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Hi ha traços que fan servir un o més d’aquests pinzells. Voleu treure els pinzells d’aquests traços i suprimir els pinzells? Remove Strokes Elimina els traços + Place Brush Art Col·loca l’art del pinzell +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` no té art (només en tenen els pinzells de dispersió, artístics i de motiu) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Eines d’organització Arrange in Grid… Organitza en quadrícula… diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index e9df0812f2..3187e78ef8 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6639,6 +6639,8 @@ breakText Glyphs Glyfy Delete the selected brushes? Odstranit vybrané štětce? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Tahy používají jeden nebo více z těchto štětců. Odebrat štětce z těchto tahů a odstranit štětce? Remove Strokes Odebrat tahy + Place Brush Art Umístit kresbu štětce +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` nemá kresbu (mají ji jen rozptylové, obrazové a vzorkové štětce) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Nástroje uspořádání Arrange in Grid… Uspořádat do mřížky… diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index e259ecba84..828371be22 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -6378,6 +6378,8 @@ breakText Glyphs Glyffer Delete the selected brushes? Slet de valgte pensler? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Streger bruger en eller flere af disse pensler. Fjern penslerne fra stregerne, og slet penslerne? Remove Strokes Fjern streger + Place Brush Art Placér penselgrafik +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` har ingen grafik (det har kun sprednings-, grafik- og mønsterpensler) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Arrangeringsværktøjer Arrange in Grid… Arrangér i gitter… diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index abfaaa13a7..b953b6a1b9 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -6226,6 +6226,8 @@ breakText Glyphs Glyphen Delete the selected brushes? Ausgewählte Pinsel löschen? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Konturen verwenden einen oder mehrere dieser Pinsel. Pinsel von diesen Konturen entfernen und die Pinsel löschen? Remove Strokes Konturen entfernen + Place Brush Art Pinselgrafik platzieren +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` hat keine Grafik (nur Spezial-, Bild- und Musterpinsel haben eine) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Anordnungswerkzeuge Arrange in Grid… In Raster anordnen… diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 4611904ff0..4f334818ff 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -6591,6 +6591,8 @@ breakText Glyphs Γλύφοι Delete the selected brushes? Να διαγραφούν τα επιλεγμένα πινέλα; Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Περιγράμματα χρησιμοποιούν ένα ή περισσότερα από αυτά τα πινέλα. Να αφαιρεθούν τα πινέλα από αυτά τα περιγράμματα και να διαγραφούν τα πινέλα; Remove Strokes Αφαίρεση περιγραμμάτων + Place Brush Art Τοποθέτηση σχεδίου πινέλου +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` δεν έχει σχέδιο (έχουν μόνο τα πινέλα διασποράς, σχεδίου και μοτίβου) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Εργαλεία διάταξης Arrange in Grid… Διάταξη σε πλέγμα… diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index b91d89b8c0..febf7cc8d0 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -6582,6 +6582,8 @@ breakText Glyphs Glifos Delete the selected brushes? ¿Eliminar los pinceles seleccionados? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Algunos trazos usan uno o más de estos pinceles. ¿Quitar los pinceles de esos trazos y eliminar los pinceles? Remove Strokes Quitar trazos + Place Brush Art Colocar ilustración del pincel +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` no tiene ilustración (solo la tienen los pinceles de dispersión, de arte y de motivo) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Herramientas de organización Arrange in Grid… Organizar en cuadrícula… diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index e296459895..803ca30249 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6669,6 +6669,8 @@ breakText Glyphs Merkit Delete the selected brushes? Poistetaanko valitut siveltimet? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Viivat käyttävät yhtä tai useampaa näistä siveltimistä. Irrotetaanko siveltimet näistä viivoista ja poistetaanko siveltimet? Remove Strokes Poista viivat + Place Brush Art Sijoita siveltimen kuvitus +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` ei sisällä kuvitusta (kuvitus on vain sirote-, taide- ja kuviosiveltimissä) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Järjestelytyökalut Arrange in Grid… Järjestä ruudukkoon… diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index ff206b9317..fe7d76e980 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6634,6 +6634,8 @@ breakText Glyphs Glyphes Delete the selected brushes? Supprimer les formes sélectionnées ? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Des contours utilisent une ou plusieurs de ces formes. Retirer les formes de ces contours et supprimer les formes ? Remove Strokes Supprimer les contours + Place Brush Art Placer l’illustration de la forme +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` n’a pas d’illustration (seules les formes diffuses, artistiques et de motif en ont) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Outils de disposition Arrange in Grid… Disposer en grille… diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 6698f10a4b..b570076889 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -3762,6 +3762,8 @@ antiAlias Sharp शार्प Remove Selected Assets सिलेक्टेड परिसंपत्तियों को हटा दें Remove Selected Entry सिलेक्टेड प्रविष्टि निकालें Remove Strokes स्ट्रोक हटाएँ + Place Brush Art ब्रश आर्टवर्क रखें +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` में कोई आर्टवर्क नहीं है (आर्टवर्क केवल स्कैटर, आर्ट और पैटर्न ब्रश में होता है) Remove the shortcut शॉर्टकट निकालें Remove Threading थ्रेडिंग निकालें Remove White Overprint व्हाइट ओवरप्रिंट निकालें diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index a8d02ca4c4..bebf443bf6 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6623,6 +6623,8 @@ breakText Glyphs Glifák Delete the selected brushes? Törlöd a kijelölt ecseteket? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Körvonalak használnak egyet vagy többet ezek közül az ecsetek közül. Leveszed az ecseteket ezekről a körvonalakról, és törlöd az ecseteket? Remove Strokes Körvonalak eltávolítása + Place Brush Art Ecsetgrafika elhelyezése +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` ecsetnek nincs grafikája (csak a szóró-, a grafikus és a mintázatecseteknek van) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Elrendezőeszközök Arrange in Grid… Elrendezés rácsba… diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 04581cd727..44f773f2f5 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -6378,6 +6378,8 @@ breakText Glyphs Glif Delete the selected brushes? Hapus kuas yang dipilih? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Stroke menggunakan satu atau beberapa kuas ini. Lepaskan kuas dari stroke tersebut dan hapus kuasnya? Remove Strokes Hapus Stroke + Place Brush Art Tempatkan karya seni kuas +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` tidak memiliki karya seni (hanya kuas sebar, kuas seni, dan kuas pola yang memilikinya) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Alat penyusunan Arrange in Grid… Susun dalam grid… diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 82827964fe..d9639172f0 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -6260,6 +6260,8 @@ breakText Glyphs Glifi Delete the selected brushes? Eliminare i pennelli selezionati? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Alcune tracce usano uno o più di questi pennelli. Rimuovere i pennelli da queste tracce ed eliminare i pennelli? Remove Strokes Rimuovi tracce + Place Brush Art Inserisci grafica del pennello +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` non ha una grafica (ce l’hanno solo i pennelli a dispersione, artistici e motivo) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Strumenti di disposizione Arrange in Grid… Disponi in griglia… diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 42a4aa3cf2..4d3aee979d 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -6325,6 +6325,8 @@ breakText Glyphs 字形 Delete the selected brushes? 選択したブラシを削除しますか? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? これらのブラシの 1 つ以上が線で使われています。それらの線からブラシを外して、ブラシを削除しますか? Remove Strokes 線を削除 + Place Brush Art ブラシのアートを配置 +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` にはアートがありません (アートを持つのは散布ブラシ、アートブラシ、パターンブラシだけです) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools 配置ツール Arrange in Grid… グリッドに配置… diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 30b0abd6bf..9b2aaca44c 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6665,6 +6665,8 @@ breakText Glyphs გლიფები Delete the selected brushes? წაიშალოს არჩეული ფუნჯები? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? კონტურები იყენებს ამ ფუნჯებიდან ერთს ან მეტს. მოიხსნას ფუნჯები ამ კონტურებიდან და წაიშალოს ფუნჯები? Remove Strokes კონტურების მოხსნა + Place Brush Art ფუნჯის ნახატის განთავსება +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` ნახატი არ აქვს (ნახატი მხოლოდ გაფანტვის, მხატვრულ და პატერნის ფუნჯებს აქვს) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools განლაგების ხელსაწყოები Arrange in Grid… ბადეში განლაგება… diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 3bc8360b49..28dda4e7ec 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -6221,6 +6221,8 @@ breakText Glyphs 글리프 Delete the selected brushes? 선택한 브러시를 삭제하시겠습니까? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? 선에서 이 브러시 중 하나 이상을 사용합니다. 해당 선에서 브러시를 제거하고 브러시를 삭제하시겠습니까? Remove Strokes 선 제거 + Place Brush Art 브러시 아트 배치 +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}`에는 아트가 없습니다 (흩뿌리기, 아트, 패턴 브러시에만 아트가 있습니다) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools 배치 도구 Arrange in Grid… 격자로 배치… diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index c6f37b432a..0a9b7f742c 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -6429,6 +6429,8 @@ breakText Glyphs Glyphs Delete the selected brushes? Geselecteerde kwasten verwijderen? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Contouren gebruiken een of meer van deze kwasten. De kwasten van deze contouren halen en de kwasten verwijderen? Remove Strokes Contouren verwijderen + Place Brush Art Penseelillustratie plaatsen +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` heeft geen illustratie (alleen verspreidings-, art- en patroonpenselen hebben er een) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Schikgereedschappen Arrange in Grid… In raster schikken… diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 3647b56f54..b5f4f10241 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -6495,6 +6495,8 @@ breakText Glyphs Glify Delete the selected brushes? Usunąć zaznaczone pędzle? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Obrysy używają co najmniej jednego z tych pędzli. Zdjąć pędzle z tych obrysów i usunąć pędzle? Remove Strokes Usuń obrysy + Place Brush Art Umieść kompozycję pędzla +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` nie ma kompozycji (mają ją tylko pędzle rozproszone, artystyczne i wzorka) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Narzędzia układania Arrange in Grid… Ułóż w siatce… diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 530a62719a..d3d55ea66c 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -6557,6 +6557,8 @@ breakText Glyphs Glifos Delete the selected brushes? Excluir os pincéis selecionados? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Traçados usam um ou mais destes pincéis. Remover os pincéis desses traçados e excluir os pincéis? Remove Strokes Remover Traçados + Place Brush Art Colocar Arte do Pincel +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` não tem arte (só os pincéis de dispersão, de arte e de padrão têm) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Ferramentas de Organização Arrange in Grid… Organizar em Grade… diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 341dc196f4..ae39c26edf 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -6616,6 +6616,8 @@ breakText Glyphs Глифы Delete the selected brushes? Удалить выбранные кисти? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Обводки используют одну или несколько из этих кистей. Убрать кисти с этих обводок и удалить кисти? Remove Strokes Убрать обводки + Place Brush Art Поместить рисунок кисти +@msg `{name}` has no art (only scatter, art and pattern brushes do) у `{name}` нет рисунка (он есть только у кистей-распылений, художественных и узорных кистей) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Инструменты размещения Arrange in Grid… Разместить по сетке… diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index ed59d98113..c0ee00d2c6 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -6687,6 +6687,8 @@ breakText Glyphs Glyfer Delete the selected brushes? Radera de valda penslarna? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Konturer använder en eller flera av de här penslarna. Ta bort penslarna från konturerna och radera penslarna? Remove Strokes Ta bort konturer + Place Brush Art Placera penselgrafik +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` har ingen grafik (bara spridnings-, konst- och mönsterpenslar har grafik) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Ordningsverktyg Arrange in Grid… Ordna i rutnät… diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index dc89f33802..cce5a4496d 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6595,6 +6595,8 @@ breakText Glyphs Glifler Delete the selected brushes? Seçili fırçalar silinsin mi? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Konturlar bu fırçalardan birini veya daha fazlasını kullanıyor. Fırçalar bu konturlardan kaldırılsın ve fırçalar silinsin mi? Remove Strokes Konturları Kaldır + Place Brush Art Fırça Çizimini Yerleştir +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` fırçasının çizimi yok (yalnızca serpme, resim ve desen fırçalarının çizimi vardır) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Düzenleme Araçları Arrange in Grid… Izgarada Düzenle… diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 8d67846ecd..ab55cec91a 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -6240,6 +6240,8 @@ breakText Glyphs Гліфи Delete the selected brushes? Видалити вибрані пензлі? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Обведення використовують один або кілька з цих пензлів. Забрати пензлі з цих обведень і видалити пензлі? Remove Strokes Забрати обведення + Place Brush Art Розмістити малюнок пензля +@msg `{name}` has no art (only scatter, art and pattern brushes do) у `{name}` немає малюнка (він є лише в пензлів-розпорошень, художніх і візерункових пензлів) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Інструменти розміщення Arrange in Grid… Розмістити сіткою… diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 8f1ea434fd..f8f36f9b7a 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -6241,6 +6241,8 @@ breakText Glyphs Glyph Delete the selected brushes? Xóa các cọ đã chọn? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Có nét đang dùng một hoặc nhiều cọ này. Gỡ cọ khỏi các nét đó và xóa cọ? Remove Strokes Gỡ nét + Place Brush Art Đặt đối tượng của cọ +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` không có đối tượng (chỉ cọ phân tán, cọ bản vẽ và cọ họa tiết mới có) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools Công cụ sắp xếp Arrange in Grid… Sắp xếp theo lưới… diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 05c6539bf7..fbb5fcb5eb 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -6480,6 +6480,8 @@ breakText Glyphs 字形 Delete the selected brushes? 删除选中的画笔吗? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? 有描边使用了其中一个或多个画笔。要从这些描边移除画笔并删除画笔吗? Remove Strokes 移除描边 + Place Brush Art 置入画笔图稿 +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` 没有图稿 (只有散布画笔、艺术画笔和图案画笔有图稿) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools 排列工具 Arrange in Grid… 按网格排列… diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index cb8a61362e..ddd1bcb1b2 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -6278,6 +6278,8 @@ breakText Glyphs 字符 Delete the selected brushes? 要刪除選取的筆刷嗎? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? 有筆畫使用了其中一個或多個筆刷。要從這些筆畫移除筆刷並刪除筆刷嗎? Remove Strokes 移除筆畫 + Place Brush Art 置入筆刷圖稿 +@msg `{name}` has no art (only scatter, art and pattern brushes do) `{name}` 沒有圖稿 (只有散佈筆刷、藝術筆刷和圖樣筆刷有圖稿) # Arrange Tools add-on (Object › Plug-ins › Arrange Tools). Arrange Tools 排列工具 Arrange in Grid… 依格狀排列… diff --git a/crates/ui-egui/src/panels/brushes.rs b/crates/ui-egui/src/panels/brushes.rs index a282ff9973..d3970497d4 100644 --- a/crates/ui-egui/src/panels/brushes.rs +++ b/crates/ui-egui/src/panels/brushes.rs @@ -2,7 +2,8 @@ //! applies it to the selected paths (and makes it the Paintbrush's current brush); double-clicking //! one opens its Brush Options. Shift-click selects a range of brushes, Cmd/Ctrl-click adds or //! removes one, and the panel menu's Select All Unused selects the brushes no stroke paints with, -//! for Delete Brush and Duplicate Brush. Brush libraries open in library panels +//! for Delete Brush and Duplicate Brush. A scatter, art or pattern brush dragged onto an empty part +//! of the canvas places a copy of its art there. Brush libraries open in library panels //! ([`BrushLibraries`]). use std::cell::RefCell; diff --git a/crates/ui-egui/src/widgets/drag.rs b/crates/ui-egui/src/widgets/drag.rs index a6789916c6..1564025a41 100644 --- a/crates/ui-egui/src/widgets/drag.rs +++ b/crates/ui-egui/src/widgets/drag.rs @@ -30,7 +30,8 @@ pub enum PanelDrag { /// `place`). LibrarySymbol { library: String, name: String }, /// A Brushes panel brush (`def`: its definition, for the chip at the pointer): the path it is - /// dropped on takes it (`brush.apply`). + /// dropped on takes it (`brush.apply`); dropped where there is no art, a scatter, art or pattern + /// brush places a copy of its art there (`brush.placeArt`). Brush { name: String, def: serde_json::Value }, /// A brush of a brush library panel (`name`: its name in `library`): the path it is dropped on /// takes it (`brush.addFromLibrary` with `apply`), and the Brushes panel adds it. diff --git a/docs/mcp.md b/docs/mcp.md index 7f6df79837..4715fc0462 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2895,6 +2895,15 @@ Brushes panel's New Brush button, its menu's New Brush… and art dropped on the Brush Options for the new brush; for a scatter, art or pattern brush they preview the art from `brush.selectionArt`. Their OK runs `brush.new {type, name, params, ids?}`, and Cancel adds no brush. +`brush.placeArt {name, x?, y?}` places a copy of the art a scatter, art or pattern brush is made of, centered on +(`x`, `y`) (default: the center of the first artboard), and selects it, in one undo step. The app runs it when a +brush is dragged from the Brushes panel onto an empty part of the canvas; dropped on a path, a brush is applied to it +(`brush.apply`). A pattern brush places its tiles left to right, 10 pt apart, in the order side, outer corner, inner +corner, start, end (only the tiles it has art for), each in a group named Side Tile, Outer Corner Tile, Inner Corner +Tile, Start Tile or End Tile. A tile with a repeat frame has an unpainted rectangle at the back of its group that marks +the frame. A symbol instance in the art is measured by its symbol's art. A calligraphic or bristle brush is refused +with "`<name>` has no art (only scatter, art and pattern brushes do)". + `variable.bind`, `variable.unbind`, `variable.delete`, `dataset.set`, `dataset.update`, `dataset.delete`, `dataset.select`, `dataset.next` and `dataset.prev` add no undo step when they leave the document as it was, such as binding art to a variable it already has or applying the active data set again: the steps to redo stay, and a saved diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 908895ad64..5660e04c35 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1337,6 +1337,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Document Info's Save… writes the category the panel shows, with Selection Only applied, as the [Document Info panel](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/document-info-panel.html) page describes ("Exports the selected category's visible information to a text file"). With All Categories shown it writes every category, which is VectorCraft's own rule (`docInfo.save {category}`). Test: `panels::doc_info::tests::save_writes_the_category_the_panel_shows`. The Brushes panel menu's Select All Unused selects the brushes that are not used in the document, as the [Brushes](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html) page (archived copy) describes. A brush counts as used when a stroke paints with it in the art (opacity masks included), in symbols, graph designs, pattern tiles or graphic styles, or in the art of a used brush (`brush.unused`). A rename in Brush Options and Delete Brush change every stroke that paints with the brush: in the layers and opacity masks, symbols, graph designs, pattern tiles, graphic styles and the art of other brushes. A rename gives those strokes the new name, and Delete Brush makes them plain strokes, in one undo step. + A scatter, art or pattern brush dragged from the Brushes panel onto an empty part of the canvas places a copy of its art there to edit, as the [Modify brushes](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/modify-brushes.html) page describes (`brush.placeArt`). The copy is centered where it is dropped. A pattern brush comes out as its tiles in a row, 10 pt apart, each in a group named for its tile, with an unpainted rectangle at the back of a tile that has a repeat frame. The centering and the tile layout are VectorCraft's own rules. Evidence: `tests::brushsym::place_art_puts_a_copy_of_the_brush_art_where_it_is_dropped`, `tests::brushsym::pattern_brushes_place_their_tiles_in_a_row` (vectorcraft-engine), `canvas::tests::a_brush_with_art_dropped_off_the_art_places_a_copy_of_its_art_there` (vectorcraft-ui-egui). In Pattern Brush Options, a menu below the Outer Corner Tile and the Inner Corner Tile sets each to the brush's own corner art (Original), None, or a tile made from the side tile: Auto-Centered stretches one side tile around the corner, Auto-Between bends a side tile on each side into the corner's diagonal, Auto-Sliced cuts a side tile on each side along the diagonal, and Auto-Overlap runs a side tile on each side past the corner. Auto-Sliced masks the tiles at the diagonal, so their outlines stay as drawn. Brushes saved without these choices draw their corners as before (`brush.options` `outer_source`, `inner_source`). Evidence: `tests::pattern_auto_corners_fill_each_corner_of_a_square`, `tests::pattern_corner_source_none_leaves_that_kind_of_corner_empty` (vectorcraft-brush), `dialogs::brush_options::tests::pattern_options_set_each_corner_tile_to_original_none_or_an_automatic_tile` (vectorcraft-ui-egui). Pattern brush corner tiles are square to the side tiles at a right-angle corner and turn 90° from one corner to the next, as the [Using Illustrator CS5](https://help.adobe.com/archive/en_US/illustrator/cs5/illustrator_cs5_help.pdf) help (About patterns) describes. That document's steps for making corner tiles build both the outer and the inner tile as the corner joining a side tile on its right to one below it, and the brush reads corner art that way, with Flip Along and Flip Across or without (#6110). At corners that are not right angles, the tile is turned halfway between the two sides, which is VectorCraft's own rule. Evidence: `tests::pattern_corner_tiles_are_square_to_the_sides`, `tests::pattern_corner_tiles_sit_on_the_corners_diagonal` (vectorcraft-brush). From ca90946d8d56935a0e2eacf76562e827bf764011 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 21:46:54 -0800 Subject: [PATCH 28/43] Data Recovery: a folder error names the folder that is missing or is not a folder When a folder on the way to the Data Recovery folder was missing and couldn't be made, or was a file, the error named the area folder or the copy inside it, for example `<recovery>/1794385145-1: Not a directory (os error 20)`. Errors on the store's folders and files now name the first folder on the way, from the top, that is not a folder ("<path> is not a folder: <OS error>") or is missing ("couldn't make <path>: <OS error>" while the store makes it, "<path> is missing" otherwise). When every folder on the way is there, the error names the folder or file it happened on. When reading a folder reports it missing, listing checks the folders on the way, so a file there is an error and not an empty list. --- crates/engine/src/cmd/recovery.rs | 65 +++++++++++++++++++---- crates/engine/src/tests/recovery.rs | 81 +++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/recovery.rs b/crates/engine/src/cmd/recovery.rs index 3424da3ddd..136ba51ceb 100644 --- a/crates/engine/src/cmd/recovery.rs +++ b/crates/engine/src/cmd/recovery.rs @@ -274,7 +274,46 @@ impl FolderStore { builder.recursive(true); #[cfg(unix)] std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700); - builder.create(dir).map_err(|e| format!("{}: {e}", dir.display())) + builder.create(dir).map_err(|e| Self::folder_error(dir, &e, true)) + } + + /// The first folder on the way to `dir` (`dir` included), from the top down, that is not a + /// folder (`true`) or is missing (`false`). None when every one of them is a folder or one of + /// them can't be checked. + fn in_the_way(dir: &std::path::Path) -> Option<(&std::path::Path, bool)> { + let mut way: Vec<&std::path::Path> = dir.ancestors().filter(|p| !p.as_os_str().is_empty()).collect(); + way.reverse(); + for p in way { + match std::fs::metadata(p) { + Ok(m) if m.is_dir() => {} + Ok(_) => return Some((p, true)), + Err(m) if m.kind() == std::io::ErrorKind::NotFound => return Some((p, false)), + Err(_) => return None, + } + } + None + } + + /// The message for error `e` on folder `dir`, which was being made (`making`) or read. It + /// names the first folder on the way to `dir` that is not a folder, or is missing (when + /// `making`, the one that couldn't be made), and otherwise `dir`. + fn folder_error(dir: &std::path::Path, e: &std::io::Error, making: bool) -> String { + match Self::in_the_way(dir) { + Some((p, true)) => format!("{} is not a folder: {e}", p.display()), + Some((p, false)) if making => format!("couldn't make {}: {e}", p.display()), + Some((p, false)) if p != dir => format!("{} is missing", p.display()), + _ => format!("{}: {e}", dir.display()), + } + } + + /// The message for error `e` on file `f`. It names the first folder on the way to `f` that is + /// not a folder or is missing, and otherwise `f`. + fn file_error(f: &std::path::Path, e: &std::io::Error) -> String { + match f.parent().and_then(Self::in_the_way) { + Some((p, true)) => format!("{} is not a folder: {e}", p.display()), + Some((p, false)) => format!("{} is missing", p.display()), + None => format!("{}: {e}", f.display()), + } } /// Refuse area folder `dir` unless it is a folder itself, not a link to one, and (on Unix) @@ -282,7 +321,7 @@ impl FolderStore { /// linked to a folder of its own in a shared recovery folder looked free, and claiming it /// wrote the copies through the link. fn own_area(dir: &std::path::Path) -> std::result::Result<(), String> { - let m = std::fs::symlink_metadata(dir).map_err(|e| format!("{}: {e}", dir.display()))?; + let m = std::fs::symlink_metadata(dir).map_err(|e| Self::folder_error(dir, &e, false))?; if !m.file_type().is_dir() { return Err(format!("{}: not a folder of its own", dir.display())); } @@ -294,11 +333,17 @@ impl FolderStore { } /// The plain names of the files (or, `dirs`, folders) in `dir`; none when it doesn't exist. + /// A file on the way to `dir` is an error: Windows reports such a folder as missing. fn names(dir: &std::path::Path, dirs: bool) -> std::result::Result<Vec<String>, String> { let entries = match std::fs::read_dir(dir) { Ok(e) => e, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vec![]), - Err(e) => return Err(format!("{}: {e}", dir.display())), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return match Self::in_the_way(dir) { + Some((p, true)) => Err(format!("{} is not a folder: {e}", p.display())), + _ => Ok(vec![]), + }; + } + Err(e) => return Err(Self::folder_error(dir, &e, false)), }; Ok(entries .filter_map(|e| e.ok()) @@ -333,7 +378,7 @@ impl FolderStore { match e.kind() { std::io::ErrorKind::NotFound => Ok(Lock::Busy), std::io::ErrorKind::PermissionDenied if std::fs::symlink_metadata(path).is_ok() => Ok(Lock::Busy), - _ => Err(format!("{}: {e}", path.display())), + _ => Err(Self::file_error(path, &e)), } } @@ -345,7 +390,7 @@ impl FolderStore { Ok(()) | Err(std::fs::TryLockError::WouldBlock) => Ok(Lock::Busy), // The lock file was removed meanwhile, and locking it failed instead. Err(std::fs::TryLockError::Error(_)) if !Self::still_named(&file, path) => Ok(Lock::Busy), - Err(std::fs::TryLockError::Error(e)) => Err(format!("{}: {e}", path.display())), + Err(std::fs::TryLockError::Error(e)) => Err(Self::file_error(path, &e)), } } @@ -393,10 +438,10 @@ impl RecoveryStore for FolderStore { if let Some(dir) = f.parent() { Self::own_area(dir)?; } - if !std::fs::symlink_metadata(&f).map_err(|e| format!("{}: {e}", f.display()))?.file_type().is_file() { + if !std::fs::symlink_metadata(&f).map_err(|e| Self::file_error(&f, &e))?.file_type().is_file() { return Err(format!("{}: not a plain file", f.display())); } - std::fs::read(&f).map_err(|e| format!("{}: {e}", f.display())) + std::fs::read(&f).map_err(|e| Self::file_error(&f, &e)) } fn write(&self, name: &str, bytes: &[u8]) -> std::result::Result<(), String> { let f = self.file(name)?; @@ -404,12 +449,12 @@ impl RecoveryStore for FolderStore { Self::create_area(dir)?; Self::own_area(dir)?; } - vectorcraft_format::write_atomic(&f, bytes).map_err(|e| format!("{}: {e}", f.display())) + vectorcraft_format::write_atomic(&f, bytes).map_err(|e| Self::file_error(&f, &e)) } fn remove(&self, name: &str) -> std::result::Result<(), String> { let f = self.file(name)?; match std::fs::remove_file(&f) { - Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(format!("{}: {e}", f.display())), + Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(Self::file_error(&f, &e)), _ => Ok(()), } } diff --git a/crates/engine/src/tests/recovery.rs b/crates/engine/src/tests/recovery.rs index f0b6a0708c..2b991e3320 100644 --- a/crates/engine/src/tests/recovery.rs +++ b/crates/engine/src/tests/recovery.rs @@ -483,6 +483,87 @@ fn the_folder_store_reads_no_linked_entries() { let _ = std::fs::remove_dir_all(&base); } +/// A file where a folder of the store's path should be: the error names that file, not the area +/// folder or the copy below it, for a write, a lock, a read, a listing, a removal and a recovery +/// save. +#[cfg(unix)] +#[test] +fn folder_errors_name_a_file_in_the_way() { + let base = std::env::temp_dir().join(format!("vc-recovery-file-in-way-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(&base).unwrap(); + let file = base.join("file"); + std::fs::write(&file, b"").unwrap(); + let folder = file.join("recovery"); + let store = FolderStore::new(&folder); + let mut s = Session::new(); + s.prefs.recovery_folder = folder.to_string_lossy().to_string(); + s.execute("file.new", &json!({"width": 50, "height": 50})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap(); + let errors = [ + ("write", store.write("1-1/Untitled-1-1.vectorcraft", b"copy").err()), + ("lock", store.lock("1-2").err()), + ("read", store.read("1-1/Untitled-1-1.vectorcraft").err()), + ("list", store.list().err()), + ("remove", store.remove("1-1/Untitled-1-1.vectorcraft").err()), + ("save", s.execute("file.recovery.save", &json!({})).err().map(|e| e.to_string())), + ]; + let _ = std::fs::remove_dir_all(&base); + let named = format!("{} is not a folder: ", file.display()); + let wrong: Vec<_> = errors.iter().filter(|(_, e)| !e.as_deref().is_some_and(|e| e.contains(&named))).collect(); + assert!(wrong.is_empty(), "{wrong:?}"); +} + +/// A folder of the store's path that is missing and can't be made: a write or a lock names it as +/// the folder it couldn't make, and a read names it as missing. An area folder that can't be made +/// in a store folder that is there is named the same way. +#[cfg(unix)] +#[test] +fn folder_errors_name_a_missing_folder_that_cannot_be_made() { + use std::os::unix::fs::PermissionsExt as _; + let base = std::env::temp_dir().join(format!("vc-recovery-missing-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let closed = base.join("closed"); + std::fs::create_dir_all(&closed).unwrap(); + std::fs::set_permissions(&closed, std::fs::Permissions::from_mode(0o500)).unwrap(); + let gone = closed.join("gone"); + let store = FolderStore::new(gone.join("recovery")); + let unmade = format!("couldn't make {}: ", gone.display()); + let area = FolderStore::new(&closed).write("1-1/Untitled-1-1.vectorcraft", b"copy").err(); + let errors = [ + ("write", store.write("1-1/Untitled-1-1.vectorcraft", b"copy").err(), unmade.clone()), + ("lock", store.lock("1-2").err(), unmade), + ("read", store.read("1-1/Untitled-1-1.vectorcraft").err(), format!("{} is missing", gone.display())), + ("area", area, format!("couldn't make {}: ", closed.join("1-1").display())), + ]; + std::fs::set_permissions(&closed, std::fs::Permissions::from_mode(0o700)).unwrap(); + let _ = std::fs::remove_dir_all(&base); + // An account that can write anywhere (root) makes the folders, so the test has nothing to check. + if errors[0].1.is_none() { + return; + } + let wrong: Vec<_> = errors.iter().filter(|(_, e, named)| !e.as_deref().is_some_and(|e| e.starts_with(named.as_str()))).collect(); + assert!(wrong.is_empty(), "{wrong:?}"); +} + +/// When every folder on the way is there, an error names the folder it happened on. +#[cfg(unix)] +#[test] +fn folder_errors_name_the_folder_when_every_folder_is_there() { + use std::os::unix::fs::PermissionsExt as _; + let base = std::env::temp_dir().join(format!("vc-recovery-unreadable-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let unreadable = base.join("unreadable"); + std::fs::create_dir_all(&unreadable).unwrap(); + std::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o300)).unwrap(); + let listed = FolderStore::new(&unreadable).list(); + std::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o700)).unwrap(); + let _ = std::fs::remove_dir_all(&base); + // An account that can read any folder (root) lists it, so the test has nothing to check. + let Err(err) = listed else { return }; + assert!(err.starts_with(&format!("{}: ", unreadable.display())), "{err}"); +} + /// A store in memory that notes each removal of an area (its lock file and folder, then its empty /// folder) and whether the area was still held then. #[derive(Default)] From 00624da7ad610cc64080cab91e3f8fa03b4b5a3d Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 22:13:41 -0800 Subject: [PATCH 29/43] MCP: preferences replies leave out folders outside the read roots Under --automation-read-root, prefs.get, prefs.list, prefs.set and prefs.reset returned the folder preferences (fontsFolder, pluginsFolder, scratchPrimary, scratchSecondary, recoveryFolder, templatesFolder) and the export presets' folder even when they were outside the read roots. Such a folder preference is now null, as ui.inspect leaves it out, and such a preset's folder is left out. An empty folder and the scratch disk choices stay; without roots the replies are unchanged. --- crates/engine/src/cmd/prefscmds.rs | 46 +++++++++-- crates/engine/src/tests.rs | 1 + crates/engine/src/tests/prefs_roots.rs | 107 +++++++++++++++++++++++++ docs/mcp.md | 5 ++ docs/target-app-parity.md | 1 + 5 files changed, 154 insertions(+), 6 deletions(-) create mode 100644 crates/engine/src/tests/prefs_roots.rs diff --git a/crates/engine/src/cmd/prefscmds.rs b/crates/engine/src/cmd/prefscmds.rs index a92b4095a9..014e825684 100644 --- a/crates/engine/src/cmd/prefscmds.rs +++ b/crates/engine/src/cmd/prefscmds.rs @@ -765,7 +765,7 @@ impl Session { pub fn specs() -> Vec<CommandSpec> { vec![ - cmd!(query "prefs.get", "Get Preferences", [], None, "{key?} → the value of one preference (or preference group: eyedropper), or {prefs: {…all…}}", always, get), + cmd!(query "prefs.get", "Get Preferences", [], None, "{key?} → the value of one preference (or preference group: eyedropper), or {prefs: {…all…}}; under automation read roots a folder preference outside them is null, and an export preset's folder outside them is left out", always, get), cmd!( query "prefs.set", "Set Preference", @@ -776,7 +776,7 @@ pub fn specs() -> Vec<CommandSpec> { set ), cmd!(query "prefs.reset", "Reset Preferences", [], None, "{category?} reset one category (or everything) to defaults", always, reset), - cmd!(query "prefs.list", "List Preferences", [], None, "{} → [{key, category, section, label, kind, value, …}] every preference", always, list), + cmd!(query "prefs.list", "List Preferences", [], None, "{} → [{key, category, section, label, kind, value, …}] every preference; under automation read roots a folder preference's value outside them is null", always, list), cmd!( query "prefs.export", "Export Preferences", @@ -882,8 +882,42 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> { Ok(json!({ "imported": values.len() })) } +/// The preferences that name a folder: the [`FOLDER_PREFS`] and the scratch disks. +const PATH_PREFS: [&str; 6] = ["fontsFolder", "pluginsFolder", "scratchPrimary", "scratchSecondary", "recoveryFolder", "templatesFolder"]; + +/// The scratch disk choices that are not folders. +const SCRATCH_CHOICES: [&str; 2] = ["Startup", "None"]; + +/// The preferences as `prefs.get`, `prefs.list`, `prefs.set` and `prefs.reset` show them to the +/// request being served. Under automation read roots, a folder preference ([`PATH_PREFS`]) outside +/// them is `null`, as `ui.inspect` leaves it out, and an export preset's `folder` outside them is +/// left out. An empty value (the default folder) and a scratch +/// disk choice ([`SCRATCH_CHOICES`]) are shown. Without roots the preferences are shown as they are. +fn shown_prefs(prefs: &Prefs) -> Value { + let mut all = prefs.to_json(); + if crate::file_access::current().is_none() { + return all; + } + // The app trims a folder before it uses it. + let hidden = |path: &str| !path.trim().is_empty() && crate::file_access::check_read(path.trim()).is_err(); + for key in PATH_PREFS { + let Some(path) = all[key].as_str() else { continue }; + if hidden(path) && !(key.starts_with("scratch") && SCRATCH_CHOICES.contains(&path.trim())) { + all[key] = Value::Null; + } + } + if let Some(presets) = all.get_mut("screenExportPresets").and_then(Value::as_array_mut) { + for settings in presets.iter_mut().filter_map(|p| p.get_mut("settings").and_then(Value::as_object_mut)) { + if settings.get("folder").and_then(Value::as_str).is_some_and(hidden) { + settings.remove("folder"); + } + } + } + all +} + fn get(s: &mut Session, p: &Value) -> Result<Value> { - let all = s.prefs.to_json(); + let all = shown_prefs(&s.prefs); match str_param(p, "key") { Some(k) if spec(k).is_some() || PREF_GROUPS.contains(&k) => Ok(all[k].clone()), Some(k) => Err(bad("prefs.get", format!("unknown preference `{k}`"))), @@ -911,7 +945,7 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> { if document_units && values.contains_key("unitsGeneral") && s.active().is_some() { s.set_document_units(s.default_units())?; } - let all = s.prefs.to_json(); + let all = shown_prefs(&s.prefs); Ok(Value::Object(values.keys().map(|k| (k.clone(), all[k.as_str()].clone())).collect())) } @@ -925,11 +959,11 @@ fn reset(s: &mut Session, p: &Value) -> Result<Value> { let mut next = s.prefs.clone(); next.reset(cat); s.apply_prefs(next); - Ok(json!({ "prefs": s.prefs.to_json() })) + Ok(json!({ "prefs": shown_prefs(&s.prefs) })) } fn list(s: &mut Session, _: &Value) -> Result<Value> { - let all = s.prefs.to_json(); + let all = shown_prefs(&s.prefs); Ok(Value::Array( PREF_SPECS .iter() diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f5bbd54220..67a7bc308d 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -133,6 +133,7 @@ mod placed_document; mod plugins; mod prefs; mod prefs_file; +mod prefs_roots; mod previewbounds; mod print; mod printadvanced; diff --git a/crates/engine/src/tests/prefs_roots.rs b/crates/engine/src/tests/prefs_roots.rs new file mode 100644 index 0000000000..b9818949f1 --- /dev/null +++ b/crates/engine/src/tests/prefs_roots.rs @@ -0,0 +1,107 @@ +//! The preferences commands under automation roots: a confined request sees a folder preference and +//! an export preset's folder only inside its read roots. + +use std::path::{Path, PathBuf}; + +use serde_json::{Value, json}; + +use crate::cmd::fileio::ScreenExportPreset; +use crate::file_access::{AutomationRoots, confine}; +use crate::*; + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}")) +} + +fn text(p: &Path) -> String { + p.to_string_lossy().to_string() +} + +/// A fresh scratch folder for one test, with the folders `in` and `out`. +fn folder(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("vc-prefs-roots-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + for sub in ["in", "out"] { + std::fs::create_dir_all(dir.join(sub)).unwrap(); + } + dir +} + +/// The folder preferences that name the folder outside the read root. +const OUTSIDE: [&str; 3] = ["pluginsFolder", "scratchPrimary", "templatesFolder"]; + +/// A session whose folder preferences name `inside` (fonts, and the Data Recovery folder with +/// spaces around it) and `outside` (the [`OUTSIDE`] ones, the Templates folder with spaces around +/// it), with a scratch disk choice and an export preset kept with each folder. +fn session(inside: &str, outside: &str) -> Session { + let mut s = Session::new(); + s.prefs.fonts_folder = inside.to_string(); + s.prefs.recovery_folder = format!(" {inside} "); + s.prefs.plugins_folder = outside.to_string(); + s.prefs.scratch_primary = outside.to_string(); + s.prefs.scratch_secondary = "None".to_string(); + s.prefs.templates_folder = format!(" {outside} "); + for (name, folder) in [("Inside", inside), ("Outside", outside)] { + let settings = json!({"formats": [{"format": "svg"}], "folder": folder}); + s.prefs.screen_export_presets.push(ScreenExportPreset { name: name.into(), settings: settings.as_object().cloned().unwrap_or_default() }); + } + s +} + +/// Does a string anywhere in `v` hold `path`? +fn holds(v: &Value, path: &str) -> bool { + v.to_string().contains(&path.replace('\\', "\\\\")) +} + +#[test] +fn prefs_commands_leave_out_folders_outside_the_read_root() { + let base = folder("get"); + let (inside, outside) = (text(&base.join("in").join("fonts")), text(&base.join("out"))); + let roots = AutomationRoots::new(&[&base.join("in")], &[]).unwrap(); + let mut s = session(&inside, &outside); + + let all = confine(roots.as_ref(), || run(&mut s, "prefs.get", json!({}))); + let prefs = &all["prefs"]; + assert_eq!(prefs["fontsFolder"], json!(inside), "a folder inside the root is shown"); + assert_eq!(prefs["recoveryFolder"], json!(format!(" {inside} ")), "checked without the spaces, shown as kept"); + assert_eq!(prefs["scratchSecondary"], json!("None"), "a scratch disk choice is shown"); + for key in OUTSIDE { + assert_eq!(prefs[key], Value::Null, "{key}: {all}"); + } + let presets = prefs["screenExportPresets"].as_array().unwrap(); + assert_eq!(presets[0]["settings"]["folder"], json!(inside)); + assert_eq!(presets[1]["name"], json!("Outside"), "the preset stays"); + assert!(presets[1]["settings"].get("folder").is_none(), "{all}"); + assert!(!holds(&all, &outside), "{all}"); + + for key in OUTSIDE { + let one = confine(roots.as_ref(), || run(&mut s, "prefs.get", json!({"key": key}))); + assert_eq!(one, Value::Null, "{key}"); + } + assert_eq!(confine(roots.as_ref(), || run(&mut s, "prefs.get", json!({"key": "fontsFolder"}))), json!(inside)); + + let list = confine(roots.as_ref(), || run(&mut s, "prefs.list", json!({}))); + assert!(!holds(&list, &outside), "{list}"); + let row = |key: &str| list.as_array().unwrap().iter().find(|r| r["key"] == key).cloned().unwrap(); + assert_eq!(row("fontsFolder")["value"], json!(inside)); + assert_eq!(row("pluginsFolder")["value"], Value::Null); + assert_eq!(row("pluginsFolder")["kind"], json!("text"), "the row stays"); + + // A request that sets another preference, or resets a category without folders. + let set = confine(roots.as_ref(), || run(&mut s, "prefs.set", json!({"values": {"keyboardIncrement": 2, "pluginsFolder": outside}}))); + assert_eq!(set, json!({"keyboardIncrement": 2.0, "pluginsFolder": null}), "a folder kept as it was is left out too"); + let reset = confine(roots.as_ref(), || run(&mut s, "prefs.reset", json!({"category": "General"}))); + assert!(!holds(&reset, &outside), "{reset}"); + assert_eq!(reset["prefs"]["fontsFolder"], json!(inside)); + + // Unconfined and with only a write root. + let all = run(&mut s, "prefs.get", json!({})); + assert_eq!(all["prefs"]["pluginsFolder"], json!(outside), "unconfined, the folder is shown"); + assert_eq!(all["prefs"]["screenExportPresets"][1]["settings"]["folder"], json!(outside)); + let write_only = AutomationRoots::new(&[], &[&base.join("in")]).unwrap(); + let all = confine(write_only.as_ref(), || run(&mut s, "prefs.get", json!({}))); + assert_eq!(all["prefs"]["fontsFolder"], Value::Null, "without a read root no folder is shown"); + assert_eq!(all["prefs"]["recoveryFolder"], Value::Null); + assert_eq!(s.prefs.plugins_folder, outside, "the preferences are unchanged"); + let _ = std::fs::remove_dir_all(&base); +} diff --git a/docs/mcp.md b/docs/mcp.md index 7f6df79837..961d56b053 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -153,6 +153,11 @@ the suite) keep the agent to the folders you give it: and `modified` the document keeps of that file. Each row keeps its id, `name` (`fileName`), status and other fields. This applies to every document, also one the person or a client with wider roots opened. The paths a client gives `links.relink` and `links.unembed` stay in their replies, and the document's links are not changed. +- `prefs.get`, `prefs.list`, `prefs.set` and `prefs.reset` show a folder preference outside the read roots + (`fontsFolder`, `pluginsFolder`, `scratchPrimary`, `scratchSecondary`, `recoveryFolder`, `templatesFolder`) as + `null`; an empty folder and the scratch disk choices `Startup` and `None` stay. In `prefs.get {}` and + `prefs.reset`, an export preset in `screenExportPresets` leaves out its `folder` when the folder is outside the + read roots, and the preset stays. The preferences themselves are not changed. - 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 diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 908895ad64..5fbe4157f0 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -753,6 +753,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - `ui.inspect` under `--automation-read-root`: the documents still opening, the recent files, library panels' files and folder preferences (saved, and in an open Preferences dialog) outside the read roots are left out of the reply, a library loaded from outside them shows by its file name in `ui.inspect` and `ui.docking`, and the app's state is not changed. `crates/ui-egui/src/tests/automation.rs` (`ui_inspect_leaves_out_paths_outside_the_read_root`). - Link replies under `--automation-read-root`: `links.list`, `links.check`, `links.info` and `document.open`'s link rows leave out the path, found path and folder of a linked file outside the read roots, and the stored size and date of it in `links.info`; each row keeps its id, file name and status, and the document's links are not changed. `crates/engine/src/tests/linkspanel.rs` (`link_paths_outside_the_read_roots_are_left_out`), `crates/engine/src/tests/placed_document.rs` (`link_info_leaves_out_a_placed_documents_path_outside_the_read_roots`). - Swatch, brush, symbol and graphic style library lists under `--automation-read-root`: library files and the user library folder outside the read roots are left out, a library loaded from outside them is listed and found by its file name, and the app's own list is not changed. `crates/engine/src/tests/library_roots.rs`. + - Preferences commands under `--automation-read-root`: `prefs.get`, `prefs.list`, `prefs.set` and `prefs.reset` show folder preferences outside the read roots as `null` and leave out an export preset's folder outside them, and the preferences are not changed. `crates/engine/src/tests/prefs_roots.rs`. - Scripts: `alert`, `confirm` and `prompt` open dialogs during a run, each with a Stop button that ends the run and puts every document back. In the app the run waits at the call for the person's answer; a run over MCP or the command line takes its answers in order from `script.run`'s `answers`, and the report lists each dialog with its answer. `crates/engine/src/tests/script_dialogs.rs`, `crates/ui-egui/src/tests/script_dialogs.rs`. - Parameter keys: the engine refuses a key that a command's params description doesn't name before the command runs, in every build and for every caller, including the app's own calls, the commands a command runs and the steps of `command.batch` and `action.batch`. An action recorded by an older version still plays from the Actions panel and the Batch dialog (`Session::replay`). `crates/engine/tests/it/param_keys.rs`. - MCP server with 33 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, undo/redo, UI inspect/screenshot aliases, any command), attached to the running app or headless. Headless and remote `command_list` include desktop `UI_COMMANDS` (`ui: true`); a UI-only id still needs `--connect` to run. Eight prompts (poster, icon-set, recolor, trace-and-style, export-set, check-spelling, variables-bind, path-tools). Effect ids are not command ids. Prefer a CLI built from the checkout when a release binary lags (#6460). `vectorcraft://commands/reference` serves the API reference. Tool descriptions and replies agree with the engine: format lists come from its format table, `save_file` says it keeps the document's own format, `inspect_document` reports the paint type's characters show, and selection exports report the encoder's warnings. `--automation-read-root` / `--automation-write-root` (on `vectorcraft-cli mcp` and on `vectorcraft --control`) confine the files commands read and write to chosen folders, links followed, with the flags PhotoCraft takes (#832); each flag may be given more than once, a path inside any of its folders being allowed (#3546, `file_access::tests::several_roots_of_a_kind_each_grant_their_access`, `cli::mcp_automation_roots_take_several_folders`). A long export can be cancelled (`notifications/cancelled` stops it at the next page or group and writes nothing), reports page progress to a client that sent a progress token, and does not make other requests wait: engine-free ones answer at once, the rest are refused as busy after two seconds (#2165, #2171, #2172). From 08be14ec3cbe27dbf8e5a94c57aa969728eed08b Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 22:14:35 -0800 Subject: [PATCH 30/43] Docs: merge the two DXF entries in gaps.md into one The older entry said a non-circular ellipse keeps the fallback; R13 and later write it as a native ELLIPSE and R12 keeps the fallback. --- docs/gaps.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/gaps.md b/docs/gaps.md index 3082873bb4..e6d171cfe8 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -323,8 +323,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi - **Done:** Numeric fields keep a text drag's start when the pointer gains focus and movement exceeds the click threshold (#1326, in part). A completed focus click selects the whole value; keyboard focus, double-click and numeric stepping remain regression-tested. Short movements still classified as clicks can select all; that case remains open. -- **Done:** DXF reads control-point SPLINE closure (#1299 follow-up after #1320); open curves keep their open state. DXF export now recognizes circular cubic paths conservatively and writes native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE, and a non-circular ellipse keeps the existing fallback. Alter Paths for Appearance outlines only the strokes a CAD line can't draw (aligned, profiled, offset dashes, wider than 2.11 mm, or in R13 and R14), so a 1 pt circle or arc stays a CIRCLE or ARC. DXF Options' Preset menu and `document.exportDxf`'s `dxfPreset: "cnc"` set the CNC / CAM options (#5500); VCarve QA remains open. Escape leaves Artboard editing for Selection; during a drag it first cancels the gesture. Headless egui key events cover idle Escape, active-drag cancellation and a subsequent Escape in `tests/distortkeys.rs`. -- **Done:** DXF reads control-point SPLINE closure (#1299 follow-up after #1320); open curves keep their open state. DXF export now recognizes circular cubic paths conservatively and writes native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE. A closed four-cubic ellipse, or a circle scaled out of proportion, is written as a native ELLIPSE in R13 and later; R12 keeps the existing fallback. DXF Options' Preset menu and `document.exportDxf`'s `dxfPreset: "cnc"` set the CNC / CAM options (#5500); VCarve QA remains open. Escape leaves Artboard editing for Selection; during a drag it first cancels the gesture. Headless egui key events cover idle Escape, active-drag cancellation and a subsequent Escape in `tests/distortkeys.rs`. +- **Done:** DXF reads control-point SPLINE closure (#1299 follow-up after #1320); open curves keep their open state. DXF export now recognizes circular cubic paths conservatively and writes native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE. A closed four-cubic ellipse, or a circle scaled out of proportion, is written as a native ELLIPSE in R13 and later; R12 keeps the existing fallback. Alter Paths for Appearance outlines only the strokes a CAD line can't draw (aligned, profiled, offset dashes, wider than 2.11 mm, or in R13 and R14), so a 1 pt circle or arc stays a CIRCLE or ARC. DXF Options' Preset menu and `document.exportDxf`'s `dxfPreset: "cnc"` set the CNC / CAM options (#5500); VCarve QA remains open. Escape leaves Artboard editing for Selection; during a drag it first cancels the gesture. Headless egui key events cover idle Escape, active-drag cancellation and a subsequent Escape in `tests/distortkeys.rs`. - **Done:** Simplify and Add Anchor Points clear stale component selections on rewritten paths; Simplify previews and reports original/new point counts (#1272). - **Done:** Object › Path › Simplify opens its slider at the Auto-Simplify level for the selection and previews that result, as the [manual-simplify page](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/manually-simplify-paths.html) describes; simplifying only a Direct Selection part of a path is done too (G1). From 023d2c6c396368b9873345df8fb3025680938e41 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 22:26:03 -0800 Subject: [PATCH 31/43] Plug-ins: ui.installPlugin's description says it answers null when the web picker opens --- crates/mcp/src/ui_catalog.rs | 2 +- crates/ui-egui/src/menus/ui_commands.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index 950adca0c8..7a8fd89cbc 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -1523,7 +1523,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.installPlugin", "Install Plug-in…", "", - "{path?: a .wasm file | dataBase64?: the module, name?: the file name shown as its source (with dataBase64), replace?: bool (true: the user agreed it takes the place of the plug-in with its id, as plugin.install), hash?: the module's SHA-256 (as plugin.install)} install a WebAssembly plug-in (plugin.install); without a path pick one (the web opens the browser's file picker; File › Open of a .wasm installs it too). A plug-in that lists permissions the user hasn't granted it asks the user first, showing its name, id, version, file SHA-256 and what each permission lets it do, so does a filter that asks for a heavy budget, and so does one without replace that would take the place of another module with its id and get its settings and cache (needsReplace in plugin.inspect), showing its id and file SHA-256: OK installs it if the file still has the SHA-256 shown, Cancel installs nothing. Only a person can press OK: ui.dialog.confirm, answer_dialog and synthetic input are refused. When automation asked (a control or MCP request, synthetic input, or a file dialog one of them opened, even when the person picked the file), replace: true doesn't skip the question about replacing, and nothing in the dialog installs it, the person's OK included: the person chooses Object › Plug-ins › Install Plug-in… to install it → plugin.install's result | {pending: \"pluginInstall\"} while it asks | {pending: \"pluginInstall\", approval: \"personMustStartInstall\"} while it asks what automation asked", + "{path?: a .wasm file | dataBase64?: the module, name?: the file name shown as its source (with dataBase64), replace?: bool (true: the user agreed it takes the place of the plug-in with its id, as plugin.install), hash?: the module's SHA-256 (as plugin.install)} install a WebAssembly plug-in (plugin.install); without a path pick one (the web opens the browser's file picker; File › Open of a .wasm installs it too). A plug-in that lists permissions the user hasn't granted it asks the user first, showing its name, id, version, file SHA-256 and what each permission lets it do, so does a filter that asks for a heavy budget, and so does one without replace that would take the place of another module with its id and get its settings and cache (needsReplace in plugin.inspect), showing its id and file SHA-256: OK installs it if the file still has the SHA-256 shown, Cancel installs nothing. Only a person can press OK: ui.dialog.confirm, answer_dialog and synthetic input are refused. When automation asked (a control or MCP request, synthetic input, or a file dialog one of them opened, even when the person picked the file), replace: true doesn't skip the question about replacing, and nothing in the dialog installs it, the person's OK included: the person chooses Object › Plug-ins › Install Plug-in… to install it → plugin.install's result | {pending: \"pluginInstall\"} while it asks | {pending: \"pluginInstall\", approval: \"personMustStartInstall\"} while it asks what automation asked | null on the web without path or dataBase64, once the browser's file picker opens (the picked file is handled as above)", ), ( "plugin.cancelRuns", diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 489567bf38..cc35f01bb0 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -1524,7 +1524,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.installPlugin", "Install Plug-in…", "", - "{path?: a .wasm file | dataBase64?: the module, name?: the file name shown as its source (with dataBase64), replace?: bool (true: the user agreed it takes the place of the plug-in with its id, as plugin.install), hash?: the module's SHA-256 (as plugin.install)} install a WebAssembly plug-in (plugin.install); without a path pick one (the web opens the browser's file picker; File › Open of a .wasm installs it too). A plug-in that lists permissions the user hasn't granted it asks the user first, showing its name, id, version, file SHA-256 and what each permission lets it do, so does a filter that asks for a heavy budget, and so does one without replace that would take the place of another module with its id and get its settings and cache (needsReplace in plugin.inspect), showing its id and file SHA-256: OK installs it if the file still has the SHA-256 shown, Cancel installs nothing. Only a person can press OK: ui.dialog.confirm, answer_dialog and synthetic input are refused. When automation asked (a control or MCP request, synthetic input, or a file dialog one of them opened, even when the person picked the file), replace: true doesn't skip the question about replacing, and nothing in the dialog installs it, the person's OK included: the person chooses Object › Plug-ins › Install Plug-in… to install it → plugin.install's result | {pending: \"pluginInstall\"} while it asks | {pending: \"pluginInstall\", approval: \"personMustStartInstall\"} while it asks what automation asked", + "{path?: a .wasm file | dataBase64?: the module, name?: the file name shown as its source (with dataBase64), replace?: bool (true: the user agreed it takes the place of the plug-in with its id, as plugin.install), hash?: the module's SHA-256 (as plugin.install)} install a WebAssembly plug-in (plugin.install); without a path pick one (the web opens the browser's file picker; File › Open of a .wasm installs it too). A plug-in that lists permissions the user hasn't granted it asks the user first, showing its name, id, version, file SHA-256 and what each permission lets it do, so does a filter that asks for a heavy budget, and so does one without replace that would take the place of another module with its id and get its settings and cache (needsReplace in plugin.inspect), showing its id and file SHA-256: OK installs it if the file still has the SHA-256 shown, Cancel installs nothing. Only a person can press OK: ui.dialog.confirm, answer_dialog and synthetic input are refused. When automation asked (a control or MCP request, synthetic input, or a file dialog one of them opened, even when the person picked the file), replace: true doesn't skip the question about replacing, and nothing in the dialog installs it, the person's OK included: the person chooses Object › Plug-ins › Install Plug-in… to install it → plugin.install's result | {pending: \"pluginInstall\"} while it asks | {pending: \"pluginInstall\", approval: \"personMustStartInstall\"} while it asks what automation asked | null on the web without path or dataBase64, once the browser's file picker opens (the picked file is handled as above)", ), ( "plugin.cancelRuns", From 949553b6ce6d26abe53586949c45dc737a061f37 Mon Sep 17 00:00:00 2001 From: Dan Green <danngreen1@gmail.com> Date: Wed, 11 Nov 2026 07:19:39 -0800 Subject: [PATCH 32/43] Rulers: the origin dragged out of the rulers' corner snaps as a drawn point does The zero point dragged out of the box where the rulers meet snaps as a Pen or shape tool's point does: with Smart Guides onto the anchors, centres, edges and paths of the art and the artboards or into line with them, with Snap to Point onto the anchors and along the ruler guides, else onto whole pixels or the grid. The guides showing what it snapped to show during the drag and go when it is released or cancelled (Escape, focus loss, another document). The origin is still set once, on release (document.setRulerOrigin, one undo step). The engine snaps it (Session::ruler_origin_drag, with the tools crate's rulerguide::OriginDrag over PointSnap::drawn), as it does a guide dragged out of a ruler. --- crates/engine/src/lib.rs | 2 ++ crates/engine/src/session/documents.rs | 1 + crates/engine/src/tests/menucmds.rs | 35 ++++++++++++++++++++++ crates/engine/src/tooling.rs | 24 +++++++++++++++ crates/tools/src/guides/snap.rs | 7 +++++ crates/tools/src/rulerguide.rs | 38 ++++++++++++++++++++++-- crates/ui-egui/src/canvas/grid.rs | 17 +++++++++-- crates/ui-egui/src/tests/ruler_origin.rs | 22 ++++++++++++++ docs/target-app-parity.md | 5 ++-- docs/ui-parity.md | 5 ++-- 10 files changed, 147 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f1f188f00c..a54e7e9e44 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -306,6 +306,8 @@ pub struct Session { pub(crate) press_point: Option<Option<(usize, cmd::gradient::StopOwner)>>, /// A guide being dragged out of a ruler ([`Session::ruler_guide`]). pub(crate) ruler_guide: Option<vectorcraft_tools::rulerguide::NewGuide>, + /// The rulers' origin being dragged out of their corner ([`Session::ruler_origin_drag`]). + pub(crate) ruler_origin: Option<vectorcraft_tools::rulerguide::OriginDrag>, /// Which selected type overflows its frame, for the ports every tool but the Type tools shows. pub(crate) port_marks: vectorcraft_tools::text::PortMarks, /// The last search for the files of missing fonts (`text.findFontFiles`), until another diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index 83a0367751..2005ffd2aa 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -124,6 +124,7 @@ impl Session { press_type_selection: None, press_point: None, ruler_guide: None, + ruler_origin: None, port_marks: Default::default(), font_search: None, link_search: None, diff --git a/crates/engine/src/tests/menucmds.rs b/crates/engine/src/tests/menucmds.rs index 28cb0a1590..44a0ddde1b 100644 --- a/crates/engine/src/tests/menucmds.rs +++ b/crates/engine/src/tests/menucmds.rs @@ -4489,6 +4489,41 @@ fn cmd_ruler_drags_make_guides_of_the_active_artboard_when_the_preference_is_on( assert_eq!(owners(&mut s)[4..], [Value::Null, json!(1)]); } +/// The rulers' origin dragged out of their corner snaps as a drawn point does: onto a shape's +/// corner and edge, the artboard's centre, and with Snap to Point along a ruler guide; with +/// nothing on it stays at the pointer. The drag changes nothing, and its guides go when it ends. +#[test] +fn the_ruler_origin_dragged_from_the_corner_snaps_as_a_drawn_point() { + use vectorcraft_geom::Point; + let mut s = session(); + rect(&mut s, 100.0, 100.0, 100.0, 100.0); + s.execute("guide.add", &json!({"vertical": true, "pos": 300})).unwrap(); + let (n, doc) = (undo_len(&s), s.doc().unwrap().doc.clone()); + let v = ViewInfo::default(); + let at = |s: &mut Session, x, y| s.ruler_origin_drag(Some(Point::new(x, y)), v).unwrap(); + assert_eq!(at(&mut s, 101.5, 98.5), Point::new(100.0, 100.0), "the corner"); + assert!(!s.overlays(v).is_empty(), "the guide showing it"); + assert_eq!(at(&mut s, 130.0, 202.0), Point::new(130.0, 200.0), "the bottom edge"); + assert_eq!(at(&mut s, 398.0, 301.5), Point::new(400.0, 300.0), "the artboard's centre"); + assert_eq!(at(&mut s, 302.0, 50.0).x, 300.0, "along the ruler guide"); + assert_eq!(s.ruler_origin_drag(None, v), None, "off the canvas"); + assert!(s.overlays(v).is_empty()); + assert_eq!(at(&mut s, 101.5, 98.5), Point::new(100.0, 100.0)); + s.end_ruler_origin_drag(); + assert!(s.overlays(v).is_empty()); + assert_eq!((undo_len(&s), &*s.doc().unwrap().doc), (n, &*doc), "nothing changed"); + // Nothing on: it stays at the pointer. + let off = ViewInfo { smart_guides: false, snap_to_point: false, ..v }; + assert_eq!(s.ruler_origin_drag(Some(Point::new(101.5, 98.5)), off), Some(Point::new(101.5, 98.5))); + s.end_ruler_origin_drag(); + // Snap to Grid: onto a gridline crossing. + let grid = ViewInfo { snap_to_grid: true, ..v }; + let p = s.ruler_origin_drag(Some(Point::new(101.5, 98.5)), grid).unwrap(); + s.end_ruler_origin_drag(); + assert_eq!(p, Point::new(p.x.round(), p.y.round()), "{p:?}"); + assert_ne!(p, Point::new(101.5, 98.5)); +} + /// Guides saved before guides had layers are put on a layer when the document opens. #[test] fn guides_from_older_files_are_put_on_a_layer() { diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index b222f32107..9bc450f937 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -751,6 +751,27 @@ impl Session { self.apply_actions(acts).map(drop) } + /// The rulers' origin dragged out of their corner, whatever the tool, with the pointer at `p` in + /// document space (none off the canvas): where it goes, snapped as a drawn point is + /// ([`vectorcraft_tools::rulerguide::OriginDrag`]), the targets gathered on the drag's first + /// call. It changes nothing: the caller sets the origin where the button is released + /// (`document.setRulerOrigin`) and ends the drag ([`Session::end_ruler_origin_drag`]). + pub fn ruler_origin_drag(&mut self, p: Option<Point>, view: ViewInfo) -> Option<Point> { + let drag = match self.ruler_origin.take() { + Some(d) => Some(d), + None => self.with_tool_cx(view, |_, cx| Some(vectorcraft_tools::rulerguide::OriginDrag::new(cx))), + }; + let Some(mut drag) = drag else { return p }; + let at = self.with_tool_cx(view, |_, cx| drag.pointer(cx, p)); + self.ruler_origin = Some(drag); + at + } + + /// The drag of the rulers' origin ended, released or cancelled: its guides go. + pub fn end_ruler_origin_drag(&mut self) { + self.ruler_origin = None; + } + /// Time passed while the pointer button is held (`dt` seconds; see [`Tool::tick`]): the /// desktop app ticks every frame of a press, agents with `holdMs` on a pointer event. pub fn tool_tick(&mut self, dt: f64, view: ViewInfo) -> Result<Vec<UiRequest>> { @@ -1112,6 +1133,9 @@ impl Session { v.extend(self.with_tool_cx(view, |_, cx| g.overlays(cx))); self.ruler_guide = Some(g); } + if let Some(d) = &self.ruler_origin { + v.extend(d.overlays()); + } let deferred_preview = self.unapplied_interaction_preview(); if let Some(d) = self.active() { let w = self.prefs.perspective_widget; diff --git a/crates/tools/src/guides/snap.rs b/crates/tools/src/guides/snap.rs index 32941c5f71..6f0b50d4b5 100644 --- a/crates/tools/src/guides/snap.rs +++ b/crates/tools/src/guides/snap.rs @@ -119,6 +119,13 @@ impl PointSnap { Self { targets } } + /// Snapping for a point placed anywhere, as a drawing tool's ([`DrawSnap`]) is: the art in the + /// window as targets ([`Targets::for_drawing`]), live text as Snap to Glyph asks. + pub fn drawn(cx: &ToolContext) -> Self { + let glyphs = (cx.snap_to_glyph && cx.smart_guides && cx.alignment_guides).then_some(cx.glyph_snap); + Self::new(cx, || Targets::for_drawing(cx.doc, &[], visible(cx), glyphs)) + } + /// Where `p` goes, and the guides showing why. pub fn snap(&self, cx: &ToolContext, p: Point) -> (Point, Vec<Overlay>) { self.snap_from(cx, p, None) diff --git a/crates/tools/src/rulerguide.rs b/crates/tools/src/rulerguide.rs index 97d8bed289..a90770d3fb 100644 --- a/crates/tools/src/rulerguide.rs +++ b/crates/tools/src/rulerguide.rs @@ -9,13 +9,14 @@ //! ruler's ticks, else to whole pixels, the grid, with Smart Guides the art's and the artboards' //! edges, centres and anchors, or with Snap to Point the anchors. Dropped off the canvas onto its //! ruler, a moved guide is deleted. Hidden or locked guides (View › Guides), and guides on a hidden -//! or locked layer, can't be picked. +//! or locked layer, can't be picked. The rulers' origin, dragged out of their corner +//! ([`OriginDrag`]), snaps as a drawn point does. use serde_json::json; use vectorcraft_doc::{Guide, NodeId}; use vectorcraft_geom::{Point, Rect}; -use crate::guides::Targets; +use crate::guides::{PointSnap, Targets}; use crate::{Action, Cursor, Overlay, PointerEvent, PointerKind, ToolContext}; /// The ruler guide under `p`, by its index in `guide.list`: the nearest within the selection @@ -247,6 +248,39 @@ impl NewGuide { } } +/// The rulers' origin dragged out of the box where they meet, with any tool: it snaps as a drawn +/// point does ([`PointSnap::drawn`]), with Smart Guides onto the anchors, centres, edges and paths +/// of the art and the artboards or into line with them, with Snap to Point onto the anchors and +/// along the ruler guides, else onto whole pixels or the grid. The targets are gathered when the +/// drag begins. It changes nothing: the origin is set where the button is released. +pub struct OriginDrag { + snap: PointSnap, + /// The guides showing what the origin snapped to. + guides: Vec<Overlay>, +} + +impl OriginDrag { + pub fn new(cx: &ToolContext) -> Self { + Self { snap: PointSnap::drawn(cx), guides: vec![] } + } + + /// Where the origin goes with the pointer at `p` (none off the canvas). + pub fn pointer(&mut self, cx: &ToolContext, p: Option<Point>) -> Option<Point> { + let Some(p) = p else { + self.guides.clear(); + return None; + }; + let (at, guides) = self.snap.snap(cx, p); + self.guides = guides; + Some(at) + } + + /// The guides showing what the origin snapped to. + pub fn overlays(&self) -> Vec<Overlay> { + self.guides.clone() + } +} + /// A selection tool's ruler-guide handling: [`Self::press`] on a press, [`Self::pointer`] for /// the rest of the gesture. #[derive(Default)] diff --git a/crates/ui-egui/src/canvas/grid.rs b/crates/ui-egui/src/canvas/grid.rs index 7f000ee787..0f6fc7d912 100644 --- a/crates/ui-egui/src/canvas/grid.rs +++ b/crates/ui-egui/src/canvas/grid.rs @@ -181,8 +181,9 @@ pub(super) fn ruler_rects(full: egui::Rect) -> [egui::Rect; 3] { /// it makes none. A double-click on a ruler (both clicks on that ruler) makes a guide at the /// pointer, across the ruler's axis (a vertical one on the top ruler, a horizontal one on the /// left; swapped in a view turned 90°, where the rulers read the other document axis, and the -/// nearer axis in between), unsnapped, as one `guide.add` and so one undo step. The corner drags the rulers' origin or resets it on a -/// double-click. +/// nearer axis in between), unsnapped, as one `guide.add` and so one undo step. The corner drags +/// the rulers' origin, snapped as a drawn point is (`Session::ruler_origin_drag`), or resets it on +/// a double-click. pub(super) fn ruler_input(app: &mut VectorcraftApp, ui: &Ui, full: egui::Rect, canvas: egui::Rect, xf: &Xf) -> Option<Point> { let [top, left, corner] = ruler_rects(full); // Right-click on a ruler or the origin box: the document units, to swap between them as @@ -274,9 +275,18 @@ pub(super) fn ruler_input(app: &mut VectorcraftApp, ui: &Ui, full: egui::Rect, c let mut preview = None; if let Some((started_in, mut cancelled)) = ui.data(|d| d.get_temp::<(Option<u64>, bool)>(drag_id)) { cancelled |= started_in != uid || ui.input(|i| !i.focused || i.key_pressed(egui::Key::Escape)); - let at = corner.interact_pointer_pos().or_else(|| ui.input(|i| i.pointer.latest_pos())).filter(|p| canvas.contains(*p)).map(|p| xf.to_doc(p)); + let pointer = + corner.interact_pointer_pos().or_else(|| ui.input(|i| i.pointer.latest_pos())).filter(|p| canvas.contains(*p)).map(|p| xf.to_doc(p)); + // The origin snaps as a drawn point does (Smart Guides, Snap to Point, pixels, the grid). + let at = if cancelled { + app.session.end_ruler_origin_drag(); + None + } else { + app.session.ruler_origin_drag(pointer, app.view_info()) + }; if corner.drag_stopped_by(egui::PointerButton::Primary) { ui.data_mut(|d| d.remove::<(Option<u64>, bool)>(drag_id)); + app.session.end_ruler_origin_drag(); if !cancelled && let Some(at) = at && let Err(e) = app.run("document.setRulerOrigin", json!({"x": at.x, "y": at.y, "artboard": app.view().map_or(0, |v| v.artboard)})) @@ -285,6 +295,7 @@ pub(super) fn ruler_input(app: &mut VectorcraftApp, ui: &Ui, full: egui::Rect, c } } else if !ui.input(|i| i.pointer.primary_down()) { ui.data_mut(|d| d.remove::<(Option<u64>, bool)>(drag_id)); + app.session.end_ruler_origin_drag(); } else { ui.data_mut(|d| d.insert_temp(drag_id, (started_in, cancelled))); if !cancelled { diff --git a/crates/ui-egui/src/tests/ruler_origin.rs b/crates/ui-egui/src/tests/ruler_origin.rs index 365cc92ef7..55e20df8c6 100644 --- a/crates/ui-egui/src/tests/ruler_origin.rs +++ b/crates/ui-egui/src/tests/ruler_origin.rs @@ -88,6 +88,27 @@ fn ruler_origin_drag_uses_the_rotated_zoomed_view_and_commits_only_on_release() assert_eq!(app.ruler_origin(), origin); } +/// The origin snaps as a drawn point does: dragged next to a rectangle's corner, it is set on it. +#[test] +fn ruler_origin_drag_snaps_onto_the_art() { + let mut app = app(); + app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap(); + app.run("select.none", json!({})).unwrap(); + // The Hand tool shows no smart guides of its own as the pointer passes over the corner. + app.select_tool("hand"); + let ctx = context(); + frame(&mut app, &ctx, vec![], canvas::show); + let to = canvas::Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_screen(Point::new(100.4, 99.6)); + frame(&mut app, &ctx, vec![Event::PointerMoved(to)], canvas::show); + assert!(app.session.overlays(app.view_info()).is_empty()); + start_drag(&mut app, &ctx, to); + frame(&mut app, &ctx, vec![], canvas::show); + assert!(!app.session.overlays(app.view_info()).is_empty(), "the guide showing what it snapped to"); + frame(&mut app, &ctx, vec![press(to, false)], canvas::show); + assert_eq!(app.ruler_origin(), Point::new(100.0, 100.0)); + assert!(app.session.overlays(app.view_info()).is_empty()); +} + #[test] fn ruler_origin_double_click_resets_to_the_active_artboard() { let mut app = app(); @@ -146,6 +167,7 @@ fn ruler_origin_cancelled_drags_do_not_modify_either_document() { Cancel::Outside => pos2(4.0, 4.0), Cancel::Escape => { frame(&mut app, &ctx, vec![key(Key::Escape)], canvas::show); + assert!(app.session.overlays(app.view_info()).is_empty(), "the snapping guides go too"); to } Cancel::Focus => { diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9c58bcaa8c..bda39a8e86 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-12 · **Change:** minor (Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Envelopes: with Distort Appearance, type's strokes paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them, which changes how VectorCraft's own envelopes of stroked type draw (#6644; 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (Rulers: the zero point dragged out of the rulers' corner snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Envelopes: with Distort Appearance, type's strokes paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them, which changes how VectorCraft's own envelopes of stroked type draw (#6644; 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) > > Live Paint Bucket update (2026-11-12): one drag fills the faces crossed in the same group (#6373). > @@ -663,7 +663,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Stroke gradients where a variable-width stroke meets itself (#5960):** with a gradient along or across a stroke that has a width profile, where the stroke crosses itself the later part paints over the earlier one only where the two overlap, and a narrow part running close beside a wide one leaves its edge alone. Each slice of the gradient reaches as far as the stroke is wide where it sits (for a dashed stroke, as wide as it gets within the reach of a cap or dot, found in one pass along the path, so a densely flattened path costs no more per point), where it used to reach as far as the stroke is wide anywhere and paint a square of its colour over the wider part. Strokes without a profile are unchanged. Tests: `stroke::tests_gradient::a_narrow_pass_over_a_wide_one_paints_only_where_they_overlap`, `a_narrow_pass_beside_a_wide_one_leaves_its_edge_alone`, `a_densely_flattened_dashed_pass_beside_a_wide_one_leaves_its_edge_alone` and `widest_ahead_is_the_widest_within_the_window`, with the existing coverage tests (joins, caps, arrowheads, profiles, dashes, aligned strokes). - **Width strokes zoomed out (#995):** a stroke with a width profile keeps its shape while the view zooms out. The canvas keeps a hairline visible (at least half a device pixel); for a profiled stroke it is the profile's widest point that gets that minimum, not the weight, so a 1 pt stroke with a 6x width point is no longer drawn twice as thick at 25% zoom than at 100%. Plain strokes are unchanged. Test: `render::tests::widthzoom`. - **Ruler double-click (#5239):** a double-click (both clicks on that ruler) on the top ruler adds a vertical guide at the pointer's x and one on the left ruler a horizontal guide at its y (swapped in a view turned 90°, where the rulers read the other document axis; the nearer axis in between), through `guide.add` in one undo step; the guide is not snapped, is shown even when guides were hidden, and is an artboard guide with the Artboard tool, as a drag from a ruler makes one. The [public page on rulers](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/use-rulers.html) doesn't describe the gesture, so it follows the request in #5239. egui frame tests cover both rulers, a lone click, Undo, a canvas click before a ruler click, and rotated views. -- **Rulers (#1212):** drag the intersection to preview and set a document zero point, or double-click it to reset to the active artboard's top-left. Transform, Properties and Control X/Y fields measure from it and convert entered positions back to absolute command coordinates. The origin survives every native format version and compression; geometry, guides and patterns stay in place. Public reference: [Use rulers](https://helpx.adobe.com/ca/illustrator/desktop/measure-and-align/grids-and-guides/use-rulers.html). +- **Rulers (#1212):** drag the intersection to preview and set a document zero point, snapped as a drawn point is (with Smart Guides onto the art's and the artboards' anchors, centres, edges and paths or into line with them, with Snap to Point along the ruler guides, else to pixels or the grid), or double-click it to reset to the active artboard's top-left. Transform, Properties and Control X/Y fields measure from it and convert entered positions back to absolute command coordinates. The origin survives every native format version and compression; geometry, guides and patterns stay in place. Public reference: [Use rulers](https://helpx.adobe.com/ca/illustrator/desktop/measure-and-align/grids-and-guides/use-rulers.html). - **Global and artboard rulers:** View › Rulers › Change to Global / Artboard Rulers (Cmd+Alt+R, `view.globalRulers`). A new document has artboard rulers: the rulers and the Transform, Properties, Control and Info X/Y count from the active artboard's top-left corner, or the zero point dragged out for that artboard, which moves with it; with global rulers one document zero point serves every artboard. Guide readouts and Shift ruler-tick snapping follow the same zero point. The mode is saved with the document; files from before it open with global rulers, as they measured. Regression tests: `engine/tests/it/ruler_origin.rs`, `ui-egui/src/tests/ruler_origin.rs`, `tools/src/rulerguide.rs`. Public reference: [About rulers](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/about-rulers.html). - **Video display options (#2849):** Artboard Options' Show Video Safe Areas and Video Ruler Pixel Aspect Ratio, and View › Rulers › Show Video Rulers ([Set video display options for artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/set-video-display-options-for-artboards.html)). Each artboard has its own (`artboard.setProps`/`artboard.new` `showVideoSafeAreas`, `videoRulerPixelAspectRatio` 0.1–10, default 1), set in one undo step and written to the native format only when on or not 1, so other documents save byte for byte as before. The help page names the safe areas without numbers; our choice is [EBU R 95](https://tech.ebu.ch/docs/r/r095.pdf)'s (note 5): the canvas outlines the action-safe area 3.5% and the title-safe (graphics-safe) area 5% in from each edge, 1 point wide and turned with the view; exports and print leave them out. Show Video Rulers (`view.videoRulers`) shows the rulers counting the active artboard in pixels from its top-left corner, whatever the ruler origin: a pixel is as many points wide as the ratio and one point tall, so a 720 × 480 pt artboard at 0.9 reads 800 × 480. Hide Video Rulers returns them to the General unit; Shift ruler-tick snapping of new guides keeps the General unit. Regression tests: `tests/panelcmds.rs` (`artboard_video_display_options_*`, `artboard_display_marks_stay_out_of_exports`), `tests/docsetup.rs` (`an_artboard_shows_its_video_safe_areas`), `canvas.rs` (`video_rulers_count_the_artboard_in_pixels_of_its_aspect_ratio`), `tests/ruler_origin.rs` (`show_video_rulers_counts_the_active_artboard_in_pixels`), `dialogs/tests.rs` (`artboard_options_show_video_display_options`). - **Artboard guide labels (#6414, in part):** Artboard Options' Guide Label gives an artboard a color label (`artboard.setProps`/`artboard.new` `guideLabel`: a layer color's name, `#rrggbb` or null), and the canvas draws that artboard's own guides in it; canvas guides, guides of an unlabeled artboard and guides whose artboard is gone keep the guide color preference, and selected guides keep the selection color. The [help page](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/align-graphic-objects-with-guides.html) describes guides restricted to an artboard, not a label, so the label, its palette and the color precedence are VectorCraft's own. It is screen-only (exports and print leave it out), written to the native format only when set, kept by duplicates and copies, and changeable on a locked artboard; `artboard.setProps` adds no undo step when nothing changes. This VectorCraft addition does not raise a score. Regression tests: `tests/panelcmds.rs` (`artboard_guide_labels_are_set_and_cleared_in_one_undo_step`, `artboard_guide_labels_round_trip_through_the_native_format`), `doc` `tests.rs` (`guide_label_text_names_a_layer_color_or_rgb`, `artboard_guide_label_defaults_and_skips_in_json`), `canvas::tests` (`artboard_guide_label_colors_only_its_own_guides`), `dialogs/tests.rs` (`artboard_options_shows_sets_and_clears_the_guide_label`). @@ -2046,6 +2046,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| | 2026-11-12 | minor | Live Paint Bucket fills faces crossed in one drag (#6373). Scores and estimates unchanged. | +| 2026-11-11 | minor | Rulers: the zero point dragged out of the rulers' corner snaps as a drawn point does (Smart Guides, Snap to Point and the ruler guides, pixels, the grid); evidence updated; scores unchanged. | | 2026-11-11 | minor | Eyedropper copies effective character dimensions between transformed text objects (#6404); scores and estimates unchanged. | | 2026-11-11 | minor | Envelopes: with Distort Appearance, type's strokes (characters' and appearance) paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them; paths' strokes, geometry effects, gradients and patterns bend as before; also in an envelope inside an envelope; opacity masks bend as other art; Expand keeps type's strokes as strokes; the visual bounds of envelopes, blends and repeats and the reach of symbol art count type's character strokes as type's own bounds do. Changes how VectorCraft's own envelopes of stroked type draw. Advanced art evidence and `.ai` import evidence updated (#6644); 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. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 6a843326c0..512372c19d 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Outline view: images and gradient meshes are picked on what Outline draws, so a click inside them reaches the path behind, #6437; previously minor: a middle-click on a document tab closes it, #6506; previously minor: Blob Brush: a round tip's preview is the outline of the area it adds, round at both ends, as the stroke it commits (#4343); scores and estimates unchanged; previously:; ruler double-click adds a guide at the pointer, #5239; G5: Inventory Icons built-in symbol library (four built-ins); previously minor: files that record no units open in Preferences › Units › General, #5820; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; numeric fields step on wheel hover without taking keyboard focus, #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; Selection QoL: marquee modes and visible feedback, #5313; #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; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (Ruler origin: the corner drag snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Outline view: images and gradient meshes are picked on what Outline draws, so a click inside them reaches the path behind, #6437; previously minor: a middle-click on a document tab closes it, #6506; previously minor: Blob Brush: a round tip's preview is the outline of the area it adds, round at both ends, as the stroke it commits (#4343); scores and estimates unchanged; previously:; ruler double-click adds a guide at the pointer, #5239; G5: Inventory Icons built-in symbol library (four built-ins); previously minor: files that record no units open in Preferences › Units › General, #5820; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; numeric fields step on wheel hover without taking keyboard focus, #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; Selection QoL: marquee modes and visible feedback, #5313; #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; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; 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). > @@ -240,7 +240,7 @@ tools, never checked side by side) isn't captured row by row. | Ruler guides: drag out, select, move, delete, lock, Make/Release Guides | done | #414, #451; marquee selects unlocked ruler guides | | A double-click on a ruler adds a guide at the pointer: a vertical one on the top ruler, a horizontal one on the left ruler (swapped in a view turned 90°, where the rulers read the other document axis; the nearer axis in between), exactly where the pointer is (no snapping), shown even with guides hidden, one undo step (`guide.add`); both clicks of the pair must be on that ruler, and the first adds nothing. The [public page on rulers](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/use-rulers.html) doesn't describe this gesture, so it follows the request in #5239 | done | #5239; an artboard guide with the Artboard tool, as a drag from a ruler makes one; `canvas/tests.rs` `double_clicking_a_ruler_adds_a_guide_at_that_position`, `a_canvas_click_then_a_ruler_click_adds_no_guide`, `a_ruler_double_click_follows_a_rotated_view` | | Escape cancels an existing ruler-guide press, move or copy | done | Selection and Direct/Group Selection; app-input regressions cover preselected guides, document/interaction-start selection, Undo/Redo and saved state, later on-canvas motion/release and temporary borrowing from idle Pen. All eight checks and the Direct Selection Escape control passed in current full CI. [Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area) | -| Ruler origin: corner drag with preview, double-click reset to the active artboard, relative X/Y fields, native save/reopen | done | #1212; `document.setRulerOrigin`, `document.resetRulerOrigin`; geometry and command coordinates stay absolute | +| Ruler origin: corner drag with preview, snapped as a drawn point is (Smart Guides, Snap to Point and the ruler guides, pixels, the grid), double-click reset to the active artboard, relative X/Y fields, native save/reopen | done | #1212; `document.setRulerOrigin`, `document.resetRulerOrigin`; geometry and command coordinates stay absolute | | Ruler guides as Layers rows: shown, hidden, locked and deleted with their layer; rows dragged to another layer | done | `guide.setLayer`; guides from older files go on a layer | | Objects made into guides: drawn in the guide colour and style of Preferences; Lock Guides locks them too (no select, move or delete); a dragged selection snaps to ruler guides with Smart Guides on, by the nearest point of the path when pressed on its stroke or beside its edge, so the path lands exactly on the guide | done | #1534 (#1543, #1583); #3701 | | A selected ruler guide's X or Y in the Transform panel; typing a value moves it | not scored | #1659 (part of #1597); several guides move together; not in Properties or the Control bar. Not established by the documentation we could find: the help pages on guides, rulers and the Transform, Control and Properties panels (read again on 2026-10-14) don't describe it, and the one public statement is a user's [community answer](https://community.adobe.com/questions-652/adding-a-new-custom-guide-785586), which is not documentation. Kept as a convenience pending the owner's decision; no parity claim | @@ -520,6 +520,7 @@ before release (#6373). [Paint faces and edges](https://helpx.adobe.com/illustra | Date | Change | Summary | |---|---|---| | 2026-11-12 | minor | Live Paint Bucket fills faces crossed in one drag (#6373). Scores and estimates unchanged. | +| 2026-11-11 | minor | Ruler origin: the corner drag snaps as a drawn point does, onto the art's and the artboards' anchors, centres, edges and paths, and with Snap to Point along the ruler guides; scores and estimates unchanged | | 2026-11-11 | minor | Eyedropper copies effective character dimensions between transformed text objects (#6404); scores and estimates unchanged. | | 2026-11-11 | minor | Outline view: a click on a path's outline selects it through an image or a gradient mesh in front, which Outline draws as a frame or mesh lines (#6437). | | 2026-11-11 | minor | A middle-click on a document tab (or its ×) closes it as the × does; closing a document's last view with unsaved changes asks to save first (#6506). VectorCraft addition; scores and estimates unchanged. | From 917411a3541d4ba33467d68d16d346a7e439091d Mon Sep 17 00:00:00 2001 From: Dan Green <danngreen1@gmail.com> Date: Wed, 11 Nov 2026 15:15:22 -0800 Subject: [PATCH 33/43] Rulers: hiding the rulers during a drag of their origin cancels it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The drag out of the rulers' corner was ended only by the rulers' own input, which the canvas skips while the rulers are hidden (View › Rulers, Presentation Mode). Hiding them mid-drag left the drag's snapping guides on the canvas and its state open until the rulers came back. The canvas now ends the drag, cancelled, while the rulers are hidden, through the same helper a release uses. --- crates/ui-egui/src/canvas.rs | 4 ++-- crates/ui-egui/src/canvas/grid.rs | 21 +++++++++++++++++---- crates/ui-egui/src/tests/ruler_origin.rs | 11 ++++++++++- 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 50136f1a9c..6e6806e075 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -50,7 +50,7 @@ use fill::fill_uncovered; use fill::{fill_view, polygon_area}; #[cfg(test)] use grid::ruler_rects; -use grid::{LineLook, PIXEL_GRID, PIXEL_GRID_ZOOM, checker, clip_segment, grid, ruler_input, rulers, textured_quad, view_bounds}; +use grid::{LineLook, PIXEL_GRID, PIXEL_GRID_ZOOM, checker, clip_segment, grid, ruler_input, rulers, rulers_hidden, textured_quad, view_bounds}; pub(crate) use grid::{RulerScale, checker_texture, ruler_label, ruler_origin_dragging}; #[cfg(test)] pub(crate) use grid::{TRANSPARENCY_GRID, vertical_label_height}; @@ -283,7 +283,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { ui.ctx().request_repaint_after_secs(secs as f32); } let hover_still = wait.is_none(); - let origin_preview = if app.ui.view.rulers && app.ui.screen_mode < 3 { ruler_input(app, ui, full, rect, &xf) } else { None }; + let origin_preview = if app.ui.view.rulers && app.ui.screen_mode < 3 { ruler_input(app, ui, full, rect, &xf) } else { rulers_hidden(app, ui) }; crate::document_drag::drop_on_canvas(app, &resp, &xf); panel_drop(app, ui, &resp, &xf); // In Presentation Mode a right click or a long touch opens no context menu. diff --git a/crates/ui-egui/src/canvas/grid.rs b/crates/ui-egui/src/canvas/grid.rs index 0f6fc7d912..2c99c618db 100644 --- a/crates/ui-egui/src/canvas/grid.rs +++ b/crates/ui-egui/src/canvas/grid.rs @@ -285,8 +285,7 @@ pub(super) fn ruler_input(app: &mut VectorcraftApp, ui: &Ui, full: egui::Rect, c app.session.ruler_origin_drag(pointer, app.view_info()) }; if corner.drag_stopped_by(egui::PointerButton::Primary) { - ui.data_mut(|d| d.remove::<(Option<u64>, bool)>(drag_id)); - app.session.end_ruler_origin_drag(); + end_ruler_origin_drag(app, ui); if !cancelled && let Some(at) = at && let Err(e) = app.run("document.setRulerOrigin", json!({"x": at.x, "y": at.y, "artboard": app.view().map_or(0, |v| v.artboard)})) @@ -294,8 +293,7 @@ pub(super) fn ruler_input(app: &mut VectorcraftApp, ui: &Ui, full: egui::Rect, c app.status(e.to_string()); } } else if !ui.input(|i| i.pointer.primary_down()) { - ui.data_mut(|d| d.remove::<(Option<u64>, bool)>(drag_id)); - app.session.end_ruler_origin_drag(); + end_ruler_origin_drag(app, ui); } else { ui.data_mut(|d| d.insert_temp(drag_id, (started_in, cancelled))); if !cancelled { @@ -306,6 +304,21 @@ pub(super) fn ruler_input(app: &mut VectorcraftApp, ui: &Ui, full: egui::Rect, c preview } +/// The rulers are hidden (View › Rulers, Presentation Mode): a drag of their origin they were +/// showing for is cancelled, so its snapping guides don't stay on the canvas. Nothing previews. +pub(super) fn rulers_hidden(app: &mut VectorcraftApp, ui: &Ui) -> Option<Point> { + if ruler_origin_dragging(ui.ctx()) { + end_ruler_origin_drag(app, ui); + } + None +} + +/// Ends a drag from the rulers' corner, released or cancelled: its state and its snapping guides go. +fn end_ruler_origin_drag(app: &mut VectorcraftApp, ui: &Ui) { + ui.data_mut(|d| d.remove::<(Option<u64>, bool)>(ruler_origin_drag_id())); + app.session.end_ruler_origin_drag(); +} + /// The key of the ruler origin drag's state in egui's temporary data. fn ruler_origin_drag_id() -> egui::Id { egui::Id::unique("ruler-origin-drag") diff --git a/crates/ui-egui/src/tests/ruler_origin.rs b/crates/ui-egui/src/tests/ruler_origin.rs index 55e20df8c6..5e93993fe3 100644 --- a/crates/ui-egui/src/tests/ruler_origin.rs +++ b/crates/ui-egui/src/tests/ruler_origin.rs @@ -153,8 +153,9 @@ fn ruler_origin_cancelled_drags_do_not_modify_either_document() { Escape, Focus, Document, + HideRulers, } - for cancel in [Cancel::Outside, Cancel::Escape, Cancel::Focus, Cancel::Document] { + for cancel in [Cancel::Outside, Cancel::Escape, Cancel::Focus, Cancel::Document, Cancel::HideRulers] { let mut app = app(); app.run("document.setRulerOrigin", json!({"x": 25, "y": 30})).unwrap(); let ctx = context(); @@ -179,6 +180,14 @@ fn ruler_origin_cancelled_drags_do_not_modify_either_document() { app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); to } + Cancel::HideRulers => { + assert!(!app.session.overlays(app.view_info()).is_empty(), "the snapping guides show"); + app.ui.view.rulers = false; + frame(&mut app, &ctx, vec![], canvas::show); + assert!(app.session.overlays(app.view_info()).is_empty(), "the snapping guides go with the rulers"); + assert!(!canvas::ruler_origin_dragging(&ctx)); + to + } }; frame(&mut app, &ctx, vec![Event::PointerMoved(release), press(release, false)], canvas::show); if cancel == Cancel::Document { From 691e5a8fb5245d1eb8b31edf2c84897161279b4d Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 21:36:30 -0800 Subject: [PATCH 34/43] Shortcuts: a single-key shortcut opens its menu item's slider or dialog A command given a key without a modifier did not open the slider or dialog that its menu item and its chords open. With Smooth on F, pressing F opened no slider, and Offset Path, Reflect, Average, Split Into Grid, Vector Halftone, Divide into Dashes and Find Nearest Swatch changed the document at once with their default values. The single-key handler ran the command directly, while a chord goes through the menu's route. Single-key presses now take the chord's route (run_hit), so Smooth and Simplify show their sliders, and Offset Path, Move, Rotate, Scale, Reflect, Shear, Transform Each, Average, Average & Join, Split Into Grid, Vector Halftone, Rearrange All Artboards, Pathfinder Options, Arrange in Grid, Divide into Dashes and Find Nearest Swatch open their dialogs. Commands without a dialog still run at once. On that route a single key also finishes a pending tool edit before its command runs, as a chord does. Holding a key that opened a dialog or the palette no longer types into it: the key's repeats and the text they type are held back until the key is let go. With Move on F, holding F typed "ffffffff" into Move's first field. A shortcut that opens a dialog or the palette also stops the shortcuts after it in the same frame, single keys and chords alike, so they no longer change the document behind the dialog. --- crates/ui-egui/src/shortcuts.rs | 74 +++++++++- crates/ui-egui/src/shortcuts/tests.rs | 194 ++++++++++++++++++++++++++ 2 files changed, 263 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index df87187a65..83d53d700c 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -194,6 +194,7 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { } ctx.input(|i| crate::shortcut_editor::note_backtick_text(&i.events)); note_key_surfaces(app, ctx); + forget_opening_key(ctx); let focused = ctx.input(|input| input.focused) || app.forwarded_frame && ctx.viewport_id() == egui::ViewportId::ROOT; // A held tool shortcut that is let go returns to the tool active before it. held_tool::follow(app, ctx, focused); @@ -364,6 +365,9 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { let ran = !hits.is_empty(); for c in hits { run_hit(app, ctx, &c); + if dialog_or_palette_open(app) { + break; + } } if settings_on_a_mac(app, ctx, mac(ctx)) || ran { return; @@ -423,10 +427,54 @@ fn note_key_surfaces(app: &VectorcraftApp, ctx: &egui::Context) { /// Removes this frame's presses of Enter and Escape when the key went down on something that has /// since changed: the press that opened or closed a dialog, and its repeats until the key is let -/// go, reach neither the new dialog nor what is under the old one. +/// go, reach neither the new dialog nor what is under the old one. Also removes the repeats of a +/// key without a modifier that opened a dialog or the palette ([`hold_back_opening_key`]). pub(crate) fn hold_back_keys(app: &VectorcraftApp, ctx: &egui::Context) { hold_back_key(app, ctx, Key::Enter); hold_back_key(app, ctx, Key::Escape); + hold_back_opening_key(ctx); +} + +/// The key of the mark of a key without a modifier whose command opened a dialog or the palette. +fn opening_key_id(ctx: &egui::Context) -> egui::Id { + egui::Id::unique("opening-key").with(ctx.viewport_id()) +} + +/// Removes this frame's repeats of the key without a modifier that opened a dialog or the +/// palette, and the text they type, until that key is let go: a held key does not type into the +/// field that takes the keyboard. +fn hold_back_opening_key(ctx: &egui::Context) { + let id = opening_key_id(ctx); + let Some(key) = ctx.data(|m| m.get_temp::<Key>(id)) else { return }; + let repeat_of = |e: Option<&egui::Event>| matches!(e, Some(egui::Event::Key { key: k, pressed: true, repeat: true, .. }) if *k == key); + ctx.input_mut(|i| { + let typed_by = typing_presses(&i.events); + let held: Vec<bool> = i + .events + .iter() + .zip(typed_by) + .map(|(e, by)| repeat_of(Some(e)) || matches!(e, egui::Event::Text(_)) && repeat_of(by.and_then(|k| i.events.get(k)))) + .collect(); + let mut held = held.into_iter(); + i.events.retain(|_| !held.next().unwrap_or(false)); + }); + // A repeat that comes in the same frame as the release is held back above; later presses are new. + let released = + ctx.input(|i| !i.key_down(key) || i.events.iter().any(|e| matches!(e, egui::Event::Key { key: k, pressed: false, .. } if *k == key))); + if released { + ctx.data_mut(|m| m.remove::<Key>(id)); + } +} + +/// Forgets the key that opened a dialog or the palette ([`hold_back_opening_key`]) once it is up +/// and this frame has none of its events. +fn forget_opening_key(ctx: &egui::Context) { + let id = opening_key_id(ctx); + if let Some(key) = ctx.data(|m| m.get_temp::<Key>(id)) + && ctx.input(|i| !i.key_down(key) && !i.events.iter().any(|e| matches!(e, egui::Event::Key { key: k, .. } if *k == key))) + { + ctx.data_mut(|m| m.remove::<Key>(id)); + } } fn hold_back_key(app: &VectorcraftApp, ctx: &egui::Context, key: Key) { @@ -549,17 +597,30 @@ fn single_key_shortcuts(app: &mut VectorcraftApp, ctx: &egui::Context) { // Single-key command shortcuts (X, Shift+X, D, /, Shift+D, F, Shift+F by default) before // tools. let Some(hit) = hits.iter().find(|c| !c.id.starts_with("tool:")).or(hits.first()) else { continue }; + // A command runs as it does on a chord ([`run_hit`]): what its menu item runs. Smooth opens + // its slider, and Move opens its dialog. match hit.id.strip_prefix("tool:") { Some(tool) => held_tool::choose(app, ctx, tool, pressed, repeat), - None if let Some((id, p)) = menu_target(hit) => crate::menus::invoke(app, &id, p), - None if hit.params.is_null() => { - let _ = app.run(&hit.id, json!({})); + None => { + run_hit(app, ctx, hit); + if dialog_or_palette_open(app) { + if let Some(key) = pressed { + let id = opening_key_id(ctx); + ctx.data_mut(|m| m.insert_temp(id, key)); + } + break; + } } - None => crate::menus::invoke(app, &hit.id, shortcut_params(hit)), } } } +/// Is a dialog or the palette open? A shortcut that opens one stops the shortcuts after it in the +/// same frame. +fn dialog_or_palette_open(app: &VectorcraftApp) -> bool { + app.ui.dialog.is_some() || app.ui.palette_open +} + /// Route Alt+arrows to spacing controls when text objects are selected. fn alt_type_arrows(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool { let m = ctx.input(|i| i.modifiers); @@ -615,6 +676,9 @@ fn type_tool_input(app: &mut VectorcraftApp, ctx: &egui::Context, composing: boo let hits = dispatch(app, ctx, |hit| hit.trigger.chord.mods.cmd); for c in hits { run_hit(app, ctx, &c); + if dialog_or_palette_open(app) { + break; + } } settings_on_a_mac(app, ctx, mac(ctx)); } diff --git a/crates/ui-egui/src/shortcuts/tests.rs b/crates/ui-egui/src/shortcuts/tests.rs index 44d438b73b..8d32a4de22 100644 --- a/crates/ui-egui/src/shortcuts/tests.rs +++ b/crates/ui-egui/src/shortcuts/tests.rs @@ -292,6 +292,200 @@ fn a_shortcut_runs_what_its_menu_item_runs() { } } +/// Commands whose menu item opens a slider or a dialog before anything changes: the sliders of +/// Smooth and Simplify, Offset Path, the Object › Transform dialogs, Average, Split Into Grid, +/// Vector Halftone, Rearrange All Artboards, Pathfinder Options, Arrange in Grid, Divide into +/// Dashes, Find Nearest Swatch, and four "…" items whose menu entry gives params. +const MENU_DIALOG_COMMANDS: [&str; 22] = [ + "object.path.smooth", + "object.path.simplify", + "object.path.offsetPath", + "object.move", + "object.rotate", + "object.scale", + "object.reflect", + "object.shear", + "object.transformEach", + "path.average", + "path.averageJoin", + "object.path.splitIntoGrid", + "object.vectorHalftone", + "artboard.rearrange", + "object.pathfinder.options", + "addon.arrange.grid", + crate::addons::pathtools::dashes::COMMAND, + crate::addons::color::nearest::COMMAND, + "object.path.cleanUp", + "object.mesh.create", + "type.smartPunctuation", + "edit.findReplace", +]; + +/// F pressed without a modifier. +fn press_f() -> Vec<egui::Event> { + vec![ + egui::Event::Key { key: Key::F, physical_key: Some(Key::F), pressed: true, repeat: false, modifiers: Modifiers::NONE }, + egui::Event::Text("f".into()), + ] +} + +/// An app with a selected rectangle where `id` has the shortcut `shortcut` and F runs nothing else. +fn app_with_shortcut(id: &str, shortcut: &str) -> VectorcraftApp { + let mut app = app_with_a_selected_rectangle(); + crate::keymap::set_override(&mut app.ui.shortcut_overrides, "view.screenMode", ""); + crate::keymap::set_override(&mut app.ui.shortcut_overrides, id, shortcut); + app.ui.status.clear(); + app +} + +fn undo_len(app: &VectorcraftApp) -> usize { + app.session.active().unwrap().history.undo.len() +} + +/// A command given a key without a modifier runs what its menu item runs, as it does on a chord: +/// its slider or dialog opens and nothing changes until it is confirmed. +#[test] +fn a_single_key_shortcut_opens_the_dialog_its_menu_item_opens() { + let chord = vec![ + egui::Event::ModifiersChanged(cmd() | Modifiers::ALT | Modifiers::SHIFT), + egui::Event::Key { key: Key::F9, physical_key: None, pressed: true, repeat: false, modifiers: cmd() | Modifiers::ALT | Modifiers::SHIFT }, + ]; + let mut failures = vec![]; + for id in MENU_DIALOG_COMMANDS { + let mut opened = |shortcut: &str, events: Vec<egui::Event>| { + let mut app = app_with_shortcut(id, shortcut); + let before = undo_len(&app); + frame(&mut app, events); + crate::shortcut_editor::sync(&crate::state::UiState::default()); + if undo_len(&app) != before { + failures.push(format!("{id} on {shortcut} changed the document: {}", app.ui.status)); + } + app.ui.dialog.as_ref().map(|d| (d.kind.clone(), d.fields.get("__command").cloned())) + }; + let by_key = opened("F", press_f()); + let by_chord = opened("Cmd+Alt+Shift+F9", chord.clone()); + if by_key.is_none() || by_key != by_chord { + failures.push(format!("{id}: F opens {by_key:?}, the chord {by_chord:?}")); + } + } + assert!(failures.is_empty(), "{failures:#?}"); +} + +/// Smooth on F opens its slider on a jagged path without smoothing it; confirming the slider keeps +/// the smoothed path as one undo step. +#[test] +fn smooth_on_a_single_key_waits_for_its_slider() { + let mut app = app_with_shortcut("object.path.smooth", "F"); + app.session.execute("edit.clear", &json!({})).unwrap(); + let anchors: Vec<_> = + (0..=60).map(|i| json!({"x": 20 + i * 5, "y": 150.0 + 40.0 * (f64::from(i) / 10.0).sin() + f64::from(i % 2) * 3.0})).collect(); + app.session.execute("path.create", &json!({"anchors": anchors})).unwrap(); + let before = undo_len(&app); + frame(&mut app, press_f()); + crate::shortcut_editor::sync(&crate::state::UiState::default()); + assert_eq!(open_dialog(&app).as_deref(), Some(crate::dialogs::smooth_slider::KIND), "{}", app.ui.status); + assert_eq!(undo_len(&app), before, "the path is not smoothed yet"); + crate::dialogs::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(undo_len(&app), before + 1, "the slider's result is one undo step"); +} + +/// A command without a dialog still runs at once on a key without a modifier: Group given F, and +/// Swap Fill and Stroke on its default Shift+X. +#[test] +fn a_single_key_shortcut_without_a_dialog_runs_at_once() { + let mut app = app_with_shortcut("object.group", "F"); + let before = undo_len(&app); + frame(&mut app, press_f()); + crate::shortcut_editor::sync(&crate::state::UiState::default()); + assert_eq!((open_dialog(&app), undo_len(&app)), (None, before + 1), "F groups: {}", app.ui.status); + let mut app = app_with_a_selected_rectangle(); + let before = undo_len(&app); + frame( + &mut app, + vec![ + egui::Event::ModifiersChanged(Modifiers::SHIFT), + egui::Event::Key { key: Key::X, physical_key: Some(Key::X), pressed: true, repeat: false, modifiers: Modifiers::SHIFT }, + egui::Event::Text("X".into()), + ], + ); + assert_eq!((open_dialog(&app), undo_len(&app)), (None, before + 1), "Shift+X swaps fill and stroke: {}", app.ui.status); +} + +/// With no document open, a command's key without a modifier reports what the same command +/// reports on a chord. +#[test] +fn a_single_key_shortcut_reports_what_its_chord_reports() { + let status = |shortcut: &str, events: Vec<egui::Event>| { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + crate::keymap::set_override(&mut app.ui.shortcut_overrides, "view.screenMode", ""); + crate::keymap::set_override(&mut app.ui.shortcut_overrides, "paint.default", shortcut); + app.ui.status.clear(); + frame(&mut app, events); + crate::shortcut_editor::sync(&crate::state::UiState::default()); + (open_dialog(&app), app.ui.status.clone()) + }; + let by_key = status("F", press_f()); + let by_chord = status( + "Cmd+Alt+Shift+F9", + vec![ + egui::Event::ModifiersChanged(cmd() | Modifiers::ALT | Modifiers::SHIFT), + egui::Event::Key { key: Key::F9, physical_key: None, pressed: true, repeat: false, modifiers: cmd() | Modifiers::ALT | Modifiers::SHIFT }, + ], + ); + assert_eq!(by_key, by_chord); + assert!(!by_key.1.is_empty(), "the chord reports something: {by_chord:?}"); +} + +/// Holding a key without a modifier that opens a dialog types nothing into the dialog: neither the +/// key's character nor its repeats reach the field that takes the keyboard. +#[test] +fn holding_a_single_key_that_opens_a_dialog_types_nothing_into_it() { + // Move on F: the dialog and the text of the field with the keyboard after the press, eight + // more frames with F held down (repeating) or not, and the release, which comes in the same + // frame as a last repeat. + let hold = |repeats: bool| { + let mut app = app_with_shortcut("object.move", "F"); + let held = HeldKey::new(Key::F, Modifiers::NONE); + held.frame(&mut app, press_f()); + for _ in 0..8 { + held.frame(&mut app, if repeats { press_f() } else { vec![] }); + } + let release = egui::Event::Key { key: Key::F, physical_key: Some(Key::F), pressed: false, repeat: false, modifiers: Modifiers::NONE }; + held.frame(&mut app, if repeats { [press_f(), vec![release]].concat() } else { vec![release] }); + let field = held.ctx.memory(|m| m.focused()).and_then(|id| held.ctx.data_mut(|d| d.get_temp::<String>(id))); + crate::shortcut_editor::sync(&crate::state::UiState::default()); + (open_dialog(&app), field, app.ui.status.clone()) + }; + let let_go = hold(false); + assert_eq!(let_go.0.as_deref(), Some("move"), "{}", let_go.2); + assert!(let_go.1.is_some(), "its first field has the keyboard"); + assert_eq!(hold(true), let_go, "the held F typed nothing into Move"); +} + +/// A key that opens a dialog stops the keys after it in the same frame: G, given Group, does not +/// group the selection behind Move's dialog. The same holds for chords. +#[test] +fn keys_after_one_that_opens_a_dialog_in_the_same_frame_run_nothing() { + let mods = cmd() | Modifiers::ALT | Modifiers::SHIFT; + let chord = |key: Key| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: mods }; + let single = |key: Key, text: &str| { + [egui::Event::Key { key, physical_key: Some(key), pressed: true, repeat: false, modifiers: Modifiers::NONE }, egui::Event::Text(text.into())] + }; + let runs = [ + ("F", "G", [single(Key::F, "f"), single(Key::G, "g")].concat()), + ("Cmd+Alt+Shift+F9", "Cmd+Alt+Shift+F10", vec![egui::Event::ModifiersChanged(mods), chord(Key::F9), chord(Key::F10)]), + ]; + for (move_key, group_key, events) in runs { + let mut app = app_with_shortcut("object.move", move_key); + crate::keymap::set_override(&mut app.ui.shortcut_overrides, "object.group", group_key); + let before = undo_len(&app); + frame(&mut app, events); + crate::shortcut_editor::sync(&crate::state::UiState::default()); + assert_eq!((open_dialog(&app).as_deref(), undo_len(&app)), (Some("move"), before), "{move_key} then {group_key}: {}", app.ui.status); + } +} + /// #2094: Cmd+Shift+O is Type › Create Outlines (not File › Open's Cmd+O, not the Artboard /// tool's Shift+O), with the Selection tool or while the Type tool edits the text. #[test] From 46efc4a651364a2d9ef38761efbc73a77aa18ac3 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 22:39:04 -0800 Subject: [PATCH 35/43] Scripts: a String object counts its rope's copy when a character is read - wrapping a string no longer flattens it, so Object(s).length and new String(s) on a large concatenated string make no copy and cannot collect while a call's operands are off the stack - reading a String object's character by index, `in` and hasOwnProperty count the flattened copy at the next limit check, and the run ends before the copy when the fuel for it is not left - unary plus on an object whose valueOf returns a concatenated string, and a property key from toString, keep that string rooted while its copy is reserved - the unshift comment cites step 6 of section 15.4.4.13 --- crates/js/src/builtins/array.rs | 2 +- crates/js/src/interp.rs | 80 ++++++++++++++++++-------------- crates/js/src/realm.rs | 1 + crates/js/src/string_builtins.rs | 3 -- crates/js/src/text_tests.rs | 55 +++++++++++++++++++--- 5 files changed, 94 insertions(+), 47 deletions(-) diff --git a/crates/js/src/builtins/array.rs b/crates/js/src/builtins/array.rs index 838fc4ff98..bfb4de5eac 100644 --- a/crates/js/src/builtins/array.rs +++ b/crates/js/src/builtins/array.rs @@ -479,7 +479,7 @@ fn unshift(interp: &mut Interp, this: Value, args: &[Value], _: bool) -> JsResul with_this(interp, &this, |interp, o| { let len = u64::from(length_of(interp, o)?); let count = args.len() as u64; - // With no arguments each element is still read and written back in place (steps 6 and 7). + // With no arguments each element is still read and written back in place (step 6). let maps = vec![Map::Shift(-1), Map::Shift(count as i64 - 1)]; let mut it = Indices::new(interp, o, 1, len + 1, false, maps)?; while let Some(k) = it.next(interp)? { diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index 82d0c8f9ac..ace43fe242 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -205,6 +205,11 @@ pub(crate) struct Interp { pub(crate) date_host: crate::date_builtins::DateHost, /// The bytes native code holds outside the heap's cells through [`HeldBytes`]. pub(crate) held: Rc<HeldCount>, + /// Bytes of the copies [`Self::get_own_property`] made flattening the rope of a String + /// object, which the heap's total adds before the next limit check. + flattened: std::cell::Cell<usize>, + /// Fuel for the units those copies took, which the next step takes. + pub(crate) flattened_fuel: std::cell::Cell<u64>, /// The global code's default XML namespace URI (ECMA-357 section 12.1). pub(crate) xml_default_uri: Key, /// Set by the host to end the current slice of a resumable run at the next safe point. @@ -450,6 +455,8 @@ impl Interp { files: None, date_host: crate::date_builtins::DateHost::default(), held: Rc::default(), + flattened: std::cell::Cell::new(0), + flattened_fuel: std::cell::Cell::new(0), xml_default_uri: Rc::from(Vec::new()), pause: Rc::default(), in_slice: false, @@ -569,6 +576,7 @@ impl Interp { fn tick(&mut self) -> JsResult<()> { self.use_fuel()?; + crate::regexp_vm::use_fuel_n(self, self.flattened_fuel.replace(0))?; self.settle_held(); if self.heap.bytes > self.gc_threshold { self.collect_garbage(); @@ -595,10 +603,12 @@ impl Interp { Ok(()) } - /// Takes the bytes that dropped [`HeldBytes`] released off the heap's total. + /// Takes the bytes that dropped [`HeldBytes`] released off the heap's total, and adds the + /// bytes of the ropes [`Self::get_own_property`] flattened. fn settle_held(&mut self) { let released = self.held.released.replace(0); - self.heap.bytes = self.heap.bytes.saturating_sub(released); + let flattened = self.flattened.replace(0); + self.heap.bytes = self.heap.bytes.saturating_sub(released).saturating_add(flattened); } /// Collects garbage now and sets when the next collection runs. @@ -664,6 +674,7 @@ impl Interp { self.heap.collect(&roots); // The collection counts the live total again, so bytes already released are not in it. self.held.released.set(0); + self.flattened.set(0); self.heap.bytes = self.heap.bytes.saturating_add(self.held_bytes).saturating_add(self.held.now.get()); } @@ -1214,6 +1225,17 @@ impl Interp { if let Some(i) = array_index(k) && (i as usize) < s.len() { + if s.is_rope() { + // Flattening copies the units. The run ends here when the fuel for the + // copy is not left; otherwise the next step takes it, and the next limit + // check adds the copy's bytes. + let fuel = self.flattened_fuel.get().saturating_add((s.len() / crate::string_builtins::UNITS_PER_FUEL) as u64 + 1); + if fuel > self.fuel { + return Err(Abrupt::Limit(LimitKind::Fuel)); + } + self.flattened_fuel.set(fuel); + self.flattened.set(self.flattened.get().saturating_add(string_bytes(s.len()))); + } let unit = s.flat().get(i as usize).copied().unwrap_or(0); return Ok(Some(Prop::Data { value: Value::String(Str::from_units(vec![unit])), flags: ENUMERABLE })); } @@ -2216,19 +2238,26 @@ impl Interp { let arguments = if code.uses_arguments { OBJECT_BYTES.saturating_add(args.len().saturating_add(4).saturating_mul(binding_bytes(10))) } else { 0 }; let need = declaration_bytes(&code).saturating_add(ENV_BYTES).saturating_add(params).saturating_add(arguments); - // The operands are off the stack. Reserving the heap collects, and so can boxing a string - // receiver, which flattens it; the operands stay rooted until the frame holds them. - let collects = self.heap.bytes.saturating_add(need) > self.limits.heap_bytes || (!code.strict && matches!(this, Value::String(_))); - let mark = self.temp_roots.len(); - if collects { + if self.heap.bytes.saturating_add(need) > self.limits.heap_bytes { + let mark = self.temp_roots.len(); self.temp_roots.push(Value::Object(callee)); self.temp_roots.push(this.clone()); self.temp_roots.extend(construct.map(Value::Object)); self.temp_roots.extend(args.iter().cloned()); + let reserved = self.reserve_heap(need); + self.temp_roots.truncate(mark); + reserved?; } - let received = self.receive_call(&code, env, this, &args, callee, need); - self.temp_roots.truncate(mark); - let (this, fenv) = received?; + let this = if code.strict { + this + } else { + match this { + Value::Undefined | Value::Null => Value::Object(self.global), + Value::Object(_) => this, + prim => Value::Object(self.to_object(&prim)?), + } + }; + let fenv = self.instantiate_function(&code, env, &args, callee)?; let base = self.stack.len(); self.frames.push(Frame { code, @@ -2246,25 +2275,6 @@ impl Interp { Ok(()) } - /// Reserves the `need` bytes of a call, boxes a primitive receiver for non-strict code and - /// binds the parameters, returning the receiver and the function's environment. - fn receive_call(&mut self, code: &Rc<Code>, env: EnvId, this: Value, args: &[Value], callee: ObjId, need: usize) -> JsResult<(Value, EnvId)> { - if self.heap.bytes.saturating_add(need) > self.limits.heap_bytes { - self.reserve_heap(need)?; - } - let this = if code.strict { - this - } else { - match this { - Value::Undefined | Value::Null => Value::Object(self.global), - Value::Object(_) => this, - prim => Value::Object(self.to_object(&prim)?), - } - }; - let fenv = self.instantiate_function(code, env, args, callee)?; - Ok((this, fenv)) - } - /// Declaration binding instantiation for function code (section 10.5). Each parameter and /// declaration takes a unit of fuel. fn instantiate_function(&mut self, code: &Rc<Code>, outer: EnvId, args: &[Value], callee: ObjId) -> JsResult<EnvId> { @@ -2578,7 +2588,8 @@ impl Interp { Value::String(s) => string_to_number(&flat_charged(self, s)?), Value::Object(_) => { let p = self.to_primitive(v, Hint::Number)?; - return self.to_number(&p); + // Rooted, so what a string holds is still counted while its copy is reserved. + return self.rooted(std::slice::from_ref(&p), |i| i.to_number(&p)); } }) } @@ -2613,7 +2624,8 @@ impl Interp { Value::Number(n) if *n >= 0.0 && *n < 4_294_967_295.0 && n.fract() == 0.0 => Ok(index_key(*n as u32)), _ => { let s = self.to_str(v)?; - self.flat_reserved(&s) + // Rooted, so what the string holds is still counted while its copy is reserved. + self.rooted(&[Value::String(s.clone())], |i| i.flat_reserved(&s)) } } } @@ -2638,11 +2650,7 @@ impl Interp { Value::Undefined | Value::Null => return Err(self.type_error("cannot convert undefined or null to an object")), Value::Bool(b) => (Class::Boolean(*b), self.intrinsics.boolean_proto), Value::Number(n) => (Class::Number(*n), self.intrinsics.number_proto), - // Flattened now, with its copy counted, so reading a character copies nothing. - Value::String(s) => { - flat_charged(self, s)?; - (Class::String(s.clone()), self.intrinsics.string_proto) - } + Value::String(s) => (Class::String(s.clone()), self.intrinsics.string_proto), }; Ok(self.heap.alloc_object(Object::new(class, Some(proto)))) } diff --git a/crates/js/src/realm.rs b/crates/js/src/realm.rs index f274c42ead..431e9d8f49 100644 --- a/crates/js/src/realm.rs +++ b/crates/js/src/realm.rs @@ -230,6 +230,7 @@ impl Realm { return Err(RunError { kind: RunErrorKind::Internal, message: message.clone(), line: 0, column: 0 }); } interp.fuel = interp.limits.fuel; + interp.flattened_fuel.set(0); interp.source_units = 0; interp.throw_site = None; interp.last_thrown = Value::Undefined; diff --git a/crates/js/src/string_builtins.rs b/crates/js/src/string_builtins.rs index 85a27e3261..b3758bdf54 100644 --- a/crates/js/src/string_builtins.rs +++ b/crates/js/src/string_builtins.rs @@ -214,9 +214,6 @@ fn string_ctor(interp: &mut Interp, _: Value, args: &[Value], construct: bool) - if !construct { return Ok(Value::String(s)); } - // Flattened now, with its copy counted, so reading a character copies nothing. The string - // stays rooted, so what it holds is still counted if reserving the copy collects. - interp.rooted(&[Value::String(s.clone())], |interp| flat_charged(interp, &s))?; let proto = interp.intrinsics.string_proto; Ok(Value::Object(interp.heap.alloc_object(Object::new(Class::String(s), Some(proto))))) } diff --git a/crates/js/src/text_tests.rs b/crates/js/src/text_tests.rs index 0c2f051f80..3d3455544e 100644 --- a/crates/js/src/text_tests.rs +++ b/crates/js/src/text_tests.rs @@ -355,22 +355,63 @@ fn a_flattened_temporary_stays_counted_while_the_next_copy_is_made() { } #[test] -fn a_string_wrapper_counts_what_its_rope_holds_while_the_copy_is_made() { +fn a_converted_rope_counts_what_it_holds_while_its_copy_is_made() { // `mk` returns a rope whose left half is a flat string of about half the heap, held by // nothing else. Its flattened copy is the other half, so the two don't fit together. let build = "var s = 'x'; for (var i = 0; i < 18; i++) s = s + s; function f(c) { return s + c; } \ function mk() { var a = f('a'); a[0]; return a + 'b'; }"; let limits = Limits { heap_bytes: 4 * 262_146, ..Limits::default() }; - let src = format!("{build} new String({{ toString: mk }}); 0"); - let result = Realm::with_options(Options::default(), limits).run(&src); - assert!(matches!(result, Err(ref e) if e.kind == RunErrorKind::Limit(LimitKind::Heap)), "{result:?}"); + let not_stopped: Vec<_> = ["+({ valueOf: mk })", "({})[{ toString: mk }]"] + .into_iter() + .filter(|read| { + let result = Realm::with_options(Options::default(), limits.clone()).run(&format!("{build} {read}; 0")); + !matches!(result, Err(ref e) if e.kind == RunErrorKind::Limit(LimitKind::Heap)) + }) + .collect(); + assert!(not_stopped.is_empty(), "not stopped by the heap limit: {not_stopped:?}"); +} + +#[test] +fn a_string_object_counts_its_flattened_rope_against_the_heap() { + // The same three ropes, each wrapped in a String object and read by index. + let build = "var s = 'x'; for (var i = 0; i < 19; i++) s = s + s; var t = s + 'y', u = s + 'z'; \ + String.prototype.first = function () { return this[0]; };"; + let limits = Limits { heap_bytes: 3 << 20, ..Limits::default() }; + // Wrapping alone reads no units, so it makes no copy. + let lengths = + Realm::with_options(Options::default(), limits.clone()).run(&format!("{build} Object(s).length + new String(t).length + Object(u).length")); + assert_eq!(lengths, Ok(JsValue::Number(1_572_866.0))); + for read in ["Object(X)[0]", "new String(X)[0]", "0 in Object(X)", "Object(X).hasOwnProperty(0)", "X.first()"] { + let src = format!("{build} {}; {}; {}; 0", read.replace('X', "s"), read.replace('X', "t"), read.replace('X', "u")); + let e = fail_with(Realm::with_options(Options::default(), limits.clone()), &src); + assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Heap), "{read}: {e:?}"); + } + let wrapped = + Realm::with_options(Options::default(), Limits::default()).run(&format!("{build} Object(t)[524288] + new String(u)[524288] + t.first()")); + assert_eq!(wrapped, Ok(JsValue::String("yzx".into()))); +} + +#[test] +fn a_string_object_takes_fuel_for_the_rope_it_flattens() { + let limits = Limits { fuel: 40_000, ..Limits::default() }; + let build = "var s = ' '; for (var i = 0; i < 20; i++) s = s + s; Object(s).length"; + assert!(Realm::with_options(Options::default(), limits.clone()).run(build).is_ok()); + for read in ["Object(s)[0]", "0 in new String(s)"] { + let e = fail_with(Realm::with_options(Options::default(), limits.clone()), &format!("{build}; {read}")); + assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Fuel), "{read}: {e:?}"); + } + // A run that ends with an error right after the read leaves the next run its whole fuel. + let mut realm = Realm::with_options(Options::default(), Limits { fuel: 100_000, ..Limits::default() }); + let defined = realm.run("var s = ' '; for (var i = 0; i < 20; i++) s = s + s; Object.defineProperty(new String(s), 0, { value: 'a' })"); + assert!(matches!(defined, Err(ref e) if e.kind == RunErrorKind::Thrown), "{defined:?}"); + assert!(realm.run("for (var j = 0; j < 5000; j++) {} j").is_ok()); } #[test] fn a_call_keeps_its_operands_while_a_string_receiver_is_boxed() { - // Boxing the rope receiver of a non-strict function flattens it, and reserving the copy - // collects the garbage string `g` left. The callee and the argument object are off the - // stack by then, and must survive the collection. + // `g` leaves a garbage string, so the heap is close to its limit when the rope receiver of a + // non-strict function is boxed. The callee and the argument object are off the stack by + // then, and must survive anything boxing the receiver does. let build = "var s = 'x'; for (var i = 0; i < 17; i++) s = s + s; function f(c) { return s + c; } \ function mk() { var a = f('a'); a[0]; return a + 'b'; } 0"; let mut realm = Realm::with_options(Options::default(), Limits::default()); From f4945780728df86e34a953eb5b3e9d06699ceb06 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 22:45:06 -0800 Subject: [PATCH 36/43] MCP: effect.refreshDeferred takes objects in symbol, pattern, brush and mask art effect.deferredStatus lists a stage in the art of a symbol, pattern, brush or opacity mask by the id of the object in that art. effect.refreshDeferred took such an id only while the object still had a deferred stage; an existing object there without one was refused as no such object, while a layer object without a stage was accepted. An id is now valid when it names an object in the layers or in that art, an object without a stage adds nothing to the counts, and an id of no object is an error. The params text and docs/mcp.md said `id` names one object, but the params text did not list the key, so `{id: N}` was refused as an unknown parameter. It is now `{ids?|id?}`, and the command checks only the form of the ids before it looks them up itself. The requested ids are held in a set while the art is walked, and only those with a stage are handed to the refresh. --- crates/engine/src/cmd.rs | 17 +++++ crates/engine/src/cmd/effectcmd.rs | 34 +++++++--- crates/engine/src/session/execute.rs | 8 ++- crates/engine/src/tests/plugins.rs | 92 ++++++++++++++++++++++++++++ docs/mcp.md | 6 +- docs/target-app-parity.md | 1 + 6 files changed, 148 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index b8c1f82e50..465025302f 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -507,6 +507,23 @@ pub(crate) fn given_objects(s: &Session, p: &Value, command: &str) -> Result<Opt Some(v) => Ok(Some(vec![checked_id(s, v, command)?])), } } +/// The objects `p` gives `command`, as [`given_objects`] reads them, checked only for their form: +/// for a command that looks its objects up itself, outside the document's layers too. +pub(crate) fn given_object_ids(p: &Value, command: &str) -> Result<Option<Vec<NodeId>>> { + if p.get("ids").is_some_and(|v| !v.is_null()) { + return ids_param(p, "ids", command); + } + match p.get("id").filter(|v| !v.is_null()) { + None => Ok(None), + Some(Value::String(r)) if r.starts_with('$') => { + Err(bad(command, format!("`id` is the step reference {r:?}, which only `run` and `command_batch` resolve"))) + } + Some(v) => { + let id = v.as_u64().map(NodeId).ok_or_else(|| bad(command, format!("invalid object id {v}: expected a non-negative integer")))?; + Ok(Some(vec![id])) + } + } +} /// `v` as an index, a count or a byte offset: past `usize::MAX` reads as `usize::MAX`, past the end of any list. pub(crate) fn index(v: u64) -> usize { usize::try_from(v).unwrap_or(usize::MAX) diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 30d927a966..9cadcc7876 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -123,7 +123,7 @@ pub fn specs() -> Vec<CommandSpec> { "Refresh Deferred Effects", ["Effect"], None, - "{ids?: [object id] (objects of the document; `id` names one; default: every object with a deferred stage)} run the deferred live-effect plug-in stages of those objects again (effect.deferredStatus), sharing a fresh 2,000 ms budget; a stage that gives a result is drawn with it; not an undo step. Enabled when a stage is deferred → {refreshed, stillDeferred (left out for want of time again, or not reached because the object, or a group or layer holding it, is hidden, or with a part of its input too small for the refresh's drawing), failed (timed out, failed or has no plug-in)}", + "{ids?|id?: [object id] (objects of the document, also in the art of a symbol, pattern, brush or opacity mask; an object without a deferred stage adds nothing; an id of no object is an error; `id` names one; default: every object with a deferred stage)} run the deferred live-effect plug-in stages of those objects again (effect.deferredStatus), sharing a fresh 2,000 ms budget; a stage that gives a result is drawn with it; not an undo step. Enabled when a stage is deferred → {refreshed, stillDeferred (left out for want of time again, or not reached because the object, or a group or layer holding it, is hidden, or with a part of its input too small for the refresh's drawing), failed (timed out, failed or has no plug-in)}", has_deferred, refresh_deferred ), @@ -150,16 +150,27 @@ fn stage_objects(d: &Document, fx: &vectorcraft_plugins::deferred::Deferrals) -> None => rest.push(object), } } - if rest.is_empty() { + for (id, n) in definition_objects(d, &rest) { + out.entry(id).or_insert((n, false)); + } + out +} + +/// The objects of `ids` found in the art `d` draws from outside its layers: the art of its +/// symbols, patterns and brushes, and opacity masks (in that art or in the layers). +fn definition_objects(d: &Document, ids: &[u64]) -> std::collections::HashMap<u64, Arc<Node>> { + let mut out = std::collections::HashMap::new(); + if ids.is_empty() { return out; } + let ids: std::collections::HashSet<u64> = ids.iter().copied().collect(); let brushes = vectorcraft_brush::library(d); let symbols = d.symbols.iter().map(|s| &*s.art); let patterns = d.patterns.iter().flat_map(|p| p.art.iter().map(|a| &**a)); let layers = d.layers.iter().map(|a| &**a); let mut look = |n: &Node| { - if rest.contains(&n.id.0) { - out.entry(n.id.0).or_insert_with(|| (Arc::new(n.clone()), false)); + if ids.contains(&n.id.0) { + out.entry(n.id.0).or_insert_with(|| Arc::new(n.clone())); } }; for a in symbols.chain(patterns).chain(brushes.iter().flat_map(|b| b.art())).chain(layers) { @@ -231,10 +242,19 @@ fn refresh_deferred(s: &mut Session, p: &Value) -> Result<Value> { let (doc, fx) = (st.doc.clone(), st.plugin_effects.clone()); let ids = super::named_ids(p)?; let objects = prune(&doc, &fx); - if let Some(id) = ids.iter().flatten().find(|id| doc.node(**id).is_none() && !objects.contains_key(&id.0)) { - return Err(EngineError::NoNode(*id)); + // An object of the art drawn outside the layers is valid whether or not it has a stage. + let elsewhere: Vec<u64> = ids.iter().flatten().filter(|id| doc.node(**id).is_none() && !objects.contains_key(&id.0)).map(|id| id.0).collect(); + let found = definition_objects(&doc, &elsewhere); + if let Some(id) = elsewhere.iter().find(|id| !found.contains_key(id)) { + return Err(EngineError::NoNode(NodeId(*id))); } - let ids: Option<Vec<u64>> = ids.map(|ids| ids.iter().map(|id| id.0).collect()); + // Only the objects with a stage have anything to refresh. + let ids: Option<Vec<u64>> = ids.map(|ids| { + let mut ids: Vec<u64> = ids.iter().map(|id| id.0).filter(|id| objects.contains_key(id)).collect(); + ids.sort_unstable(); + ids.dedup(); + ids + }); let counts = fx.refresh(ids.as_deref(), |targets| { let mut renderer = vectorcraft_render::Renderer::new(); let mut whole = Vec::new(); diff --git a/crates/engine/src/session/execute.rs b/crates/engine/src/session/execute.rs index abe9121eff..23079fe901 100644 --- a/crates/engine/src/session/execute.rs +++ b/crates/engine/src/session/execute.rs @@ -82,7 +82,13 @@ impl Session { && params.get("ids").is_none_or(Value::is_null) && params.get("id").and_then(Value::as_str).is_some_and(|e| !e.starts_with('$')); if cmd::params::id_stands_for_ids(spec.params) && !effect_named && self.active().is_some() { - cmd::given_objects(self, params, id)?; + // `effect.refreshDeferred` looks its objects up itself: they can also be in the art of + // a symbol, pattern, brush or opacity mask. + if id == "effect.refreshDeferred" { + cmd::given_object_ids(params, id)?; + } else { + cmd::given_objects(self, params, id)?; + } } if SELECTS_GIVEN_IDS.contains(&id) { self.check_given_ids(id, params)?; diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index 36dbc5ba8e..b1af923df4 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -1153,6 +1153,98 @@ fn deferred_stages_in_symbol_art_are_listed_and_refreshed() { assert_eq!(run(&mut s, "effect.deferredStatus", json!({}))["effects"], json!([])); } +/// `effect.refreshDeferred` takes the id of an object in the art of a symbol, pattern, brush or +/// opacity mask, as `effect.deferredStatus` lists it: one with a deferred stage is refreshed, and +/// one without (or whose stage is gone) adds nothing to the counts. The id of art that is no +/// longer in the document is an error. +#[test] +fn refresh_takes_objects_in_definition_art() { + let mut s = session(); + const FX: &str = "plugin.test.engine.deferred.nested"; + let out = json!({"objects": [{"path": square_at(110.0)}]}).to_string(); + install_wat(&mut s, &tpl::returning(&tpl::manifest("test.engine.deferred.nested", "effect", "{}"), &out)); + // A symbol whose art holds an object with the effect and one without. + let staged = rect(&mut s, 10.0, "#ff0000"); + let plain = rect(&mut s, 30.0, "#ff0000"); + run(&mut s, "effect.apply", json!({"ids": [staged.0], "effect": FX})); + run(&mut s, "select.set", json!({"ids": [staged.0, plain.0]})); + run(&mut s, "symbol.new", json!({"name": "Nested"})); + // A pattern, an art brush (its source deleted) and an opacity mask, none with a stage. + let tile = rect(&mut s, 50.0, "#00ff00"); + run(&mut s, "select.set", json!({"ids": [tile.0]})); + run(&mut s, "object.pattern.make", json!({"name": "Tile", "width": 20, "height": 20})); + run(&mut s, "object.pattern.done", json!({})); + let bar = rect(&mut s, 70.0, "#0000ff"); + run(&mut s, "select.set", json!({"ids": [bar.0]})); + run(&mut s, "brush.new", json!({"type": "art", "name": "Bar"})); + run(&mut s, "edit.clear", json!({"ids": [bar.0]})); + let masked = rect(&mut s, 90.0, "#ff0000"); + let mask = rect(&mut s, 90.0, "#ffffff"); + run(&mut s, "select.set", json!({"ids": [masked.0, mask.0]})); + run(&mut s, "transparency.makeOpacityMask", json!({})); + // A layer object with a stage that stays deferred, and one without. + let kept = rect(&mut s, 130.0, "#ff0000"); + run(&mut s, "effect.apply", json!({"ids": [kept.0], "effect": FX})); + let bare = rect(&mut s, 150.0, "#ff0000"); + + // The ids of the objects of `art` that are not in the layers. + let nested = |s: &Session, art: &vectorcraft_doc::Node| { + let d = &s.doc().unwrap().doc; + let mut ids = Vec::new(); + vectorcraft_doc::swatches::walk_with_masks(art, &mut |n| { + if d.node(n.id).is_none() { + ids.push(n.id.0); + } + }); + ids + }; + let d = s.doc().unwrap().doc.clone(); + let in_symbol = nested(&s, &d.symbols[0].art); + let in_pattern = d.patterns.iter().flat_map(|p| p.art.iter().flat_map(|a| nested(&s, a))).collect::<Vec<_>>(); + let brushes = vectorcraft_brush::library(&d); + let in_brush = brushes.iter().flat_map(|b| b.art().into_iter().flat_map(|a| nested(&s, a)).collect::<Vec<_>>()).collect::<Vec<_>>(); + let in_mask = d.node(masked).and_then(|n| n.extras().mask.as_ref()).map(|m| nested(&s, &m.art)).unwrap_or_default(); + for (what, ids) in [("symbol", &in_symbol), ("pattern", &in_pattern), ("brush", &in_brush), ("opacity mask", &in_mask)] { + assert!(!ids.is_empty(), "the {what} art has an object outside the layers"); + } + + let step = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(3_000)); + let fx = vectorcraft_plugins::deferred::Deferrals::with_clock(stepping_clock(&step)); + s.doc_mut().unwrap().plugin_effects = fx.clone(); + fx.view(|| drawn(&s)); + fx.first_view_done(); + let listed = |s: &mut Session| { + run(s, "effect.deferredStatus", json!({}))["effects"].as_array().unwrap().iter().map(|e| e["objectId"].as_u64().unwrap()).collect::<Vec<_>>() + }; + let deferred = listed(&mut s); + let [nested_staged, last] = deferred[..] else { panic!("two stages: {deferred:?}") }; + assert!(in_symbol.contains(&nested_staged) && last == kept.0, "{deferred:?} {in_symbol:?}"); + let Some(&nested_plain) = in_symbol.iter().find(|i| **i != nested_staged) else { panic!("{in_symbol:?}") }; + + // Objects without a stage, each on its own and together: nothing to refresh. + let none = json!({"refreshed": 0, "stillDeferred": 0, "failed": 0}); + let mut plain_ids = vec![nested_plain, in_pattern[0], in_brush[0], in_mask[0], bare.0]; + for id in &plain_ids { + assert_eq!(run(&mut s, "effect.refreshDeferred", json!({"id": id})), none, "object {id}"); + } + assert_eq!(run(&mut s, "effect.refreshDeferred", json!({"ids": plain_ids})), none); + assert_eq!(listed(&mut s), [nested_staged, kept.0]); + + // The staged object of the symbol's art, by its id, with the others. + step.store(0, std::sync::atomic::Ordering::Relaxed); + plain_ids.push(nested_staged); + assert_eq!(run(&mut s, "effect.refreshDeferred", json!({"ids": plain_ids})), json!({"refreshed": 1, "stillDeferred": 0, "failed": 0})); + assert_eq!(listed(&mut s), [kept.0]); + // Its stage is gone; the object is still there. + assert_eq!(run(&mut s, "effect.refreshDeferred", json!({"id": nested_staged})), none); + + // Art no longer in the document, and an id of no object. + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).patterns.clear(); + assert!(s.execute("effect.refreshDeferred", &json!({"id": in_pattern[0]})).is_err(), "the pattern is gone"); + assert!(s.execute("effect.refreshDeferred", &json!({"ids": [nested_plain, 999_999]})).is_err(), "not an object of the document"); + assert_eq!(listed(&mut s), [kept.0]); +} + /// `CRAFT_PLUGIN_PAINTS_WASM=<folder>` saves a test live effect to try in the app /// (`stroke-width-test-effect.wasm`): it draws a triangle over the area 100,80–300,230 while its /// object's stroke is 5 pt wide, and hands the geometry back otherwise. diff --git a/docs/mcp.md b/docs/mcp.md index b9c23ad2d1..544e00a0a2 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -7975,13 +7975,15 @@ drawings leave a deferred stage as it is until its input or parameters change, w the active document (`reason`: `budget`, `timeout`, `missing`, `failed` or `safeMode`; `item` is the fill or stroke whose effect it is; `objectId` can be an object in the art of a symbol, pattern, brush or opacity mask; an empty list when none is deferred). A stage whose object is deleted, or whose plug-in effect is -removed, moved from its place in the list or hidden (or whose fill or stroke is hidden), is no longer listed. `effect.refreshDeferred {ids?}` (Effect › Refresh +removed, moved from its place in the list or hidden (or whose fill or stroke is hidden), is no longer listed. `effect.refreshDeferred {ids?|id?}` (Effect › Refresh Deferred Effects, enabled while a stage is deferred) runs the deferred stages of those objects (default: all of them) again with a fresh 2,000 ms budget and returns `{refreshed, stillDeferred, failed}`: `stillDeferred` counts stages left out for want of time again, the stages of an object that is hidden or in a hidden group or layer, which is not drawn, and the stages with a part of their input too small for the drawing the refresh makes, and `failed` those that timed out, failed or have no plug-in. `id` names one object in place of -`ids`, and an entry that is not an object id of the document is an error. Each document has its own budget and its own deferred stages. Exports, Expand +`ids`. An entry can name an object in the document's layers or in the art of a symbol, pattern, brush or opacity mask; +an object without a deferred stage adds nothing to the counts, and an entry that is not an object of the document is +an error. Each document has its own budget and its own deferred stages. Exports, Expand Appearance and commands run every stage under the per-call limit, so an export never leaves out a stage that was only deferred for time, and the canvas then draws the result. The web build measures the budget and the per-call limit with the page's `performance.now()`. An exporter adds a write-only file format `plugin.<id>` (listed by `document.formats`): diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9c58bcaa8c..4d096c6d41 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1708,6 +1708,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **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`, a light filter with `progress` and no `commandAllowlist` runs in the background with the same progress window when it runs at once or OK runs it with Preview off, 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. - **Live-effect plug-ins paint their output (G17, #121):** a live effect whose manifest has `"api": 2` and `"paintOutput": true` returns pieces, each drawn by itself in order with fills and strokes of its own in the filter's paint forms (a piece without them keeps the object's; an empty list paints none; an effect on a fill returns only fills, one on a stroke only strokes). The whole result is checked first: limits, numbers in range with nothing clamped, known keys only, and the swatches and patterns it names in the document; one fault draws that step of the stack unchanged, keeps the effect record and puts the plug-in's name and the field in `plugin.info`'s `lastError`. The pieces are drawn, measured and exported to PDF and SVG from the appearance stack without changing the document, and Expand Appearance writes them as ordinary paths in one undo step. Effects without the key behave as before. Regressions: `crates/plugins/src/painted/tests.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. - **Live-effect plug-ins while a document opens (G17):** the live-effect plug-in runs of a document's first view on screen share a 2,000 ms budget (VectorCraft's own value). A stage left out for time, or one that times out, fails, has no plug-in or meets Safe Mode, draws its input unchanged and is deferred; the status bar says how many, `effect.deferredStatus` lists them with their object, effect and reason, and Effect › Refresh Deferred Effects (`effect.refreshDeferred`) runs them again with a fresh budget. Each document has its own budget, an edit runs the stages it changes, and exports, Expand Appearance and commands run every stage under the 5 s limit of each run. The web build times the budget and each run's limit with the page's `performance.now()`. Regressions: `crates/plugins/src/deferred.rs`, `crates/plugins/src/effect.rs`, `crates/plugins/src/host.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/canvas/tests.rs`. Scores and hours unchanged. +- **Refresh Deferred Effects by object (G17):** `effect.refreshDeferred` takes `ids`, or `id` for one, naming objects in the layers or in the art of a symbol, pattern, brush or opacity mask, the objects `effect.deferredStatus` lists; an object without a deferred stage adds nothing to the counts, and an id of no object in the document is an error. Regressions: `crates/engine/src/tests/plugins.rs`. Scores and hours unchanged. - **Plug-in settings and cache (G17, #2191):** at plug-in API level 2 a plug-in with the `settings` permission keeps settings of its own (`env.vc_settings_get`, `_set`, `_delete`) and one with `cache` a cache the user can clear (`env.vc_cache_read`, `_write`, `_clear`), under its id in the `Plug-ins` folder. Keys are plain names, never paths; VectorCraft's own caps refuse a write past 256 settings, 1 MiB of settings or 512 MiB of cache; a damaged settings file is moved aside and the settings start empty. `plugin.clearCache` empties a cache, `plugin.list` and `plugin.info` give `cacheBytes`, replacing a plug-in keeps its storage and `plugin.remove` deletes it. Another module under an id that is installed or has storage installs only with `replace: true`, which the app asks the user for; each storage call counts the whole settings file or cache entry it reads and writes toward the run's 1 GiB. Regressions: `crates/plugins/src/storage.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_clear_cache.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. - **Plug-in settings and cache totals in memory (#6228):** the settings stay in memory while `settings.json` keeps its size and modified time, a set to the same value writes nothing, every other change is written to the file before the call returns, and `cacheBytes` comes from a total kept while the `cache` folder keeps its modified time. Regressions: `crates/engine/src/tests/plugins.rs`. Scores and hours unchanged. - **Plug-in settings a failed removal can't move back:** when `plugin.remove` can't remove the grant and can't rename the plug-in's `.removed` folder back, its error names that folder, and the next load, install or removal of the plug-in renames it back instead of deleting it. Regressions: `crates/engine/src/tests/plugins.rs`. Scores and hours unchanged. From 78e8957f717343269e0a8dcaa5e6ad6e6b3d38b2 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 22:47:44 -0800 Subject: [PATCH 37/43] Data Recovery: a removal that Windows reports as missing names a file in the way On Windows, removing a copy whose path runs through a file reports the copy as missing, so the removal counted as done and the file in the way went unreported, while a listing names it. The removal now names that file, and a copy that is just not there still counts as removed. --- crates/engine/src/cmd/recovery.rs | 19 +++++++++++++++---- crates/engine/src/tests/recovery.rs | 18 ++++++++++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/recovery.rs b/crates/engine/src/cmd/recovery.rs index 136ba51ceb..7f5f33e34b 100644 --- a/crates/engine/src/cmd/recovery.rs +++ b/crates/engine/src/cmd/recovery.rs @@ -316,6 +316,20 @@ impl FolderStore { } } + /// The outcome `r` of removing file `f`. A file that is not there counts as removed, unless a + /// file is on the way to it: Windows reports that as a missing file, and it is named as for a + /// listing. + pub(crate) fn removed(f: &std::path::Path, r: std::io::Result<()>) -> std::result::Result<(), String> { + match r { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => match f.parent().and_then(Self::in_the_way) { + Some((p, true)) => Err(format!("{} is not a folder: {e}", p.display())), + _ => Ok(()), + }, + Err(e) => Err(Self::file_error(f, &e)), + Ok(()) => Ok(()), + } + } + /// Refuse area folder `dir` unless it is a folder itself, not a link to one, and (on Unix) /// belongs to this account. Listing areas leaves links out, so a name another account had /// linked to a folder of its own in a shared recovery folder looked free, and claiming it @@ -453,10 +467,7 @@ impl RecoveryStore for FolderStore { } fn remove(&self, name: &str) -> std::result::Result<(), String> { let f = self.file(name)?; - match std::fs::remove_file(&f) { - Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(Self::file_error(&f, &e)), - _ => Ok(()), - } + Self::removed(&f, std::fs::remove_file(&f)) } fn location(&self) -> String { self.0.to_string_lossy().into_owned() diff --git a/crates/engine/src/tests/recovery.rs b/crates/engine/src/tests/recovery.rs index 2b991e3320..0ba51f20a3 100644 --- a/crates/engine/src/tests/recovery.rs +++ b/crates/engine/src/tests/recovery.rs @@ -514,6 +514,24 @@ fn folder_errors_name_a_file_in_the_way() { assert!(wrong.is_empty(), "{wrong:?}"); } +/// A removal that finds the copy missing because a file is on the way to it (how Windows reports a +/// file in the way) names that file. A copy that is just not there still counts as removed. +#[test] +fn a_removal_reported_missing_names_a_file_in_the_way() { + let base = std::env::temp_dir().join(format!("vc-recovery-remove-in-way-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(&base).unwrap(); + let file = base.join("file"); + std::fs::write(&file, b"").unwrap(); + let missing = || std::io::Error::from(std::io::ErrorKind::NotFound); + let behind = FolderStore::removed(&file.join("recovery").join("1-1").join("Untitled-1-1.vectorcraft"), Err(missing())); + let gone = FolderStore::removed(&base.join("recovery").join("1-1").join("Untitled-1-1.vectorcraft"), Err(missing())); + let _ = std::fs::remove_dir_all(&base); + let named = format!("{} is not a folder: ", file.display()); + assert!(behind.as_ref().is_err_and(|e| e.starts_with(&named)), "{behind:?}"); + assert_eq!(gone, Ok(())); +} + /// A folder of the store's path that is missing and can't be made: a write or a lock names it as /// the folder it couldn't make, and a read names it as missing. An area folder that can't be made /// in a store folder that is there is named the same way. From 40283f4a380955ac22639e31bcd6354c5931b75c Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 22:49:15 -0800 Subject: [PATCH 38/43] Scripts: loose equality and the JSON space string keep a converted string rooted while it is flattened --- crates/js/src/builtins/json.rs | 3 ++- crates/js/src/interp.rs | 4 +++- crates/js/src/text_tests.rs | 19 ++++++++++++------- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/crates/js/src/builtins/json.rs b/crates/js/src/builtins/json.rs index 364ec99696..d32344946e 100644 --- a/crates/js/src/builtins/json.rs +++ b/crates/js/src/builtins/json.rs @@ -474,7 +474,8 @@ fn settings(interp: &mut Interp, replacer: &Value, space: &Value) -> JsResult<Se let n = to_integer(interp, &space)?.clamp(0.0, 10.0) as usize; s.gap = vec![u16::from(b' '); n]; } - Value::String(t) => s.gap = flat_charged(interp, &t)?.iter().take(10).copied().collect(), + // Rooted, so what the string holds is still counted while its copy is reserved. + Value::String(t) => s.gap = interp.rooted(&[Value::String(t.clone())], |i| flat_charged(i, &t))?.iter().take(10).copied().collect(), _ => {} } Ok(s) diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index 19d2eef22c..b399ebc237 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -2816,7 +2816,9 @@ impl Interp { | (Value::Bool(_), Value::Bool(_)) | (Value::Object(_), Value::Object(_)) => return Ok(strict_eq(&x, &y)), (Value::Number(n), Value::String(s)) | (Value::String(s), Value::Number(n)) => { - return Ok(*n == string_to_number(&flat_charged(self, s)?)); + // Rooted, so what the string holds is still counted while its copy is reserved. + let units = self.rooted(&[x.clone(), y.clone()], |i| flat_charged(i, s))?; + return Ok(*n == string_to_number(&units)); } (Value::Bool(v), _) => x = Value::Number(f64::from(u8::from(*v))), (_, Value::Bool(v)) => y = Value::Number(f64::from(u8::from(*v))), diff --git a/crates/js/src/text_tests.rs b/crates/js/src/text_tests.rs index 3d3455544e..6fccc085fd 100644 --- a/crates/js/src/text_tests.rs +++ b/crates/js/src/text_tests.rs @@ -361,13 +361,18 @@ fn a_converted_rope_counts_what_it_holds_while_its_copy_is_made() { let build = "var s = 'x'; for (var i = 0; i < 18; i++) s = s + s; function f(c) { return s + c; } \ function mk() { var a = f('a'); a[0]; return a + 'b'; }"; let limits = Limits { heap_bytes: 4 * 262_146, ..Limits::default() }; - let not_stopped: Vec<_> = ["+({ valueOf: mk })", "({})[{ toString: mk }]"] - .into_iter() - .filter(|read| { - let result = Realm::with_options(Options::default(), limits.clone()).run(&format!("{build} {read}; 0")); - !matches!(result, Err(ref e) if e.kind == RunErrorKind::Limit(LimitKind::Heap)) - }) - .collect(); + let not_stopped: Vec<_> = [ + "+({ valueOf: mk })", + "({})[{ toString: mk }]", + "({ valueOf: mk }) == 1", + "JSON.stringify(0, null, (function () { var o = new String(''); o.toString = mk; return o; })())", + ] + .into_iter() + .filter(|read| { + let result = Realm::with_options(Options::default(), limits.clone()).run(&format!("{build} {read}; 0")); + !matches!(result, Err(ref e) if e.kind == RunErrorKind::Limit(LimitKind::Heap)) + }) + .collect(); assert!(not_stopped.is_empty(), "not stopped by the heap limit: {not_stopped:?}"); } From f388a795ce2b7c6486e772a085d206d71783a294 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 22:49:43 -0800 Subject: [PATCH 39/43] Script checker: a private name that starts with a directive's name is read to its end Inside braces, a line such as `#include$size = 1` was taken for an `#include` line, because only the ASCII letters, digits and `_` after the `#` were compared with the directive names. The name now runs over every character a name may hold, written out or as an escape, before the comparison. --- crates/scriptcheck/src/lex.rs | 14 ++++++++++++-- crates/scriptcheck/src/tests.rs | 15 +++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/crates/scriptcheck/src/lex.rs b/crates/scriptcheck/src/lex.rs index 02d8c2de03..a9a805d87c 100644 --- a/crates/scriptcheck/src/lex.rs +++ b/crates/scriptcheck/src/lex.rs @@ -457,10 +457,20 @@ impl<'s> Lexer<'s> { /// A line that starts with `#` and then `rest` is a private name here, not a preprocessor line: /// the line is inside braces or in the code of an XML literal, and the name `rest` starts with - /// is not a directive's. + /// is not a directive's. The name runs over every character a name may hold, written out or as + /// an escape, so `#include$size` is not `#include`. pub fn private_line(&self, rest: &str) -> bool { let in_code = !self.braces.is_empty() || self.deeper_braces > 0 || !self.xml.is_empty(); - let end = rest.find(|c: char| !c.is_ascii_alphanumeric() && c != '_').unwrap_or(rest.len()); + let mut end = 0; + while let Some(more) = rest.get(end..) { + let len = match more.chars().next() { + Some('\\') => escape(more).filter(|&(c, _)| ident_part(c)).map(|(_, len)| len), + Some(c) if ident_part(c) => Some(c.len_utf8()), + _ => None, + }; + let Some(len) = len else { break }; + end += len; + } let name = rest.get(..end).unwrap_or_default(); in_code && !catalog::DIRECTIVES.iter().any(|d| d.eq_ignore_ascii_case(name)) } diff --git a/crates/scriptcheck/src/tests.rs b/crates/scriptcheck/src/tests.rs index 19cee61603..853f212c11 100644 --- a/crates/scriptcheck/src/tests.rs +++ b/crates/scriptcheck/src/tests.rs @@ -491,6 +491,21 @@ fn private_name_at_line_start_is_not_a_directive() { ); } +#[test] +fn private_name_that_starts_with_a_directive_name_is_not_a_directive() { + let r = report(concat!( + "class Box {\n", + " #include$size = 1;\n", + " #include\u{e9} = 2;\n", + " #include\\u0041 = 3;\n", + " #include\\u{41} = 4;\n", + " #INCLUDE 'a.jsxinc'\n", + "}\n", + )); + let directives: Vec<_> = r.directives.iter().map(|d| (d.name, d.line)).collect(); + assert_eq!(directives, [("include", 6)]); +} + #[test] fn xml_code_uses_escaped_names() { let r = report("var x = <a>{\\u{61}pp.activeDocument}</a>;\nvar y = <b>\\u0061pp.z \\u{61}</b>;\n"); From c2c560eeaaf9ff7d4ad36df2dcde0ca46a4ca19f Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 22:57:35 -0800 Subject: [PATCH 40/43] Scripts: a String object's character is read along its rope when the copy does not fit When the flattened copy of a String object's concatenated string would pass the heap limit before a collection can run, the indexed read walks the rope to the unit instead, taking a unit of fuel for each node it visits, so no copy is made. --- crates/js/src/interp.rs | 38 +++++++++++++++++++++--------- crates/js/src/text_tests.rs | 46 ++++++++++++++++++++----------------- crates/js/src/value.rs | 28 ++++++++++++++++++++++ 3 files changed, 80 insertions(+), 32 deletions(-) diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index b399ebc237..696df47784 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -209,7 +209,8 @@ pub(crate) struct Interp { /// Bytes of the copies [`Self::get_own_property`] made flattening the rope of a String /// object, which the heap's total adds before the next limit check. flattened: std::cell::Cell<usize>, - /// Fuel for the units those copies took, which the next step takes. + /// Fuel for the units those copies took and the rope nodes it read in place, which the next + /// step takes. pub(crate) flattened_fuel: std::cell::Cell<u64>, /// The global code's default XML namespace URI (ECMA-357 section 12.1). pub(crate) xml_default_uri: Key, @@ -1246,18 +1247,33 @@ impl Interp { if let Some(i) = array_index(k) && (i as usize) < s.len() { - if s.is_rope() { - // Flattening copies the units. The run ends here when the fuel for the - // copy is not left; otherwise the next step takes it, and the next limit - // check adds the copy's bytes. - let fuel = self.flattened_fuel.get().saturating_add((s.len() / crate::string_builtins::UNITS_PER_FUEL) as u64 + 1); - if fuel > self.fuel { - return Err(Abrupt::Limit(LimitKind::Fuel)); + let unit = if s.is_rope() { + let owed = self.flattened_fuel.get(); + let flattened = self.flattened.get().saturating_add(string_bytes(s.len())); + if self.heap.bytes.saturating_add(flattened) > self.limits.heap_bytes { + // The copy does not fit without a collection, which needs `&mut self`: + // the unit is read along the rope instead, a unit of fuel per node. + let Some((unit, visited)) = s.unit_at(i as usize, self.fuel.saturating_sub(owed)) else { + return Err(Abrupt::Limit(LimitKind::Fuel)); + }; + self.flattened_fuel.set(owed.saturating_add(visited)); + unit + } else { + // Flattening copies the units. The run ends here when the fuel for + // the copy is not left; otherwise the next step takes it, and the + // next limit check adds the copy's bytes. + let fuel = owed.saturating_add((s.len() / crate::string_builtins::UNITS_PER_FUEL) as u64 + 1); + if fuel > self.fuel { + return Err(Abrupt::Limit(LimitKind::Fuel)); + } + self.flattened_fuel.set(fuel); + self.flattened.set(flattened); + s.flat().get(i as usize).copied() } - self.flattened_fuel.set(fuel); - self.flattened.set(self.flattened.get().saturating_add(string_bytes(s.len()))); + } else { + s.flat().get(i as usize).copied() } - let unit = s.flat().get(i as usize).copied().unwrap_or(0); + .unwrap_or(0); return Ok(Some(Prop::Data { value: Value::String(Str::from_units(vec![unit])), flags: ENUMERABLE })); } } diff --git a/crates/js/src/text_tests.rs b/crates/js/src/text_tests.rs index 6fccc085fd..d2627cfb58 100644 --- a/crates/js/src/text_tests.rs +++ b/crates/js/src/text_tests.rs @@ -314,23 +314,15 @@ fn operators_and_builtins_count_a_flattened_rope_against_the_heap() { // Three ropes of about 524,288 units, each 1 MiB once flat, with a 3 MiB heap. let build = "var s = 'x'; for (var i = 0; i < 19; i++) s = s + s; var t = s + 'y', u = s + 'z';"; let limits = Limits { heap_bytes: 3 << 20, ..Limits::default() }; - let not_stopped: Vec<_> = [ - "X < 'a'", - "X == 1", - "+X", - "X[0]", - "new String(X)[0]", - "({})[{ toString: function () { return X; } }]", - "JSON.stringify(0, null, X)", - "[1].join(X)", - ] - .into_iter() - .filter(|read| { - let src = format!("{build} {}; {}; {}; 0", read.replace('X', "s"), read.replace('X', "t"), read.replace('X', "u")); - let result = Realm::with_options(Options::default(), limits.clone()).run(&src); - !matches!(result, Err(ref e) if e.kind == RunErrorKind::Limit(LimitKind::Heap)) - }) - .collect(); + let not_stopped: Vec<_> = + ["X < 'a'", "X == 1", "+X", "X[0]", "({})[{ toString: function () { return X; } }]", "JSON.stringify(0, null, X)", "[1].join(X)"] + .into_iter() + .filter(|read| { + let src = format!("{build} {}; {}; {}; 0", read.replace('X', "s"), read.replace('X', "t"), read.replace('X', "u")); + let result = Realm::with_options(Options::default(), limits.clone()).run(&src); + !matches!(result, Err(ref e) if e.kind == RunErrorKind::Limit(LimitKind::Heap)) + }) + .collect(); assert!(not_stopped.is_empty(), "not stopped by the heap limit: {not_stopped:?}"); } @@ -386,10 +378,22 @@ fn a_string_object_counts_its_flattened_rope_against_the_heap() { let lengths = Realm::with_options(Options::default(), limits.clone()).run(&format!("{build} Object(s).length + new String(t).length + Object(u).length")); assert_eq!(lengths, Ok(JsValue::Number(1_572_866.0))); - for read in ["Object(X)[0]", "new String(X)[0]", "0 in Object(X)", "Object(X).hasOwnProperty(0)", "X.first()"] { - let src = format!("{build} {}; {}; {}; 0", read.replace('X', "s"), read.replace('X', "t"), read.replace('X', "u")); - let e = fail_with(Realm::with_options(Options::default(), limits.clone()), &src); - assert_eq!(e.kind, RunErrorKind::Limit(LimitKind::Heap), "{read}: {e:?}"); + // Two copies fit. The third read finds no room for its copy and reads the unit along the + // rope, so the live heap stays within the limit. + for (read, expected) in [ + ("Object(X)[0]", "xxx"), + ("new String(X)[0]", "xxx"), + ("0 in Object(X)", "truetruetrue"), + ("Object(X).hasOwnProperty(0)", "truetruetrue"), + ("X.first()", "xxx"), + ("Object(X)[524288]", "undefinedyz"), + ] { + let src = format!("{build} '' + ({}) + ({}) + ({})", read.replace('X', "s"), read.replace('X', "t"), read.replace('X', "u")); + let mut realm = Realm::with_options(Options::default(), limits.clone()); + assert_eq!(realm.run(&src), Ok(JsValue::String(expected.into())), "{read}"); + let interp = realm.interp(); + interp.collect_garbage(); + assert!(interp.heap.bytes <= limits.heap_bytes, "{read}: {} bytes live", interp.heap.bytes); } let wrapped = Realm::with_options(Options::default(), Limits::default()).run(&format!("{build} Object(t)[524288] + new String(u)[524288] + t.first()")); diff --git a/crates/js/src/value.rs b/crates/js/src/value.rs index e164001ad9..a0fbcd4a53 100644 --- a/crates/js/src/value.rs +++ b/crates/js/src/value.rs @@ -163,6 +163,34 @@ impl Str { flat } + /// The code unit at `i` read along the rope without flattening it, visiting at most + /// `max_nodes` nodes: `None` when that is not enough, otherwise the unit (when `i` is in + /// range) and the number of nodes visited. + pub(crate) fn unit_at(&self, mut i: usize, max_nodes: u64) -> Option<(Option<u16>, u64)> { + let mut node = self.clone(); + let mut visited = 0u64; + loop { + visited += 1; + if visited > max_nodes { + return None; + } + let next = match &*node.0.borrow() { + Node::Flat(f) => return Some((f.get(i).copied(), visited)), + Node::Concat { left, right, .. } => { + let left_len = left.len(); + if i < left_len { + left.clone() + } else { + i -= left_len; + right.clone() + } + } + Node::Taken => return Some((None, visited)), + }; + node = next; + } + } + pub(crate) fn to_rust_string(&self) -> String { String::from_utf16_lossy(&self.flat()) } From e846710e85979ef140ca8f79077fbf295ace9e7d Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 23:02:26 -0800 Subject: [PATCH 41/43] MCP: the css.exportFile catalog entry matches its desktop description --- crates/mcp/src/ui_catalog.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index c6cf12857f..78b23a3020 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -1486,7 +1486,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "css.exportFile", "Export CSS…", "", - "{path?, scope?: selection|all (default selection), …css.selection options} css.export written to path, else a picked file (the web downloads it), with the pictures of rasterized art next to it (CSS Properties panel: Export Selected CSS…, Export All…) → {path, rules, images: [path…], warnings (css.export's; the status bar shows the first)}", + "{path?, scope?: selection|all (default selection), …css.selection options} css.export written to path, else a picked file (the web downloads it), with the pictures of rasterized art next to it (CSS Properties panel: Export Selected CSS…, Export All…) → {path, rules, images: [path…], warnings (css.export's; the status bar shows the first and how many there are, and hovering the message lists up to 30 of them, fewer when the window is small, with how many were left out)}", ), ( "file.print", From 49c8a380a1e2ed2f44b39d23fc1068cfea09cf5a Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 23:02:45 -0800 Subject: [PATCH 42/43] Scripts: flattening a rope keeps it rooted while its copy is reserved flat_charged and the property-key path now root the rope itself, so a string held only by native code (a getter's result for ++o.x, a valueOf result, a toString key) still counts what it holds when the reservation collects. The per-call roots for these cases are removed. --- crates/js/src/builtins/json.rs | 3 +-- crates/js/src/interp.rs | 17 +++++++---------- crates/js/src/string_builtins.rs | 5 +++-- crates/js/src/text_tests.rs | 1 + 4 files changed, 12 insertions(+), 14 deletions(-) diff --git a/crates/js/src/builtins/json.rs b/crates/js/src/builtins/json.rs index d32344946e..364ec99696 100644 --- a/crates/js/src/builtins/json.rs +++ b/crates/js/src/builtins/json.rs @@ -474,8 +474,7 @@ fn settings(interp: &mut Interp, replacer: &Value, space: &Value) -> JsResult<Se let n = to_integer(interp, &space)?.clamp(0.0, 10.0) as usize; s.gap = vec![u16::from(b' '); n]; } - // Rooted, so what the string holds is still counted while its copy is reserved. - Value::String(t) => s.gap = interp.rooted(&[Value::String(t.clone())], |i| flat_charged(i, &t))?.iter().take(10).copied().collect(), + Value::String(t) => s.gap = flat_charged(interp, &t)?.iter().take(10).copied().collect(), _ => {} } Ok(s) diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index 696df47784..ff821b2171 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -2626,8 +2626,7 @@ impl Interp { Value::String(s) => string_to_number(&flat_charged(self, s)?), Value::Object(_) => { let p = self.to_primitive(v, Hint::Number)?; - // Rooted, so what a string holds is still counted while its copy is reserved. - return self.rooted(std::slice::from_ref(&p), |i| i.to_number(&p)); + return self.to_number(&p); } }) } @@ -2662,19 +2661,19 @@ impl Interp { Value::Number(n) if *n >= 0.0 && *n < 4_294_967_295.0 && n.fract() == 0.0 => Ok(index_key(*n as u32)), _ => { let s = self.to_str(v)?; - // Rooted, so what the string holds is still counted while its copy is reserved. - self.rooted(&[Value::String(s.clone())], |i| i.flat_reserved(&s)) + self.flat_reserved(&s) } } } /// The code units of `s`. Flattening a rope makes a copy of its units, so for a rope that - /// copy's bytes are reserved and added to the heap first, without taking fuel. Everything - /// the caller still needs must be reachable from the roots. + /// copy's bytes are reserved and added to the heap first, without taking fuel. The rope stays + /// rooted while the copy is reserved; everything else the caller still needs must be + /// reachable from the roots. fn flat_reserved(&mut self, s: &Str) -> JsResult<Key> { if s.is_rope() { let bytes = string_bytes(s.len()); - self.reserve_heap(bytes)?; + self.rooted(&[Value::String(s.clone())], |i| i.reserve_heap(bytes))?; self.heap.bytes = self.heap.bytes.saturating_add(bytes); } Ok(s.flat()) @@ -2832,9 +2831,7 @@ impl Interp { | (Value::Bool(_), Value::Bool(_)) | (Value::Object(_), Value::Object(_)) => return Ok(strict_eq(&x, &y)), (Value::Number(n), Value::String(s)) | (Value::String(s), Value::Number(n)) => { - // Rooted, so what the string holds is still counted while its copy is reserved. - let units = self.rooted(&[x.clone(), y.clone()], |i| flat_charged(i, s))?; - return Ok(*n == string_to_number(&units)); + return Ok(*n == string_to_number(&flat_charged(self, s)?)); } (Value::Bool(v), _) => x = Value::Number(f64::from(u8::from(*v))), (_, Value::Bool(v)) => y = Value::Number(f64::from(u8::from(*v))), diff --git a/crates/js/src/string_builtins.rs b/crates/js/src/string_builtins.rs index b3758bdf54..e1baed6c23 100644 --- a/crates/js/src/string_builtins.rs +++ b/crates/js/src/string_builtins.rs @@ -109,10 +109,11 @@ fn push_held<T>(interp: &mut Interp, held: &mut HeldBytes, list: &mut Vec<T>, it } /// The code units of `s`. Flattening a rope makes a copy of its units, so for a rope that copy's -/// bytes are reserved and added to the heap, and its units charged as fuel, first. +/// bytes are reserved and added to the heap, and its units charged as fuel, first. The rope stays +/// rooted while the copy is reserved, so what it holds is still counted. pub(crate) fn flat_charged(interp: &mut Interp, s: &Str) -> JsResult<Rc<[u16]>> { if s.is_rope() { - reserve_units(interp, s.len())?; + interp.rooted(&[Value::String(s.clone())], |interp| reserve_units(interp, s.len()))?; } Ok(s.flat()) } diff --git a/crates/js/src/text_tests.rs b/crates/js/src/text_tests.rs index d2627cfb58..01fc613cc3 100644 --- a/crates/js/src/text_tests.rs +++ b/crates/js/src/text_tests.rs @@ -357,6 +357,7 @@ fn a_converted_rope_counts_what_it_holds_while_its_copy_is_made() { "+({ valueOf: mk })", "({})[{ toString: mk }]", "({ valueOf: mk }) == 1", + "++({ get x() { return mk(); } }).x", "JSON.stringify(0, null, (function () { var o = new String(''); o.toString = mk; return o; })())", ] .into_iter() From 44d5d20f5811ac34d5d2e88dcdd4eac94442bdeb Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 23:02:47 -0800 Subject: [PATCH 43/43] Paragraph Styles: Style Options edits a style's tab stops Paragraph Style Options and New Paragraph Style gain a Tabs section. It lists the style's stops and edits the selected stop's Alignment, position (X:, or Y: for vertical type), Leader and Align On character. Add puts a left stop 36 pt past the furthest one, Delete Tab removes the selected stop, and Clear All Tabs leaves the style with no stops. OK sorts the stops by position and keeps the stop edited last when two share one position. Reset Panel removes the style's tabs. paraStyle.new, paraStyle.setAttrs and paraStyle.edit check changed tabs stop by stop: at most 256 stops, and each changed stop's position from 0 to 100000 pt and leader of at most 8 characters. Stops the style already has are kept as they are. Changed tabs are sorted by position, keeping the first of stops at one position. text.tabs.set rejects a leader longer than 8 characters, an Align On value of more than one character and more than 256 stops. Loading paragraph styles skips a style whose indents, spacing, hyphenation, justification or tabs are out of range. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/doc/src/text/tabs_lists.rs | 9 + crates/engine/src/cmd/tabs.rs | 44 ++- crates/engine/src/cmd/textstyles.rs | 72 +++- crates/engine/src/cmd/textstyles/tests.rs | 74 +++++ crates/ui-egui/src/dialogs/new_text_style.rs | 1 + .../ui-egui/src/dialogs/para_style_options.rs | 307 +++++++++++++++++- .../src/dialogs/para_style_options/tests.rs | 245 +++++++++++++- crates/ui-egui/src/i18n/ca.tsv | 5 + crates/ui-egui/src/i18n/cs.tsv | 5 + crates/ui-egui/src/i18n/de.tsv | 5 + crates/ui-egui/src/i18n/el.tsv | 5 + crates/ui-egui/src/i18n/es.tsv | 5 + crates/ui-egui/src/i18n/fi.tsv | 5 + crates/ui-egui/src/i18n/fr.tsv | 5 + crates/ui-egui/src/i18n/hi.tsv | 4 + crates/ui-egui/src/i18n/hu.tsv | 5 + crates/ui-egui/src/i18n/it.tsv | 5 + crates/ui-egui/src/i18n/ja.tsv | 5 + crates/ui-egui/src/i18n/ka.tsv | 5 + crates/ui-egui/src/i18n/ko.tsv | 5 + crates/ui-egui/src/i18n/pl.tsv | 5 + crates/ui-egui/src/i18n/pt-br.tsv | 5 + crates/ui-egui/src/i18n/ru.tsv | 5 + crates/ui-egui/src/i18n/sv.tsv | 4 + crates/ui-egui/src/i18n/tr.tsv | 5 + crates/ui-egui/src/i18n/uk.tsv | 5 + crates/ui-egui/src/i18n/vi.tsv | 5 + crates/ui-egui/src/i18n/zh-hans.tsv | 5 + crates/ui-egui/src/i18n/zh-hant.tsv | 5 + crates/ui-egui/src/panels/tabs.rs | 2 +- docs/mcp.md | 23 +- docs/target-app-parity.md | 3 +- 32 files changed, 845 insertions(+), 43 deletions(-) diff --git a/crates/doc/src/text/tabs_lists.rs b/crates/doc/src/text/tabs_lists.rs index c4ec92b70b..5ed95b78e1 100644 --- a/crates/doc/src/text/tabs_lists.rs +++ b/crates/doc/src/text/tabs_lists.rs @@ -28,6 +28,15 @@ pub struct TabStop { pub align_on: char, } +impl TabStop { + /// The largest stop position `text.tabs.set` and a paragraph style take, in points. + pub const MAX_POSITION: f64 = 100_000.0; + /// The most leader characters a stop repeats. + pub const MAX_LEADER: usize = 8; + /// The most stops `text.tabs.set` and a paragraph style take in one list. + pub const MAX_STOPS: usize = 256; +} + fn default_align_on() -> char { '.' } diff --git a/crates/engine/src/cmd/tabs.rs b/crates/engine/src/cmd/tabs.rs index 331227e3be..04a2c5504a 100644 --- a/crates/engine/src/cmd/tabs.rs +++ b/crates/engine/src/cmd/tabs.rs @@ -13,7 +13,7 @@ pub fn specs() -> Vec<CommandSpec> { "Set Tab Stops", ["Window", "Tabs"], None, - "{stops: [{position: pt, align?: left|center|right|decimal, leader?: \". \", alignOn?: \".\"}], ids?|id?, start?: byte, end?: byte} replace the tab stops of the selected text (with a range: of the paragraphs it touches); supplied align, leader and alignOn must be strings, and alignOn cannot be empty", + "{stops: [{position: pt, align?: left|center|right|decimal, leader?: \". \", alignOn?: \".\"}], ids?|id?, start?: byte, end?: byte} replace the tab stops of the selected text (with a range: of the paragraphs it touches); at most 256 stops, positions 0..=100000 pt; supplied align, leader and alignOn must be strings, a leader has at most 8 characters and alignOn exactly one; stops are sorted by position, and of stops at one position the first given is kept", has_selection, set ), @@ -38,10 +38,14 @@ pub fn specs() -> Vec<CommandSpec> { fn parse_stops(p: &Value) -> Result<Vec<TabStop>> { const C: &str = "text.tabs.set"; let arr = p.get("stops").and_then(Value::as_array).ok_or_else(|| bad(C, "missing stops"))?; + let max = TabStop::MAX_STOPS; + if arr.len() > max { + return Err(bad(C, format!("at most {max} tab stops"))); + } let mut stops = vec![]; for s in arr { let position = s.get("position").and_then(Value::as_f64).ok_or_else(|| bad(C, "each stop needs a position"))?; - if !(0.0..=100_000.0).contains(&position) { + if !(0.0..=TabStop::MAX_POSITION).contains(&position) { return Err(bad(C, "positions must be between 0 and 100000 pt")); } let align = match s.get("align") { @@ -60,16 +64,25 @@ fn parse_stops(p: &Value) -> Result<Vec<TabStop>> { None | Some(Value::Null) => "", Some(Value::String(value)) => value.as_str(), Some(_) => return Err(bad(C, "`leader` must be a string")), + }; + let (label, max) = ("`leader`", TabStop::MAX_LEADER); + if leader.chars().count() > max { + return Err(bad(C, format!("{label} must be at most {max} characters"))); } - .chars() - .take(8) - .collect(); let align_on = match s.get("alignOn") { None | Some(Value::Null) => '.', - Some(Value::String(value)) => value.chars().next().ok_or_else(|| bad(C, "`alignOn` cannot be empty"))?, + Some(Value::String(value)) => { + let mut chars = value.chars(); + let c = chars.next().ok_or_else(|| bad(C, "`alignOn` cannot be empty"))?; + if chars.next().is_some() { + let label = "`alignOn`"; + return Err(bad(C, format!("{label} must be one character"))); + } + c + } Some(_) => return Err(bad(C, "`alignOn` must be a string")), }; - stops.push(TabStop { position, align, leader, align_on }); + stops.push(TabStop { position, align, leader: leader.to_string(), align_on }); } stops.sort_by(|a, b| a.position.total_cmp(&b.position)); stops.dedup_by(|a, b| (a.position - b.position).abs() < 1e-6); @@ -128,7 +141,7 @@ fn get(s: &mut Session, p: &Value) -> Result<Value> { #[cfg(test)] mod tests { - use serde_json::json; + use serde_json::{Value, json}; use crate::Session; @@ -139,6 +152,21 @@ mod tests { } } + /// A leader of more than eight characters, an `alignOn` of more than one character and a list + /// of more than 256 stops are errors, not cut short. + #[test] + fn long_leaders_long_align_on_and_too_many_stops_are_errors() { + let err = |stops: Value| super::parse_stops(&json!({ "stops": stops })).unwrap_err().to_string(); + assert!(err(json!([{"position": 40, "leader": "123456789"}])).contains("`leader` must be at most 8 characters")); + assert!(err(json!([{"position": 40, "alignOn": ",."}])).contains("`alignOn` must be one character")); + let many: Vec<Value> = (0..257).map(|i| json!({"position": i})).collect(); + assert!(err(Value::from(many.clone())).contains("at most 256 tab stops")); + let ok = super::parse_stops(&json!({ "stops": &many[..256] })).unwrap(); + assert_eq!(ok.len(), 256); + let ok = super::parse_stops(&json!({"stops": [{"position": 0, "leader": "12345678", "alignOn": "€"}]})).unwrap(); + assert_eq!((ok[0].leader.as_str(), ok[0].align_on), ("12345678", '€')); + } + #[test] fn set_get_clear_tabs() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/textstyles.rs b/crates/engine/src/cmd/textstyles.rs index d30e69df68..dc424e1157 100644 --- a/crates/engine/src/cmd/textstyles.rs +++ b/crates/engine/src/cmd/textstyles.rs @@ -8,7 +8,7 @@ use serde::Serialize; use serde::de::DeserializeOwned; use serde_json::{Map, Value, json}; -use vectorcraft_doc::{CharStyle, Document, Node, NodeId, NodeKind, ParaStyle, TextStyleDef}; +use vectorcraft_doc::{CharStyle, Document, Node, NodeId, NodeKind, ParaStyle, TabStop, TextStyleDef}; use super::clipboard::resources::import_text_styles; use super::typecmd::{refresh_bounds, text_targets}; @@ -125,7 +125,7 @@ macro_rules! style_cmds { } /// The params of `charStyle.load`, `paraStyle.load` and `textStyle.loadAll`. -const LOAD_PARAMS: &str = "{path? | data?: file text | dataBase64?, name?: file name (default: the path's)} copy the styles of this kind (textStyle.loadAll: character and paragraph styles) from a document VectorCraft opens (see document.formats) into this one, after its own, in one undo step, with the swatches and patterns they paint with, the symbols and brushes those patterns use, and the styles of either kind that type in those patterns and symbols is set in, as Paste brings those of pasted type (a global or spot color whose name this document gives another color is this document's). A style that is the same as the style here of its name, or of its name with a number (`Body 2`), isn't added again; any other is added under a free name (`Body 2`). The file's Normal styles aren't loaded, and a style whose attributes don't read or are out of the range charStyle.new takes, or whose name is empty, is skipped → {added: [names here], existing: [names already here], skipped: [names in the file]} (textStyle.loadAll: {charStyles: {…}, paraStyles: {…}}); charStyle.load and paraStyle.load list only the styles of their own kind"; +const LOAD_PARAMS: &str = "{path? | data?: file text | dataBase64?, name?: file name (default: the path's)} copy the styles of this kind (textStyle.loadAll: character and paragraph styles) from a document VectorCraft opens (see document.formats) into this one, after its own, in one undo step, with the swatches and patterns they paint with, the symbols and brushes those patterns use, and the styles of either kind that type in those patterns and symbols is set in, as Paste brings those of pasted type (a global or spot color whose name this document gives another color is this document's). A style that is the same as the style here of its name, or of its name with a number (`Body 2`), isn't added again; any other is added under a free name (`Body 2`). The file's Normal styles aren't loaded, and a style whose attributes don't read or are out of the range charStyle.new or paraStyle.new takes, or whose name is empty, is skipped → {added: [names here], existing: [names already here], skipped: [names in the file]} (textStyle.loadAll: {charStyles: {…}, paraStyles: {…}}); charStyle.load and paraStyle.load list only the styles of their own kind"; pub fn specs() -> Vec<CommandSpec> { let mut v = style_cmds!( @@ -148,13 +148,13 @@ pub fn specs() -> Vec<CommandSpec> { "paraStyle", "Paragraph Styles", "Paragraph", - "; changed numeric attrs: indents, space_before and space_after -1296..=1296 pt; hyphenation words_longer_than 1..=25, after_first and before_last 1..=15, hyphen_limit 0..=25, zone 0..=1296 pt; justification word_spacing 0..=1000%, letter_spacing -100..=500%, glyph_scaling 50..=200% (each [min, desired, max] in order), auto_leading 0..=500%; tab positions 0..=100000 pt" + "; changed numeric attrs: indents, space_before and space_after -1296..=1296 pt; hyphenation words_longer_than 1..=25, after_first and before_last 1..=15, hyphen_limit 0..=25, zone 0..=1296 pt; justification word_spacing 0..=1000%, letter_spacing -100..=500%, glyph_scaling 50..=200% (each [min, desired, max] in order), auto_leading 0..=500%; changed tabs: at most 256 stops, each changed stop's position 0..=100000 pt and leader at most 8 characters, sorted by position keeping the first of stops at one position" )); v.push(spec( "paraStyle.edit", "Edit Paragraph Style", &[], - "{name, to, attrs: {…}} replace name and sparse attributes in one undo step; users keep local overrides; changed numeric attrs: indents, space_before and space_after -1296..=1296 pt; hyphenation words_longer_than 1..=25, after_first and before_last 1..=15, hyphen_limit 0..=25, zone 0..=1296 pt; justification word_spacing 0..=1000%, letter_spacing -100..=500%, glyph_scaling 50..=200% (each [min, desired, max] in order), auto_leading 0..=500%; tab positions 0..=100000 pt", + "{name, to, attrs: {…}} replace name and sparse attributes in one undo step; users keep local overrides; changed numeric attrs: indents, space_before and space_after -1296..=1296 pt; hyphenation words_longer_than 1..=25, after_first and before_last 1..=15, hyphen_limit 0..=25, zone 0..=1296 pt; justification word_spacing 0..=1000%, letter_spacing -100..=500%, glyph_scaling 50..=200% (each [min, desired, max] in order), auto_leading 0..=500%; changed tabs: at most 256 stops, each changed stop's position 0..=100000 pt and leader at most 8 characters, sorted by position keeping the first of stops at one position", |s, p| edit_style(s, p, Kind::Para), true, )); @@ -569,8 +569,9 @@ fn attrs_param(d: &Document, p: &Value, kind: Kind, verb: &str, kept: Option<&Ma char_attrs(&st) } Kind::Para => { - let st = with_attrs(&ParaStyle::default(), m, &c)?; + let mut st = with_attrs(&ParaStyle::default(), m, &c)?; para_ranges(&st, m, kept, &c)?; + sort_tabs(&mut st, m, kept); para_attrs(&st) } }; @@ -587,7 +588,8 @@ const PARA_DISTANCE: f64 = 1296.0; /// Check the paragraph attributes of `st` (read from `attrs`) that differ from `kept` against the /// ranges `text.setFormat` keeps them to: the indents and the space before and after, each /// Hyphenation option, each Justification option (Minimum, Desired and Maximum in order, as -/// `attrs` gives them) and the position of each tab stop. A value `kept` has is not checked. +/// `attrs` gives them), and the number of tab stops with the position and leader of each stop +/// `kept` does not hold. A value `kept` has is not checked. fn para_ranges(st: &ParaStyle, attrs: &Map<String, Value>, kept: Option<&Map<String, Value>>, c: &str) -> Result<()> { let within = |key: &str, value: f64, lo: f64, hi: f64| { if (lo..=hi).contains(&value) { Ok(()) } else { Err(bad(c, format!("`{key}` must be from {lo} to {hi}"))) } @@ -644,18 +646,37 @@ fn para_ranges(st: &ParaStyle, attrs: &Map<String, Value>, kept: Option<&Map<Str } } if changed("tabs") { - let kept_tabs: Vec<f64> = kept - .and_then(|k| k.get("tabs")) - .and_then(Value::as_array) - .map(|stops| stops.iter().filter_map(|s| s.get("position").and_then(Value::as_f64)).collect()) - .unwrap_or_default(); - for stop in st.tabs.iter().filter(|stop| !kept_tabs.contains(&stop.position)) { - within("tabs.position", stop.position, 0.0, 100_000.0)?; + let kept_tabs = kept_tab_stops(kept); + let max = TabStop::MAX_STOPS; + if st.tabs.len() > max.max(kept_tabs.len()) { + return Err(bad(c, format!("at most {max} tab stops"))); + } + for stop in st.tabs.iter().filter(|stop| !kept_tabs.contains(stop)) { + within("tabs.position", stop.position, 0.0, TabStop::MAX_POSITION)?; + let (label, max) = ("`tabs.leader`", TabStop::MAX_LEADER); + if stop.leader.chars().count() > max { + return Err(bad(c, format!("{label} must be at most {max} characters"))); + } } } Ok(()) } +/// The tab stops `kept` holds, each as it reads (a stop that does not read is left out). +fn kept_tab_stops(kept: Option<&Map<String, Value>>) -> Vec<TabStop> { + let stops = kept.and_then(|k| k.get("tabs")).and_then(Value::as_array); + stops.into_iter().flatten().filter_map(|s| serde_json::from_value(s.clone()).ok()).collect() +} + +/// `st`'s tab stops sorted by position, keeping the first of stops within 1e-6 pt of each other, +/// as `text.tabs.set` keeps them, when `attrs` changes them from `kept`. +fn sort_tabs(st: &mut ParaStyle, attrs: &Map<String, Value>, kept: Option<&Map<String, Value>>) { + if attrs.contains_key("tabs") && kept.and_then(|k| k.get("tabs")) != attrs.get("tabs") { + st.tabs.sort_by(|a, b| a.position.total_cmp(&b.position)); + st.tabs.dedup_by(|a, b| (a.position - b.position).abs() < 1e-6); + } +} + /// Check a character style's `fill` or `stroke` (`key`): each solid color and gradient stop has a /// tint from 0 to 1, and a link to a global swatch of `d`, whose color at that tint it takes. fn style_paint(d: &Document, paint: &mut vectorcraft_color::Paint, key: &str, c: &str) -> Result<()> { @@ -1011,10 +1032,29 @@ fn unused(s: &mut Session, kind: Kind) -> Result<Value> { /// The styles of `kind` in `src` that load, with their source names so that art in the file keeps /// referring to them: not the Normal style, and not one whose attributes don't read, whose sizes, -/// scales, leading, shifts or stroke width are out of the range `charStyle.new` takes, or whose name -/// is empty, which are skipped. → (the styles, the names in `src` of those skipped) +/// scales, leading, shifts or stroke width are out of the range `charStyle.new` takes, whose +/// indents, spacing, Hyphenation or Justification options or tab stops are out of the range +/// `paraStyle.new` takes, or whose name is empty, which are skipped. → (the styles, the names in +/// `src` of those skipped) fn loadable(src: &Document, kind: Kind) -> (Vec<TextStyleDef>, Vec<String>) { - const RANGED: [&str; 8] = ["size", "h_scale", "v_scale", "leading", "baseline_shift", "tcyUpDown", "tcyLeftRight", "stroke_width"]; + const RANGED: [&str; 16] = [ + "size", + "h_scale", + "v_scale", + "leading", + "baseline_shift", + "tcyUpDown", + "tcyLeftRight", + "stroke_width", + "left_indent", + "right_indent", + "first_line_indent", + "space_before", + "space_after", + "hyphenation", + "justification", + "tabs", + ]; let (mut defs, mut skipped) = (vec![], vec![]); for def in kind.defs(src) { let name = def.name.as_str(); diff --git a/crates/engine/src/cmd/textstyles/tests.rs b/crates/engine/src/cmd/textstyles/tests.rs index 87bca29048..e38798401e 100644 --- a/crates/engine/src/cmd/textstyles/tests.rs +++ b/crates/engine/src/cmd/textstyles/tests.rs @@ -1729,3 +1729,77 @@ fn paragraph_style_attributes_are_checked_before_a_definition_changes() { assert!(error.contains(&format!("`{key}`")), "{attrs}: {error}"); } } + +/// A paragraph style's changed tab stops are checked stop by stop: a stop the style holds is kept +/// as it is, and a stop at a held position with another alignment is a changed stop. A changed +/// stop's leader has at most eight characters and its Align On one character, a changed list has +/// at most 256 stops (or as many as the style holds, here 300), and the stops are stored sorted by position, +/// keeping the first of stops at one position. +#[test] +fn paragraph_style_tab_stops_are_checked_by_stop_and_sorted() { + let (mut s, _) = session_with_text("Text"); + s.execute("paraStyle.new", &json!({"name": "Body", "attrs": {}})).unwrap(); + let def = |s: &Session| s.doc().unwrap().doc.para_styles.iter().find(|d| d.name == "Body").unwrap().attrs.clone(); + let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let held: Vec<Value> = + (0..300).map(|i| json!({"position": 200000.0 + f64::from(i), "align": "left", "leader": "123456789", "alignOn": "."})).collect(); + doc.para_styles.iter_mut().find(|d| d.name == "Body").unwrap().attrs.insert("tabs".into(), Value::from(held.clone())); + let before = s.doc().unwrap().doc.clone(); + let mut moved = held.clone(); + moved[0]["align"] = json!("right"); + for (stops, why) in [ + (Value::from(moved), "`tabs.position`"), + (json!([{"position": 36.0, "leader": "123456789"}]), "`tabs.leader` must be at most 8 characters"), + (Value::from((0..301).map(|i| json!({"position": f64::from(i)})).collect::<Vec<_>>()), "at most 256 tab stops"), + (json!([{"position": 36.0, "alignOn": ",."}]), "expected a character"), + ] { + for cmd in ["paraStyle.edit", "paraStyle.setAttrs"] { + let params = match cmd { + "paraStyle.edit" => json!({"name": "Body", "to": "Body", "attrs": {"tabs": stops}}), + _ => json!({"name": "Body", "attrs": {"tabs": stops}}), + }; + let error = s.execute(cmd, ¶ms).unwrap_err().to_string(); + assert!(error.contains(why), "{cmd}: {error}"); + } + } + let many = Value::from((0..257).map(|i| json!({"position": f64::from(i)})).collect::<Vec<_>>()); + let error = s.execute("paraStyle.new", &json!({"name": "Other", "attrs": {"tabs": many}})).unwrap_err().to_string(); + assert!(error.contains("at most 256 tab stops"), "{error}"); + assert_eq!(s.doc().unwrap().doc, before); + // The held stops stay while one is dropped and another attribute changes. + s.execute("paraStyle.setAttrs", &json!({"name": "Body", "attrs": {"tabs": &held[1..], "left_indent": 4.0}})).unwrap(); + assert_eq!(def(&s)["tabs"].as_array().map(Vec::len), Some(299)); + let stops = json!([{"position": 72.0, "align": "right"}, {"position": 36.0}, {"position": 36.0000001, "align": "center"}, {"position": 0.0, "leader": "12345678"}]); + s.execute("paraStyle.setAttrs", &json!({"name": "Body", "attrs": {"tabs": stops}})).unwrap(); + assert_eq!( + def(&s)["tabs"], + json!([{"position": 0.0, "align": "left", "leader": "12345678", "alignOn": "."}, {"position": 36.0, "align": "left", "alignOn": "."}, + {"position": 72.0, "align": "right", "alignOn": "."}]) + ); + s.execute("paraStyle.setAttrs", &json!({"name": "Body", "attrs": {"tabs": []}})).unwrap(); + assert_eq!(def(&s)["tabs"], json!([]), "an empty list stays set"); +} + +/// Load Paragraph Styles and Load All Styles skip a paragraph style whose indents, spacing, +/// Hyphenation or Justification options or tab stops are out of the range `paraStyle.new` takes, +/// and load the others with their stops. +#[test] +fn loading_skips_paragraph_styles_out_of_range() { + let file = [ + ("Good", json!({"left_indent": 6.0, "tabs": [{"position": 36.0, "align": "decimal", "alignOn": ","}]})), + ("Indent", json!({"left_indent": 2000.0})), + ("Space", json!({"space_after": -1300.0})), + ("Hyphens", json!({"hyphenation": {"words_longer_than": 40}})), + ("Spacing", json!({"justification": {"word_spacing": [120.0, 100.0, 133.0]}})), + ("Leader", json!({"tabs": [{"position": 10.0, "leader": "123456789"}]})), + ]; + let data = styles_file(&[], &file); + for cmd in ["paraStyle.load", "textStyle.loadAll"] { + let (mut s, _) = session_with_text("Title"); + let r = s.execute(cmd, &json!({"dataBase64": data, "name": "Other.vectorcraft"})).unwrap(); + let r = if cmd == "textStyle.loadAll" { r["paraStyles"].clone() } else { r }; + assert_eq!(r, json!({"added": ["Good"], "existing": [], "skipped": ["Indent", "Space", "Hyphens", "Spacing", "Leader"]}), "{cmd}"); + let good = super::style_attrs(&s.doc().unwrap().doc, true, "Good").unwrap(); + assert_eq!(good["tabs"], json!([{"position": 36.0, "align": "decimal", "alignOn": ","}]), "{cmd}"); + } +} diff --git a/crates/ui-egui/src/dialogs/new_text_style.rs b/crates/ui-egui/src/dialogs/new_text_style.rs index 91731006f1..4978a6a1f6 100644 --- a/crates/ui-egui/src/dialogs/new_text_style.rs +++ b/crates/ui-egui/src/dialogs/new_text_style.rs @@ -40,6 +40,7 @@ pub fn open(app: &mut VectorcraftApp, para: bool, target: Value) -> Result<Value fields.insert("section".into(), json!("general")); let attrs = target.get("attrs").and_then(Value::as_object).cloned().unwrap_or_default(); fields.extend(super::para_style_options::form_fields(&attrs, app.session.general_unit())); + fields.insert("__vertical".into(), json!(super::para_style_options::vertical_text(app))); } else { fields.insert("section".into(), json!("general")); fields.insert("__smallCapsSize".into(), json!(d.setup.small_caps_size)); diff --git a/crates/ui-egui/src/dialogs/para_style_options.rs b/crates/ui-egui/src/dialogs/para_style_options.rs index ffd0a23682..34a623cc5c 100644 --- a/crates/ui-egui/src/dialogs/para_style_options.rs +++ b/crates/ui-egui/src/dialogs/para_style_options.rs @@ -1,18 +1,20 @@ //! Paragraph Style Options fields shared by editing and creating a paragraph style: General, Basic -//! Paragraph Formats (alignment, indents and spacing), Composition (composer and Roman Hanging -//! Punctuation), Hyphenation and Justification. +//! Paragraph Formats (alignment, indents and spacing), Tabs (the style's tab stops), Composition +//! (composer and Roman Hanging Punctuation), Hyphenation and Justification. //! //! Each field is unset (empty, or a checkbox in its unset state) or set, and an unset field leaves //! its attribute out of the style. Distances are shown and typed in the General unit and kept in //! points. The Hyphenation and Justification options are one attribute each (`hyphenation`, //! `justification`): when any of their fields is set, a field left empty takes its default value. -//! A field still as it opened keeps the style's stored value as it is. +//! The `tabs` field is unset (null) or a list of stops, an empty list included, and is the `tabs` +//! attribute. A field still as it opened keeps the style's stored value as it is. //! //! Behavior reference (public documentation): //! <https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html>. use serde_json::{Map, Value, json}; -use vectorcraft_doc::{HyphenationOptions, JustificationOptions, Unit}; +use vectorcraft_doc::text::DEFAULT_TAB_INTERVAL; +use vectorcraft_doc::{HyphenationOptions, JustificationOptions, TabAlign, TabStop, Unit}; use super::{DialogSpec, form}; use crate::VectorcraftApp; @@ -21,7 +23,11 @@ use crate::state::Dialog; pub const KIND: &str = "paraStyleOptions"; /// The section ids, in the order the section list shows them. -pub(crate) const SECTIONS: [&str; 5] = ["general", "basic", "composition", "hyphenation", "justification"]; +pub(crate) const SECTIONS: [&str; 6] = ["general", "basic", "tabs", "composition", "hyphenation", "justification"]; +/// The alignments of a tab stop with the names the `tabs` field gives them, in the Tabs panel's +/// order. +const TAB_ALIGNS: [(TabAlign, &str); 4] = + [(TabAlign::Left, "left"), (TabAlign::Center, "center"), (TabAlign::Right, "right"), (TabAlign::Decimal, "decimal")]; /// The distance fields of Basic Paragraph Formats, which are also the attributes they set. const DISTANCES: [&str; 5] = ["left_indent", "right_indent", "first_line_indent", "space_before", "space_after"]; /// The range of the indents, the space before and after, and the Hyphenation Zone (pt), as @@ -98,6 +104,7 @@ pub(super) const SPEC: DialogSpec = DialogSpec { "glyph_max", "auto_leading", "single_word", + "tabs", "section", ] }, @@ -112,6 +119,7 @@ pub fn open(app: &mut VectorcraftApp, name: &str) -> Result<Value, String> { let doc = &app.session.active().ok_or("no document open")?.doc; let def = doc.para_styles.iter().find(|s| s.name == name).ok_or_else(|| format!("no style `{name}`"))?; let mut fields = form_fields(&def.attrs, unit); + fields.insert("__vertical".into(), json!(vertical_text(app))); fields.insert("name".into(), json!(name)); fields.insert("__style".into(), json!(name)); fields.insert("section".into(), json!("general")); @@ -129,6 +137,76 @@ pub(crate) fn form_fields(attrs: &Map<String, Value>, unit: Unit) -> Map<String, fields } +/// Whether the first selected text is vertical type, where the Tabs section names a stop's +/// position Y and its Left and Right alignments Top and Bottom. +pub(crate) fn vertical_text(app: &VectorcraftApp) -> bool { + crate::panels::character::first_selected_text(app).is_some_and(|t| t.vertical) +} + +/// The `tabs` field for a style's `tabs` attribute: unset (null) when the style leaves it out, +/// otherwise one entry per stop ([`stop_field`]). +fn tabs_field(value: Option<&Value>, unit: Unit) -> Value { + let Some(stops) = value.and_then(Value::as_array) else { return Value::Null }; + Value::Array(stops.iter().filter_map(|s| serde_json::from_value::<TabStop>(s.clone()).ok()).map(|s| stop_field(&s, unit)).collect()) +} + +/// A tab stop's entry in the `tabs` field: its position as text in `unit`, its alignment, leader +/// and Align On character, and the position in points it shows (`__pt`), which a position still as +/// it shows keeps exactly. +fn stop_field(s: &TabStop, unit: Unit) -> Value { + let align = TAB_ALIGNS.iter().find(|(a, _)| *a == s.align).map_or("left", |(_, key)| *key); + json!({"position": unit.format(s.position), "align": align, "leader": s.leader, "alignOn": s.align_on.to_string(), "__pt": s.position}) +} + +/// The position in points of a `tabs` entry: its `__pt` while its text is as that shows in `unit`, +/// else the text read in `unit`; `None` when the text is not a distance. +fn stop_position(row: &Value, unit: Unit) -> Option<f64> { + let text = row.get("position").and_then(Value::as_str).map_or("", str::trim); + row.get("__pt").and_then(Value::as_f64).filter(|pt| pt.is_finite() && unit.format(*pt) == text).or_else(|| unit.parse(text)) +} + +/// The tab stop a `tabs` entry sets, or why it sets none: its position must be a distance from 0 to +/// 100000 pt (`label` names the field), its alignment one of [`TAB_ALIGNS`], its leader text of at +/// most eight characters and its Align On character one character. A leader or Align On character +/// left out is none or a period. +fn read_stop(row: &Value, unit: Unit, label: &str) -> Result<TabStop, String> { + let position = stop_position(row, unit).ok_or_else(|| format!("{label} must be a number"))?; + if !(0.0..=TabStop::MAX_POSITION).contains(&position) { + let (min, max) = (unit.format(0.0), unit.format(TabStop::MAX_POSITION)); + return Err(format!("{label} must be a number from {min} to {max}")); + } + let other = match row.get("align") { + None | Some(Value::Null) => "left", + Some(v) => v.as_str().unwrap_or(""), + }; + let align = TAB_ALIGNS.iter().find(|(_, key)| *key == other).map(|(a, _)| *a).ok_or_else(|| format!("unknown align `{other}`"))?; + let leader = match row.get("leader") { + None | Some(Value::Null) => "", + Some(Value::String(s)) => s.as_str(), + Some(_) => { + let key = "leader"; + return Err(format!("`{key}` must be a string")); + } + }; + let (label, max) = (caption(tl!("Leader:")), TabStop::MAX_LEADER); + if leader.chars().count() > max { + return Err(format!("{label} must be at most {max} characters")); + } + let label = caption(tl!("Align On:")); + let align_on = match row.get("alignOn") { + None | Some(Value::Null) => '.', + Some(Value::String(s)) => { + let mut chars = s.chars(); + match (chars.next(), chars.next()) { + (Some(c), None) => c, + _ => return Err(format!("{label} must be one character")), + } + } + Some(_) => return Err(format!("{label} must be one character")), + }; + Ok(TabStop { position, align, leader: leader.into(), align_on }) +} + /// A distance in points as its field shows it in `unit`. fn distance_field(value: Option<&Value>, unit: Unit) -> Value { value.and_then(Value::as_f64).map_or_else(|| json!(""), |pt| json!(unit.format(pt))) @@ -174,6 +252,7 @@ pub(crate) fn fields_from_attrs(attrs: &Map<String, Value>, unit: Unit) -> Map<S } fields.insert("auto_leading".into(), number_field(justification.and_then(|j| j.get("auto_leading")))); fields.insert("single_word".into(), justification.and_then(|j| j.get("single_word")).filter(|v| v.is_string()).cloned().unwrap_or(Value::Null)); + fields.insert("tabs".into(), tabs_field(attrs.get("tabs"), unit)); fields } @@ -292,9 +371,60 @@ pub(crate) fn edited_attrs(d: &Dialog) -> Result<Map<String, Value>, String> { let same = JUSTIFICATION_FIELDS.iter().all(|key| unchanged(key, "__resetJustification")); let value = if same { None } else { justification(d, &original, &original_fields)? }; keep_or(&mut attrs, "justification", same, value); + + let same = unchanged("tabs", "__resetTabs"); + let value = if same { None } else { tab_stops(d, &original, unit)? }; + keep_or(&mut attrs, "tabs", same, value); Ok(attrs) } +/// The `tabs` attribute the Tabs section sets: none while the list is unset; otherwise its stops +/// sorted by position. Of stops within 1e-6 pt of each other, the one edited last (`__tabEdited`) +/// is kept, else the first in the list. A stop as the style has it is kept without a check, and any +/// other is read by [`read_stop`]. An error when the list has more than 256 stops and more than the +/// style has. +fn tab_stops(d: &Dialog, original: &Map<String, Value>, unit: Unit) -> Result<Option<Value>, String> { + let rows = match d.fields.get("tabs") { + None | Some(Value::Null) => return Ok(None), + Some(Value::Array(rows)) => rows, + Some(_) => { + let key = "tabs"; + return Err(format!("`{key}` must be an array")); + } + }; + let kept: Vec<TabStop> = + original.get("tabs").and_then(Value::as_array).into_iter().flatten().filter_map(|s| serde_json::from_value(s.clone()).ok()).collect(); + let max = TabStop::MAX_STOPS; + if rows.len() > max.max(kept.len()) { + return Err(format!("at most {max} tab stops")); + } + let label = caption(if d.bool("__vertical") { tl!("Y:") } else { tl!("X:") }); + let kept_rows: Vec<Value> = kept.iter().map(|s| stop_field(s, unit)).collect(); + let mut stops = Vec::with_capacity(rows.len()); + for row in rows { + match kept_rows.iter().position(|k| k == row) { + Some(i) => stops.push(kept[i].clone()), + None => stops.push(read_stop(row, unit, label)?), + } + } + let edited = d.fields.get("__tabEdited").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()); + let mut order: Vec<usize> = (0..stops.len()).collect(); + order.sort_by(|&a, &b| stops[a].position.total_cmp(&stops[b].position).then(a.cmp(&b))); + let mut keep: Vec<usize> = Vec::with_capacity(order.len()); + for i in order { + match keep.last_mut() { + Some(last) if (stops[*last].position - stops[i].position).abs() < 1e-6 => { + if Some(i) == edited { + *last = i; + } + } + _ => keep.push(i), + } + } + let stops: Vec<TabStop> = keep.into_iter().map(|i| stops[i].clone()).collect(); + Ok(Some(serde_json::to_value(stops).map_err(|e| e.to_string())?)) +} + /// The `hyphenation` attribute the Hyphenation fields set: none when every field is empty or /// unset; otherwise the style's options (or the defaults) with each changed field written over /// them, an empty one at its default. @@ -416,15 +546,24 @@ fn reset_justification(d: &mut Dialog) { } } +/// Reset Panel in Tabs: the tab stops become unset, which leaves `tabs` out of the style. +fn reset_tabs(d: &mut Dialog) { + d.fields.insert("__resetTabs".into(), json!(true)); + d.fields.insert("tabs".into(), Value::Null); + d.fields.remove("__tabEdited"); + d.fields.insert("__tabSel".into(), json!(0)); +} + /// Whether the section `d` shows has a Reset Panel: every section but General. pub(crate) fn can_reset(d: &Dialog) -> bool { - ["basic", "composition", "hyphenation", "justification"].contains(&d.str("section").as_str()) + ["basic", "tabs", "composition", "hyphenation", "justification"].contains(&d.str("section").as_str()) } /// Reset Panel: clear the settings of the section `d` shows. pub(crate) fn reset_section(d: &mut Dialog) { match d.str("section").as_str() { "basic" => reset_basic(d), + "tabs" => reset_tabs(d), "composition" => reset_composition(d), "hyphenation" => reset_hyphenation(d), "justification" => reset_justification(d), @@ -449,9 +588,8 @@ pub fn reset_panel(app: &mut VectorcraftApp) -> Result<Value, String> { /// The section list of Paragraph Style Options and New Paragraph Style. pub(crate) fn section_list(ui: &mut egui::Ui, d: &mut Dialog) { ui.vertical(|ui| { - for (id, label) in - SECTIONS.into_iter().zip([tl!("General"), tl!("Basic Paragraph Formats"), tl!("Composition"), tl!("Hyphenation"), tl!("Justification")]) - { + let labels = [tl!("General"), tl!("Basic Paragraph Formats"), tl!("Tabs"), tl!("Composition"), tl!("Hyphenation"), tl!("Justification")]; + for (id, label) in SECTIONS.into_iter().zip(labels) { if ui.selectable_label(d.str("section") == id, label).clicked() { d.fields.insert("section".into(), json!(id)); } @@ -529,6 +667,156 @@ pub(crate) fn basic_body(ui: &mut egui::Ui, d: &mut Dialog) { }); } +/// The editing fields of the selected tab stop, with the `tabs` entry key each one shows. +const TAB_EDITORS: [(&str, &str); 3] = [("__tabPosition", "position"), ("__tabLeader", "leader"), ("__tabAlignOn", "alignOn")]; + +/// Tabs: the selected stop's Alignment, X (Y in vertical type), Leader and Align On, which only a +/// decimal stop uses; Add ([`add_tab`]), Delete Tab ([`delete_tab`]) and Clear All Tabs +/// ([`clear_tabs`]); and the list of stops, one row each, a click on which selects its stop, which +/// scrolls when it is taller than 160 points. A change to a field makes its stop the one edited last. +pub(crate) fn tabs_body(ui: &mut egui::Ui, d: &mut Dialog) { + let vertical = d.bool("__vertical"); + let (mut rows, mut sel, _) = tab_rows(d); + let mut changed = false; + let has = sel < rows.len(); + let shown = TAB_EDITORS.map(|(_, key)| rows.get(sel).and_then(|r| r.get(key)).and_then(Value::as_str).unwrap_or("").to_string()); + for ((field, _), text) in TAB_EDITORS.iter().zip(&shown) { + d.fields.insert((*field).into(), json!(text)); + } + let align = rows.get(sel).and_then(|r| r.get("align")).and_then(Value::as_str).unwrap_or("left").to_string(); + egui::Grid::new("paragraph-style-tabs").num_columns(2).spacing([18.0, 8.0]).show(ui, |ui| { + let row = ui.label(tl!("Alignment:")); + let names: Vec<&str> = TAB_ALIGNS.iter().map(|(a, _)| crate::panels::tabs::align_name(*a, vertical)).collect(); + let shown = TAB_ALIGNS.iter().position(|(_, key)| *key == align).and_then(|i| names.get(i).copied()).unwrap_or(""); + ui.add_enabled_ui(has, |ui| { + ui.label_inputs_by(row.id, |ui| { + if let Some(i) = crate::widgets::dropdown(ui, (KIND, "tab_align"), shown, &names, 190.0) + && let (Some(r), Some((_, key))) = (rows.get_mut(sel).and_then(Value::as_object_mut), TAB_ALIGNS.get(i)) + { + r.insert("align".into(), json!(key)); + changed = true; + } + }); + }); + ui.end_row(); + field_row(ui, d, "__tabPosition", if vertical { tl!("Y:") } else { tl!("X:") }, "", has); + field_row(ui, d, "__tabLeader", tl!("Leader:"), "", has); + field_row(ui, d, "__tabAlignOn", tl!("Align On:"), "", has && align == "decimal"); + }); + for ((field, key), was) in TAB_EDITORS.iter().zip(&shown) { + let typed = d.str(field); + if let Some(r) = rows.get_mut(sel).and_then(Value::as_object_mut) + && typed != *was + { + r.insert((*key).into(), json!(typed)); + changed = true; + } + } + if changed { + d.fields.insert("tabs".into(), Value::Array(rows.clone())); + d.fields.insert("__tabEdited".into(), json!(sel)); + } + ui.add_space(6.0); + let (mut add, mut delete, mut clear) = (false, false, false); + let can_add = new_tab_position(&rows, unit_of(d)).is_some(); + ui.horizontal(|ui| { + add = ui.add_enabled(can_add, egui::Button::new(tl!("Add"))).clicked(); + delete = ui.add_enabled(has, egui::Button::new(tl!("Delete Tab"))).clicked(); + clear = ui.add_enabled(!rows.is_empty(), egui::Button::new(tl!("Clear All Tabs"))).clicked(); + }); + ui.add_space(4.0); + let reveal = d.fields.remove("__tabScroll").is_some(); + egui::ScrollArea::vertical().id_salt("paragraph-style-tab-list").max_height(160.0).auto_shrink([false, true]).show(ui, |ui| { + for (i, r) in rows.iter().enumerate() { + let a = r.get("align").and_then(Value::as_str).and_then(|key| TAB_ALIGNS.iter().find(|(_, k)| *k == key)).map(|(a, _)| *a); + let name = a.map_or("", |a| crate::panels::tabs::align_name(a, vertical)); + let mut text = format!("{} {name}", r.get("position").and_then(Value::as_str).unwrap_or("")); + if let Some(leader) = r.get("leader").and_then(Value::as_str).filter(|l| !l.is_empty()) { + text += &format!(" {leader}"); + } + let row = ui.selectable_label(i == sel, text); + if reveal && i == sel { + ui.scroll_to_rect_animation(row.rect, None, egui::style::ScrollAnimation::none()); + } + if row.clicked() { + sel = i; + } + } + }); + d.fields.insert("__tabSel".into(), json!(sel)); + if add { + add_tab(d); + } else if delete { + delete_tab(d); + } else if clear { + clear_tabs(d); + } +} + +/// The `tabs` entries of `d` (none while unset), the selected one's index (`__tabSel`, at most the +/// last) and the index of the one edited last (`__tabEdited`). +fn tab_rows(d: &Dialog) -> (Vec<Value>, usize, Option<usize>) { + let rows: Vec<Value> = d.fields.get("tabs").and_then(Value::as_array).cloned().unwrap_or_default(); + let index = |key: &str| d.fields.get(key).and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()); + let sel = index("__tabSel").unwrap_or(0).min(rows.len().saturating_sub(1)); + (rows, sel, index("__tabEdited")) +} + +/// The position (pt) Add gives a new stop among `rows`: half an inch past the furthest stop that +/// reads, half an inch when none does, and at most 100000 pt. `None` while the list holds 256 stops +/// or when a stop already sits at that position. +fn new_tab_position(rows: &[Value], unit: Unit) -> Option<f64> { + if rows.len() >= TabStop::MAX_STOPS { + return None; + } + let positions: Vec<f64> = rows.iter().filter_map(|r| stop_position(r, unit)).collect(); + let furthest = positions.iter().copied().filter(|pt| (0.0..=TabStop::MAX_POSITION).contains(pt)).reduce(f64::max); + let position = furthest.map_or(DEFAULT_TAB_INTERVAL, |pt| pt + DEFAULT_TAB_INTERVAL).min(TabStop::MAX_POSITION); + (!positions.iter().any(|pt| (pt - position).abs() < 1e-6)).then_some(position) +} + +/// Add in Tabs: a left stop at [`new_tab_position`], which becomes the selected and last edited +/// stop and is scrolled into view in the list. Nothing when that gives no position. +pub(crate) fn add_tab(d: &mut Dialog) { + let unit = unit_of(d); + let (mut rows, _, _) = tab_rows(d); + let Some(position) = new_tab_position(&rows, unit) else { return }; + rows.push(stop_field(&TabStop { position, align: TabAlign::Left, leader: String::new(), align_on: '.' }, unit)); + let at = rows.len() - 1; + d.fields.insert("tabs".into(), Value::Array(rows)); + d.fields.insert("__tabSel".into(), json!(at)); + d.fields.insert("__tabEdited".into(), json!(at)); + d.fields.insert("__tabScroll".into(), json!(true)); +} + +/// Delete Tab in Tabs: remove the selected stop; the next one (or the last) becomes selected. +pub(crate) fn delete_tab(d: &mut Dialog) { + let (mut rows, sel, edited) = tab_rows(d); + if sel >= rows.len() { + return; + } + rows.remove(sel); + match edited { + Some(e) if e == sel => { + d.fields.remove("__tabEdited"); + } + Some(e) if e > sel => { + d.fields.insert("__tabEdited".into(), json!(e - 1)); + } + _ => {} + } + d.fields.insert("__tabSel".into(), json!(sel.min(rows.len().saturating_sub(1)))); + d.fields.insert("tabs".into(), Value::Array(rows)); + d.fields.insert("__tabScroll".into(), json!(true)); +} + +/// Clear All Tabs in Tabs: an empty list of stops, which the style then sets. +pub(crate) fn clear_tabs(d: &mut Dialog) { + d.fields.insert("tabs".into(), json!([])); + d.fields.insert("__tabSel".into(), json!(0)); + d.fields.remove("__tabEdited"); +} + /// Composition: the Single-line or Every-line Composer, and Roman Hanging Punctuation. pub(crate) fn composition_body(ui: &mut egui::Ui, d: &mut Dialog) { egui::Grid::new("paragraph-style-composition").num_columns(2).spacing([18.0, 8.0]).show(ui, |ui| { @@ -624,6 +912,7 @@ pub(crate) fn general_body(ui: &mut egui::Ui, d: &mut Dialog) { pub(crate) fn section_body(ui: &mut egui::Ui, d: &mut Dialog) { match d.str("section").as_str() { "basic" => basic_body(ui, d), + "tabs" => tabs_body(ui, d), "composition" => composition_body(ui, d), "hyphenation" => hyphenation_body(ui, d), "justification" => justification_body(ui, d), diff --git a/crates/ui-egui/src/dialogs/para_style_options/tests.rs b/crates/ui-egui/src/dialogs/para_style_options/tests.rs index 42fa0bd491..38d158b102 100644 --- a/crates/ui-egui/src/dialogs/para_style_options/tests.rs +++ b/crates/ui-egui/src/dialogs/para_style_options/tests.rs @@ -162,6 +162,7 @@ fn reset_panel_clears_only_its_section() { let stored = def(&app, "Body"); for (section, cleared) in [ ("basic", &["justify", "space_after"][..]), + ("tabs", &["tabs"]), ("composition", &["composer", "roman_hanging"]), ("hyphenation", &["hyphenate", "hyphenation"]), ("justification", &["justification"]), @@ -239,7 +240,15 @@ fn new_paragraph_style_offers_the_sections() { set(&mut app, "section", json!(section)); texts += &crate::tests_labels::painted_text(&mut app, |app, ui| super::super::show(app, ui.ctx())); } - for label in ["Basic Paragraph Formats", "Composition", "First-line Left Indent", "Hyphenate Capitalized Words", "Single Word Justification:"] { + for label in [ + "Basic Paragraph Formats", + "Tabs", + "Composition", + "First-line Left Indent", + "Align On:", + "Hyphenate Capitalized Words", + "Single Word Justification:", + ] { assert!(texts.contains(label), "{label} is drawn: {texts}"); } set(&mut app, "section", json!("basic")); @@ -257,6 +266,237 @@ fn new_paragraph_style_offers_the_sections() { assert_eq!(paras(&app, id)[0].justify, Justify::Center); } +/// The rows of the `tabs` field of the open dialog. +fn tab_rows(app: &VectorcraftApp) -> Vec<Value> { + app.ui.dialog.as_ref().unwrap().fields["tabs"].as_array().cloned().unwrap_or_default() +} + +/// Tabs shows a style's stops in the General unit with their alignment, leader and Align On +/// character; a stop left as it was keeps its position exactly, a changed one is read in the unit, +/// and the stops are stored sorted by position. Add puts a left stop half an inch past the furthest +/// stop, Delete Tab removes the selected stop and Clear All Tabs leaves the style an empty list. +#[test] +fn tabs_section_edits_the_style_stops() { + let mut app = app(); + let stops = json!([{"position": 36.0, "align": "right"}, {"position": 72.123456, "align": "decimal", "leader": ".", "alignOn": ","}]); + app.run("paraStyle.new", json!({"name": "Body", "attrs": {"left_indent": 3.0, "tabs": stops}})).unwrap(); + let stored = def(&app, "Body"); + app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap(); + let rows = tab_rows(&app); + let shown: Vec<(&str, &str, &str, &str)> = rows + .iter() + .map(|r| (r["position"].as_str().unwrap(), r["align"].as_str().unwrap(), r["leader"].as_str().unwrap(), r["alignOn"].as_str().unwrap())) + .collect(); + assert_eq!(shown, [("36 pt", "right", "", "."), ("72.123 pt", "decimal", ".", ",")]); + assert_eq!(edited_attrs(app.ui.dialog.as_ref().unwrap()).unwrap(), stored, "nothing changed"); + + // The first stop moves past the second and turns centered; the second keeps its exact position. + let mut rows = rows; + rows[0]["position"] = json!("1.5 in"); + rows[0]["align"] = json!("center"); + set(&mut app, "tabs", Value::from(rows)); + let a = edited_attrs(app.ui.dialog.as_ref().unwrap()).unwrap(); + assert_eq!( + a["tabs"], + json!([{"position": 72.123456, "align": "decimal", "leader": ".", "alignOn": ","}, {"position": 108.0, "align": "center", "alignOn": "."}]) + ); + assert_eq!(a["left_indent"], json!(3.0), "other attributes stay"); + + let d = app.ui.dialog.as_mut().unwrap(); + add_tab(d); + assert_eq!((tab_rows(&app).len(), app.ui.dialog.as_ref().unwrap().fields["__tabSel"].clone()), (3, json!(2))); + let a = edited_attrs(app.ui.dialog.as_ref().unwrap()).unwrap(); + assert_eq!(a["tabs"][2], json!({"position": 144.0, "align": "left", "alignOn": "."}), "half an inch past the furthest"); + set(&mut app, "__tabSel", json!(0)); + delete_tab(app.ui.dialog.as_mut().unwrap()); + let a = edited_attrs(app.ui.dialog.as_ref().unwrap()).unwrap(); + assert_eq!(a["tabs"].as_array().unwrap().iter().map(|s| s["position"].clone()).collect::<Vec<_>>(), [json!(72.123456), json!(144.0)]); + super::super::confirm(&mut app).unwrap(); + assert_eq!(def(&app, "Body")["tabs"].as_array().map(Vec::len), Some(2)); + + // Clear All Tabs leaves an empty list set, which Reset Panel removes; a new stop on a style + // without stops goes at half an inch. + app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap(); + clear_tabs(app.ui.dialog.as_mut().unwrap()); + assert_eq!(edited_attrs(app.ui.dialog.as_ref().unwrap()).unwrap()["tabs"], json!([])); + super::super::confirm(&mut app).unwrap(); + assert_eq!(def(&app, "Body")["tabs"], json!([]), "an empty list is stored"); + app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap(); + set(&mut app, "section", json!("tabs")); + app.run("ui.paraStyleOptions.resetPanel", json!({})).unwrap(); + let a = edited_attrs(app.ui.dialog.as_ref().unwrap()).unwrap(); + assert!(!a.contains_key("tabs") && a["left_indent"] == json!(3.0), "{a:?}"); + add_tab(app.ui.dialog.as_mut().unwrap()); + assert_eq!(edited_attrs(app.ui.dialog.as_ref().unwrap()).unwrap()["tabs"], json!([{"position": 36.0, "align": "left", "alignOn": "."}])); +} + +/// Of stops typed at one position, the one edited last is kept; with none edited, the first in the +/// list is. +#[test] +fn a_repeated_tab_position_keeps_the_stop_edited_last() { + let mut app = app(); + app.run("paraStyle.new", json!({"name": "Body", "attrs": {}})).unwrap(); + app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap(); + let rows = json!([{"position": "36", "align": "right"}, {"position": "0.5 in", "align": "center"}, {"position": "10", "align": "left"}]); + set(&mut app, "tabs", rows); + let kept = |app: &VectorcraftApp| edited_attrs(app.ui.dialog.as_ref().unwrap()).unwrap()["tabs"].clone(); + assert_eq!(kept(&app), json!([{"position": 10.0, "align": "left", "alignOn": "."}, {"position": 36.0, "align": "right", "alignOn": "."}])); + set(&mut app, "__tabEdited", json!(1)); + assert_eq!(kept(&app)[1]["align"], json!("center")); +} + +/// The list of stops scrolls, so a style with 256 stops keeps the Tabs section short enough for the +/// dialog's buttons to stay on screen. +#[test] +fn a_long_tab_list_scrolls() { + let mut app = app(); + let stops: Vec<Value> = (0..256).map(|i| json!({"position": 36.0 * f64::from(i + 1), "align": "left"})).collect(); + app.run("paraStyle.new", json!({"name": "Body", "attrs": {"tabs": stops}})).unwrap(); + app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut height = f32::INFINITY; + let mut out = ctx.run_ui(Default::default(), |ui| { + let top = ui.cursor().top(); + tabs_body(ui, d); + height = ui.cursor().top() - top; + }); + out.textures_delta.clear(); + assert!(height < 400.0, "the Tabs section is {height} points tall"); +} + +/// The stop Add puts at the end of a long list is scrolled into view: its row in the list is drawn +/// inside the list's visible area. +#[test] +fn an_added_tab_stop_is_scrolled_into_view() { + let mut app = app(); + let stops: Vec<Value> = (0..100).map(|i| json!({"position": 36.0 * f64::from(i + 1), "align": "left"})).collect(); + app.run("paraStyle.new", json!({"name": "Body", "attrs": {"tabs": stops}})).unwrap(); + app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let draw = |d: &mut Dialog| { + let mut out = ctx.run_ui(Default::default(), |ui| tabs_body(ui, d)); + out.textures_delta.clear(); + out.shapes.iter().any(|c| match &c.shape { + egui::Shape::Text(t) => t.galley.text().starts_with("3636 pt ") && c.clip_rect.intersects(t.visual_bounding_rect()), + _ => false, + }) + }; + draw(d); + add_tab(d); + let shown: Vec<bool> = (0..3).map(|_| draw(d)).collect(); + assert_eq!(shown.last(), Some(&true), "{shown:?}"); +} + +/// Add does not put a stop where one already is: with a stop at 100000 pt, the largest position, +/// it adds nothing and the Add button is disabled, so the existing stop keeps its settings. +#[test] +fn add_leaves_a_stop_at_the_largest_position_alone() { + let mut app = app(); + let stops = json!([{"position": 100000.0, "align": "right", "leader": "-"}]); + app.run("paraStyle.new", json!({"name": "Body", "attrs": {"tabs": stops}})).unwrap(); + app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap(); + let before = tab_rows(&app); + add_tab(app.ui.dialog.as_mut().unwrap()); + assert_eq!(tab_rows(&app), before, "no stop is added"); + assert!(new_tab_position(&before, Unit::Points).is_none(), "Add is disabled"); + assert_eq!(edited_attrs(app.ui.dialog.as_ref().unwrap()).unwrap(), def(&app, "Body"), "the stop is kept"); +} + +/// A tab position out of range or not a distance, an unknown alignment, a leader of more than eight +/// characters, an Align On entry that is not one character, more than 256 stops, a `tabs` value that +/// is not a list and a stop that is not an object are errors that keep the dialog open and the style +/// as it was. Vertical type names the position Y. +#[test] +fn invalid_tab_stops_keep_the_dialog_open() { + let mut app = app(); + app.run("paraStyle.new", json!({"name": "Body", "attrs": {}})).unwrap(); + let stop = |key: &str, value: Value| { + let mut stop = json!({"position": "36", "align": "decimal", "leader": "", "alignOn": "."}); + stop[key] = value; + json!([stop]) + }; + let many = Value::from((0..257).map(|i| json!({"position": format!("{i}"), "align": "left"})).collect::<Vec<_>>()); + for (tabs, why) in [ + (stop("position", json!("-1")), "X must be a number from 0 pt to 100000 pt"), + (stop("position", json!("100001 pt")), "X must be a number from 0 pt to 100000 pt"), + (stop("position", json!("far")), "X must be a number"), + (stop("align", json!("diagonal")), "unknown align `diagonal`"), + (stop("leader", json!("123456789")), "Leader must be at most 8 characters"), + (stop("alignOn", json!("")), "Align On must be one character"), + (stop("alignOn", json!(",.")), "Align On must be one character"), + (many, "at most 256 tab stops"), + (json!("36"), "`tabs` must be an array"), + (json!({"position": "36"}), "`tabs` must be an array"), + (json!([5]), "X must be a number"), + ] { + app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap(); + set(&mut app, "tabs", tabs.clone()); + assert_eq!(edited_attrs(app.ui.dialog.as_ref().unwrap()).unwrap_err(), why, "{tabs}"); + assert!(super::super::confirm(&mut app).is_err()); + assert!(app.ui.dialog.is_some()); + super::super::cancel(&mut app); + assert_eq!(def(&app, "Body"), Map::new()); + } + app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap(); + set(&mut app, "__vertical", json!(true)); + set(&mut app, "tabs", stop("position", json!("far"))); + assert_eq!(edited_attrs(app.ui.dialog.as_ref().unwrap()).unwrap_err(), "Y must be a number"); + // Stops a style read from a file holds out of range stay while another stop is added. + super::super::cancel(&mut app); + let doc = std::sync::Arc::make_mut(&mut app.session.doc_mut().unwrap().doc); + let held = json!([{"position": 200000.0, "align": "left", "leader": "123456789", "alignOn": "."}]); + doc.para_styles.iter_mut().find(|d| d.name == "Body").unwrap().attrs.insert("tabs".into(), held.clone()); + app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap(); + add_tab(app.ui.dialog.as_mut().unwrap()); + super::super::confirm(&mut app).unwrap(); + assert_eq!(def(&app, "Body")["tabs"][1], held[0]); +} + +/// Preview shows a style's new stops on the paragraphs that use it, and Cancel puts the document +/// and its history back. OK sets them in one undo step, a paragraph whose stops the Tabs panel set +/// keeps its own, and Undo and Redo restore both. Saving and opening the document keeps the stops. +#[test] +fn tabs_preview_cancel_and_ok_update_the_paragraphs_in_one_undo_step() { + let mut app = app(); + let id = app.run("text.create", json!({"x": 10, "y": 40, "text": "a\tb\nc\td"})).unwrap()["id"].as_u64().unwrap(); + app.run("paraStyle.new", json!({"name": "Body", "attrs": {"tabs": [{"position": 36.0}]}})).unwrap(); + app.run("paraStyle.apply", json!({"name": "Body", "id": id})).unwrap(); + app.run("text.tabs.set", json!({"ids": [id], "start": 4, "end": 4, "stops": [{"position": 50.0}]})).unwrap(); + let before = app.session.doc().unwrap().doc.clone(); + let undo = app.session.doc().unwrap().history.undo.len(); + let new_stops = json!([{"position": "20", "align": "right", "leader": "."}, {"position": "80", "align": "decimal", "alignOn": ","}]); + let positions = |app: &VectorcraftApp| paras(app, id).iter().map(|p| p.tabs.iter().map(|t| t.position).collect::<Vec<_>>()).collect::<Vec<_>>(); + app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap(); + set(&mut app, "section", json!("tabs")); + set(&mut app, "tabs", new_stops.clone()); + frame(&mut app); + assert_eq!(positions(&app), [vec![20.0, 80.0], vec![50.0]], "Preview shows the change"); + super::super::cancel(&mut app); + assert_eq!(app.session.doc().unwrap().doc, before); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo); + + app.run("ui.paraStyleOptions", json!({"name": "Body"})).unwrap(); + set(&mut app, "tabs", new_stops); + frame(&mut app); + super::super::confirm(&mut app).unwrap(); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1); + assert_eq!(positions(&app), [vec![20.0, 80.0], vec![50.0]]); + let p = paras(&app, id); + assert_eq!((p[0].tabs[0].leader.as_str(), p[0].tabs[1].align_on), (".", ',')); + let changed = app.session.doc().unwrap().doc.clone(); + let saved = vectorcraft_format::load(&vectorcraft_format::save(&changed, false)).unwrap(); + let def_of = |d: &vectorcraft_doc::Document| d.para_styles.iter().find(|s| s.name == "Body").map(|s| s.attrs.clone()); + assert_eq!(def_of(&saved), def_of(&changed)); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc, before); + app.run("edit.redo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc, changed); +} + /// The section list, Reset Panel and both buttons are drawn in every section, and the Hyphenation /// options are drawn whether or not Hyphenation is on. #[test] @@ -269,6 +509,7 @@ fn every_section_draws_its_fields() { for (section, label) in [ ("general", "Style Name:"), ("basic", "Space After Paragraph"), + ("tabs", "Clear All Tabs"), ("composition", "Roman Hanging Punctuation"), ("hyphenation", "Hyphenation Zone:"), ("justification", "Glyph Scaling:"), @@ -282,7 +523,7 @@ fn every_section_draws_its_fields() { out.textures_delta.clear(); texts = crate::tests_labels::shapes_text(&out); } - for shown in [label, "General", "Basic Paragraph Formats", "Justification", "Reset Panel", "OK", "Cancel"] { + for shown in [label, "General", "Basic Paragraph Formats", "Tabs", "Justification", "Reset Panel", "OK", "Cancel"] { assert!(texts.contains(shown), "{shown} is drawn in {section}: {texts}"); } } diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index cf0b677271..5319afbb40 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5075,6 +5075,7 @@ digits Malayalam Malaiàlam @msg `sampleSize` must be 1, 3 or 5 `sampleSize` ha de ser 1, 3 o 5 @msg `select` must be true or false `select` ha de ser true o false @msg `stops` must be an array `stops` ha de ser una matriu +@msg `{key}` must be an array `{key}` ha de ser una matriu @msg `svg` must be an object of SVG options (see document.formats) `svg` ha de ser un objecte d’opcions SVG (vegeu document.formats) @msg `to` must be "corner" or "smooth" `to` ha de ser "corner" o "smooth" @msg `{_1}` has no brushes to apply `{_1}` no té pinzells per aplicar @@ -6468,6 +6469,10 @@ anchor Points: Punts: @msg features lists at most {max} tags features admet com a màxim {max} etiquetes @msg unknown OpenType feature `{t}` funció OpenType desconeguda `{t}` @msg {label} must be a number above 0 and at most {max} {label} ha de ser un nombre superior a 0 i com a màxim {max} +@msg {label} must be at most {max} characters {label} ha de tenir com a màxim {max} caràcters +@msg {label} must be one character {label} ha de ser un sol caràcter +@msg at most {max} tab stops com a màxim {max} tabulacions +@msg unknown align `{other}` alineació desconeguda `{other}` @msg no character style `{name}` No s'ha trobat l'estil de caràcter `{name}` # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 9125a4f05f..126193bd44 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -4554,6 +4554,7 @@ imageTrace Outline Obrys @msg `globalAdjust` must be an object `globalAdjust` musí být objekt @msg `sampleSize` must be 1, 3 or 5 `sampleSize` musí být 1, 3 nebo 5 @msg `stops` must be an array `stops` musí být pole +@msg `{key}` must be an array `{key}` musí být pole @msg `svg` must be an object of SVG options (see document.formats) `svg` musí být objekt s možnostmi SVG (viz document.formats) @msg `{_1}` is no save command `{_1}` není příkaz pro uložení @msg `{area}` is no recovery area name `{area}` není název oblasti obnovení @@ -6496,6 +6497,10 @@ anchor Points: Body: @msg features lists at most {max} tags features obsahuje nejvýše {max} značek @msg unknown OpenType feature `{t}` neznámá funkce OpenType `{t}` @msg {label} must be a number above 0 and at most {max} {label} musí být číslo větší než 0 a nejvýše {max} +@msg {label} must be at most {max} characters {label} smí mít nejvýše {max} znaků +@msg {label} must be one character {label} musí být jeden znak +@msg at most {max} tab stops nejvýše {max} zarážek tabulátoru +@msg unknown align `{other}` neznámé zarovnání `{other}` @msg no character style `{name}` styl znaků `{name}` neexistuje # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index ec8d91ad1c..4bf1071ca7 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4169,6 +4169,7 @@ theme Appearance Erscheinungsbild @msg `globalAdjust` must be an object `globalAdjust` muss ein Objekt sein @msg `sampleSize` must be 1, 3 or 5 `sampleSize` muss 1, 3 oder 5 sein @msg `stops` must be an array `stops` muss ein Array sein +@msg `{key}` must be an array `{key}` muss ein Array sein @msg `svg` must be an object of SVG options (see document.formats) `svg` muss ein Objekt mit SVG-Optionen sein (siehe document.formats) @msg `{_1}` is no save command `{_1}` ist kein Speicherbefehl @msg `{area}` is no recovery area name `{area}` ist kein Name eines Wiederherstellungsbereichs @@ -6090,6 +6091,10 @@ anchor Points: Punkte: @msg features lists at most {max} tags features enthält höchstens {max} Tags @msg unknown OpenType feature `{t}` unbekannte OpenType-Funktion `{t}` @msg {label} must be a number above 0 and at most {max} {label} muss eine Zahl über 0 und höchstens {max} sein +@msg {label} must be at most {max} characters {label} darf höchstens {max} Zeichen lang sein +@msg {label} must be one character {label} muss genau ein Zeichen sein +@msg at most {max} tab stops höchstens {max} Tabstopps +@msg unknown align `{other}` unbekannte Ausrichtung `{other}` @msg no character style `{name}` Zeichenformat `{name}` nicht gefunden # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 17527f5871..226eb76db9 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2374,6 +2374,7 @@ axis Both Και τα δύο @msg `globalAdjust` must be an object Το `globalAdjust` πρέπει να είναι αντικείμενο @msg `sampleSize` must be 1, 3 or 5 Το `sampleSize` πρέπει να είναι 1, 3 ή 5 @msg `stops` must be an array Το `stops` πρέπει να είναι πίνακας +@msg `{key}` must be an array Το `{key}` πρέπει να είναι πίνακας @msg `svg` must be an object of SVG options (see document.formats) Το `svg` πρέπει να είναι αντικείμενο επιλογών SVG (δείτε document.formats) @msg `{_1}` is no save command Το `{_1}` δεν είναι εντολή αποθήκευσης @msg `{area}` is no recovery area name Το `{area}` δεν είναι όνομα περιοχής ανάκτησης @@ -6447,6 +6448,10 @@ anchor Points: Σημεία: @msg features lists at most {max} tags Το features περιέχει έως {max} ετικέτες @msg unknown OpenType feature `{t}` άγνωστο χαρακτηριστικό OpenType `{t}` @msg {label} must be a number above 0 and at most {max} Το {label} πρέπει να είναι αριθμός μεγαλύτερος από 0 και έως {max} +@msg {label} must be at most {max} characters Το {label} πρέπει να έχει έως {max} χαρακτήρες +@msg {label} must be one character Το {label} πρέπει να είναι ένας χαρακτήρας +@msg at most {max} tab stops έως {max} στάσεις στηλοθέτη +@msg unknown align `{other}` άγνωστη στοίχιση `{other}` @msg no character style `{name}` Δεν βρέθηκε το στυλ χαρακτήρων `{name}` # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index f4ea5ecf80..2ca9bcef39 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4245,6 +4245,7 @@ axis Both Ambos @msg `globalAdjust` must be an object `globalAdjust` debe ser un objeto @msg `sampleSize` must be 1, 3 or 5 `sampleSize` debe ser 1, 3 o 5 @msg `stops` must be an array `stops` debe ser una lista +@msg `{key}` must be an array `{key}` debe ser una lista @msg `svg` must be an object of SVG options (see document.formats) `svg` debe ser un objeto con opciones de SVG (consulta document.formats) @msg `{_1}` is no save command `{_1}` no es un comando de guardado @msg `{area}` is no recovery area name `{area}` no es el nombre de un área de recuperación @@ -6439,6 +6440,10 @@ anchor Points: Puntos: @msg features lists at most {max} tags features admite como máximo {max} etiquetas @msg unknown OpenType feature `{t}` función OpenType desconocida `{t}` @msg {label} must be a number above 0 and at most {max} {label} debe ser un número mayor que 0 y como máximo {max} +@msg {label} must be at most {max} characters {label} debe tener como máximo {max} caracteres +@msg {label} must be one character {label} debe ser un solo carácter +@msg at most {max} tab stops como máximo {max} tabulaciones +@msg unknown align `{other}` alineación desconocida `{other}` @msg no character style `{name}` No existe el estilo de carácter `{name}` # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index b7a34058f4..64ea089a47 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -4468,6 +4468,7 @@ imageTrace Outline Ääriviiva @msg `globalAdjust` must be an object `globalAdjust` on oltava objekti @msg `sampleSize` must be 1, 3 or 5 `sampleSize` on oltava 1, 3 tai 5 @msg `stops` must be an array `stops` on oltava taulukko +@msg `{key}` must be an array `{key}` on oltava taulukko @msg `svg` must be an object of SVG options (see document.formats) `svg` on oltava SVG-asetusten objekti (ks. document.formats) @msg `{_1}` is no save command `{_1}` ei ole tallennuskomento @msg `{area}` is no recovery area name `{area}` ei ole palautusalueen nimi @@ -6526,6 +6527,10 @@ anchor Points: Pisteet: @msg features lists at most {max} tags features sisältää enintään {max} tunnistetta @msg unknown OpenType feature `{t}` tuntematon OpenType-ominaisuus `{t}` @msg {label} must be a number above 0 and at most {max} {label} on oltava luku, joka on yli 0 ja enintään {max} +@msg {label} must be at most {max} characters {label} saa olla enintään {max} merkkiä +@msg {label} must be one character {label} on oltava yksi merkki +@msg at most {max} tab stops enintään {max} sarkainkohtaa +@msg unknown align `{other}` tuntematon tasaus `{other}` @msg no character style `{name}` Merkkityyliä `{name}` ei löytynyt # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 36bf674ef2..dc00b8b6e5 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4234,6 +4234,7 @@ axis Both Les deux @msg `globalAdjust` must be an object `globalAdjust` doit être un objet @msg `sampleSize` must be 1, 3 or 5 `sampleSize` doit valoir 1, 3 ou 5 @msg `stops` must be an array `stops` doit être un tableau +@msg `{key}` must be an array `{key}` doit être un tableau @msg `svg` must be an object of SVG options (see document.formats) `svg` doit être un objet d’options SVG (voir document.formats) @msg `{_1}` is no save command `{_1}` n’est pas une commande d’enregistrement @msg `{area}` is no recovery area name `{area}` n’est pas un nom de zone de récupération @@ -6491,6 +6492,10 @@ anchor Points: Points : @msg features lists at most {max} tags features contient au plus {max} balises @msg unknown OpenType feature `{t}` fonction OpenType inconnue `{t}` @msg {label} must be a number above 0 and at most {max} {label} doit être un nombre supérieur à 0 et au plus égal à {max} +@msg {label} must be at most {max} characters {label} doit compter au plus {max} caractères +@msg {label} must be one character {label} doit être un seul caractère +@msg at most {max} tab stops au plus {max} taquets de tabulation +@msg unknown align `{other}` alignement inconnu `{other}` @msg no character style `{name}` Style de caractère `{name}` introuvable # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 7dbeabac1e..d5061a5cd5 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -5532,6 +5532,9 @@ antiAlias Sharp शार्प @msg {label} keeps 1 of the document's {n} artboards: the file reopens with one. Turn on Preserve Editing Capabilities to keep them all. {label} डॉक्यूमेंट के {n} आर्टबोर्ड में से 1 रखता है: फ़ाइल एक के साथ फिर से खुलती है। उन्हें रखने के लिए संरक्षित संपादन क्षमताओं को चालू करें। @msg {label} must be a number {label} एक नंबर होना चाहिए @msg {label} must be a number above 0 and at most {max} {label} 0 से ऊपर और सबसे {max} पर होना चाहिए +@msg {label} must be at most {max} characters {label} में अधिकतम {max} वर्ण होने चाहिए +@msg {label} must be one character {label} एक वर्ण होना चाहिए +@msg at most {max} tab stops अधिकतम {max} टैब स्टॉप @msg {label} must be a number at most {max} {label} सबसे {max} पर एक नंबर होना चाहिए @msg {label} must be a number from -1296 to 1296 {label} को -1296 से 1296 के बीच की संख्या होना चाहिए @msg {label} must be a number from {min} to {max} {label} को {min} से {max} तक एक नंबर होना चाहिए @@ -5887,6 +5890,7 @@ antiAlias Sharp शार्प @msg `status` must be linked, embedded, missing or modified, or a list of them `status` को लिंक, एम्बेडेड, लापता या संशोधित किया जाना चाहिए, या उनमें से एक सूची @msg `stop` must be a stop index `stop` एक स्टॉप इंडेक्स होना चाहिए @msg `stops` must be an array `stops` एक सारणी होना चाहिए +@msg `{key}` must be an array `{key}` एक सारणी होना चाहिए @msg `story` styles the whole story: give no `start` or `end` `story` पूरी स्टोरी को स्टाइल करता है: `start` या `end` न दें @msg `story` takes the whole story: give no `start` or `end` `story` पूरी स्टोरी लेता है: `start` या `end` न दें @msg `strokeMode` applies to strokes (stroke: true) `strokeMode` स्ट्रोक पर लागू होता है (stroke: true) diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index c87d42e6dc..25b424b93b 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4753,6 +4753,7 @@ imageTrace Outline Körvonal @msg `globalAdjust` must be an object `globalAdjust`: objektum kell @msg `sampleSize` must be 1, 3 or 5 `sampleSize`: 1, 3 vagy 5 kell @msg `stops` must be an array `stops`: tömb kell +@msg `{key}` must be an array `{key}`: tömb kell @msg `svg` must be an object of SVG options (see document.formats) `svg`: SVG-beállításokat tartalmazó objektum kell (lásd: document.formats) @msg `to` must be "corner" or "smooth" `to`: "corner" vagy "smooth" kell @msg `fontHeightReference` must be "emBox", "capHeight", "xHeight" or "icfBox" `fontHeightReference`: "emBox", "capHeight", "xHeight" vagy "icfBox" kell @@ -6480,6 +6481,10 @@ anchor Points: Pontok: @msg features lists at most {max} tags a features legfeljebb {max} címkét tartalmazhat @msg unknown OpenType feature `{t}` ismeretlen OpenType-funkció: `{t}` @msg {label} must be a number above 0 and at most {max} {label} 0-nál nagyobb és legfeljebb {max} értékű szám kell legyen +@msg {label} must be at most {max} characters {label} legfeljebb {max} karakter lehet +@msg {label} must be one character {label} pontosan egy karakter kell legyen +@msg at most {max} tab stops legfeljebb {max} tabulátor +@msg unknown align `{other}` ismeretlen igazítás: `{other}` @msg no character style `{name}` A(z) `{name}` karakterstílus nem található # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 36c8820296..bc98913d51 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4296,6 +4296,7 @@ axis Both Entrambi @msg `globalAdjust` must be an object `globalAdjust` deve essere un oggetto @msg `sampleSize` must be 1, 3 or 5 `sampleSize` deve essere 1, 3 o 5 @msg `stops` must be an array `stops` deve essere un array +@msg `{key}` must be an array `{key}` deve essere un array @msg `svg` must be an object of SVG options (see document.formats) `svg` deve essere un oggetto di opzioni SVG (vedi document.formats) @msg `{_1}` is no save command `{_1}` non è un comando di salvataggio @msg `{area}` is no recovery area name `{area}` non è il nome di un'area di recupero @@ -6124,6 +6125,10 @@ anchor Points: Punti: @msg features lists at most {max} tags features contiene al massimo {max} tag @msg unknown OpenType feature `{t}` funzione OpenType sconosciuta `{t}` @msg {label} must be a number above 0 and at most {max} {label} deve essere un numero maggiore di 0 e al massimo {max} +@msg {label} must be at most {max} characters {label} deve avere al massimo {max} caratteri +@msg {label} must be one character {label} deve essere un solo carattere +@msg at most {max} tab stops al massimo {max} tabulazioni +@msg unknown align `{other}` allineamento sconosciuto `{other}` @msg no character style `{name}` Stile di carattere `{name}` non trovato # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 6dcabf27e8..ffb1ee617a 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4394,6 +4394,7 @@ swatch Bright Violet ビビッドバイオレット @msg `preserve` must be an object `preserve` にはオブジェクトを指定してください @msg `globalAdjust` must be an object `globalAdjust` にはオブジェクトを指定してください @msg `stops` must be an array `stops` には配列を指定してください +@msg `{key}` must be an array `{key}` には配列を指定してください @msg `{k}` must be [x, y] `{k}` は [x, y] の形式で指定してください @msg a gradient needs at least two stops グラデーションには 2 つ以上の分岐点が必要です @msg give both `start` and `end` [x, y] (or neither) `start` と `end` は両方とも [x, y] で指定するか、どちらも省略してください @@ -6189,6 +6190,10 @@ anchor Points: ポイント: @msg features lists at most {max} tags features に指定できるタグは {max} 個までです @msg unknown OpenType feature `{t}` 不明な OpenType 機能 `{t}` @msg {label} must be a number above 0 and at most {max} {label} には 0 より大きく {max} 以下の数値を指定してください +@msg {label} must be at most {max} characters {label} には {max} 文字以内で指定してください +@msg {label} must be one character {label} には 1 文字を指定してください +@msg at most {max} tab stops タブストップは {max} 個までです +@msg unknown align `{other}` 不明な揃え `{other}` @msg no character style `{name}` 文字スタイル `{name}` が見つかりません # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 23a18557fa..24d38e9850 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -4027,6 +4027,7 @@ font-technology Variable ცვლადი @msg `globalAdjust` must be an object `globalAdjust` უნდა იყოს ობიექტი @msg `sampleSize` must be 1, 3 or 5 `sampleSize` უნდა იყოს 1, 3 ან 5 @msg `stops` must be an array `stops` უნდა იყოს მასივი +@msg `{key}` must be an array `{key}` უნდა იყოს მასივი @msg `svg` must be an object of SVG options (see document.formats) `svg` უნდა იყოს SVG-ის პარამეტრების ობიექტი (იხ. document.formats) @msg `{_1}` is no save command `{_1}` შენახვის ბრძანება არ არის @msg `{area}` is no recovery area name `{area}` აღდგენის არის სახელი არ არის @@ -6522,6 +6523,10 @@ anchor Points: წერტილები: @msg features lists at most {max} tags features შეიძლება შეიცავდეს არაუმეტეს {max} ტეგს @msg unknown OpenType feature `{t}` უცნობი OpenType შესაძლებლობა `{t}` @msg {label} must be a number above 0 and at most {max} {label} უნდა იყოს 0-ზე მეტი და არაუმეტეს {max}-ისა რიცხვი +@msg {label} must be at most {max} characters {label} უნდა შეიცავდეს არაუმეტეს {max} სიმბოლოს +@msg {label} must be one character {label} უნდა იყოს ერთი სიმბოლო +@msg at most {max} tab stops არაუმეტეს {max} ტაბულაციის გაჩერება +@msg unknown align `{other}` უცნობი სწორება `{other}` @msg no character style `{name}` სიმბოლოს სტილი `{name}` ვერ მოიძებნა # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 5366483cd9..f257966647 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4321,6 +4321,7 @@ theme Appearance 외관 @msg `preserve` must be an object `preserve`는 개체여야 합니다 @msg `globalAdjust` must be an object `globalAdjust`는 개체여야 합니다 @msg `stops` must be an array `stops`는 배열이어야 합니다 +@msg `{key}` must be an array `{key}`은(는) 배열이어야 합니다 @msg `{k}` must be [x, y] `{k}`은(는) [x, y]여야 합니다 @msg a gradient needs at least two stops 그레이디언트에는 정지점이 두 개 이상 필요합니다 @msg give both `start` and `end` [x, y] (or neither) `start`와 `end` [x, y]를 모두 지정하세요(또는 둘 다 생략) @@ -6085,6 +6086,10 @@ anchor Points: 포인트: @msg features lists at most {max} tags features에는 태그를 최대 {max}개까지 지정할 수 있습니다 @msg unknown OpenType feature `{t}` 알 수 없는 OpenType 기능 `{t}` @msg {label} must be a number above 0 and at most {max} {label}은(는) 0 초과 {max} 이하의 숫자여야 합니다 +@msg {label} must be at most {max} characters {label}은(는) 최대 {max}자여야 합니다 +@msg {label} must be one character {label}은(는) 한 글자여야 합니다 +@msg at most {max} tab stops 탭 정지는 최대 {max}개까지 지정할 수 있습니다 +@msg unknown align `{other}` 알 수 없는 정렬 `{other}` @msg no character style `{name}` 문자 스타일 `{name}`을 찾을 수 없습니다 # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 8d72f58fa4..c44333ad8b 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -4377,6 +4377,7 @@ imageTrace Outline Kontur @msg `globalAdjust` must be an object `globalAdjust` musi być obiektem @msg `sampleSize` must be 1, 3 or 5 `sampleSize` musi wynosić 1, 3 lub 5 @msg `stops` must be an array `stops` musi być tablicą +@msg `{key}` must be an array `{key}` musi być tablicą @msg `svg` must be an object of SVG options (see document.formats) `svg` musi być obiektem opcji SVG (patrz document.formats) @msg `{_1}` is no save command `{_1}` nie jest poleceniem zapisu @msg `{area}` is no recovery area name `{area}` nie jest nazwą obszaru odzyskiwania @@ -6351,6 +6352,10 @@ anchor Points: Punkty: @msg features lists at most {max} tags features może zawierać najwyżej {max} znaczników @msg unknown OpenType feature `{t}` nieznana funkcja OpenType `{t}` @msg {label} must be a number above 0 and at most {max} {label} musi być liczbą większą od 0 i nie większą niż {max} +@msg {label} must be at most {max} characters {label} może mieć najwyżej {max} znaków +@msg {label} must be one character {label} musi być jednym znakiem +@msg at most {max} tab stops najwyżej {max} tabulatorów +@msg unknown align `{other}` nieznane wyrównanie `{other}` @msg no character style `{name}` Nie znaleziono stylu znakowego `{name}` # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index eb2c29947a..c4c9994027 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3792,6 +3792,7 @@ axis Both Ambos @msg `globalAdjust` must be an object `globalAdjust` deve ser um objeto @msg `sampleSize` must be 1, 3 or 5 `sampleSize` deve ser 1, 3 ou 5 @msg `stops` must be an array `stops` deve ser uma matriz +@msg `{key}` must be an array `{key}` deve ser uma matriz @msg `svg` must be an object of SVG options (see document.formats) `svg` deve ser um objeto com opções de SVG (veja document.formats) @msg `{_1}` is no save command `{_1}` não é um comando de salvar @msg `{area}` is no recovery area name `{area}` não é o nome de uma área de recuperação @@ -6412,6 +6413,10 @@ anchor Points: Pontos: @msg features lists at most {max} tags features aceita no máximo {max} marcas @msg unknown OpenType feature `{t}` recurso OpenType desconhecido `{t}` @msg {label} must be a number above 0 and at most {max} {label} deve ser um número maior que 0 e no máximo {max} +@msg {label} must be at most {max} characters {label} deve ter no máximo {max} caracteres +@msg {label} must be one character {label} deve ser um único caractere +@msg at most {max} tab stops no máximo {max} paradas de tabulação +@msg unknown align `{other}` alinhamento desconhecido `{other}` @msg no character style `{name}` Estilo de caractere `{name}` não encontrado # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 533192c53a..d3d3a0bd53 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4282,6 +4282,7 @@ axis Both Оба @msg `globalAdjust` must be an object `globalAdjust` должно быть объектом @msg `sampleSize` must be 1, 3 or 5 `sampleSize` должно быть 1, 3 или 5 @msg `stops` must be an array `stops` должно быть массивом +@msg `{key}` must be an array `{key}` должно быть массивом @msg `svg` must be an object of SVG options (see document.formats) `svg` должно быть объектом параметров SVG (см. document.formats) @msg `{_1}` is no save command `{_1}` не является командой сохранения @msg `{area}` is no recovery area name `{area}` не является именем области восстановления @@ -6473,6 +6474,10 @@ anchor Points: Точки: @msg features lists at most {max} tags features содержит не более {max} тегов @msg unknown OpenType feature `{t}` неизвестная функция OpenType `{t}` @msg {label} must be a number above 0 and at most {max} {label} должно быть числом больше 0 и не более {max} +@msg {label} must be at most {max} characters {label} должно содержать не более {max} символов +@msg {label} must be one character {label} должно быть одним символом +@msg at most {max} tab stops не более {max} позиций табуляции +@msg unknown align `{other}` неизвестное выравнивание `{other}` @msg no character style `{name}` Стиль символов `{name}` не найден # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 558238e60e..7fb7252fab 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -4155,6 +4155,7 @@ axis Both Båda @msg `globalAdjust` must be an object `globalAdjust` måste vara ett objekt @msg `sampleSize` must be 1, 3 or 5 `sampleSize` måste vara 1, 3 eller 5 @msg `stops` must be an array `stops` måste vara en array +@msg `{key}` must be an array `{key}` måste vara en array @msg `svg` must be an object of SVG options (see document.formats) `svg` måste vara ett objekt med SVG-alternativ (se document.formats) @msg `{_1}` is no save command `{_1}` är inget sparkommando @msg `{area}` is no recovery area name `{area}` är inget namn på ett återställningsområde @@ -6543,6 +6544,9 @@ shape Open Öppen @msg features lists at most {max} tags features får innehålla högst {max} taggar @msg unknown OpenType feature `{t}` okänd OpenType-funktion `{t}` @msg {label} must be a number above 0 and at most {max} {label} måste vara ett tal över 0 och högst {max} +@msg {label} must be at most {max} characters {label} får innehålla högst {max} tecken +@msg {label} must be one character {label} måste vara ett tecken +@msg at most {max} tab stops högst {max} tabbstopp @msg no character style `{name}` Teckenformatet `{name}` hittades inte # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index f88726b3fc..7320e9ccf1 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -4035,6 +4035,7 @@ axis Both Her İkisi @msg `globalAdjust` must be an object `globalAdjust` bir nesne olmalıdır @msg `sampleSize` must be 1, 3 or 5 `sampleSize` 1, 3 veya 5 olmalıdır @msg `stops` must be an array `stops` bir dizi olmalıdır +@msg `{key}` must be an array `{key}` bir dizi olmalıdır @msg `svg` must be an object of SVG options (see document.formats) `svg`, SVG seçeneklerinin bir nesnesi olmalıdır (bkz. document.formats) @msg `{_1}` is no save command `{_1}` kaydetme komutu değil @msg `{area}` is no recovery area name `{area}`, kurtarma alanı adı değildir @@ -6452,6 +6453,10 @@ anchor Points: Noktalar: @msg features lists at most {max} tags features en fazla {max} etiket içerebilir @msg unknown OpenType feature `{t}` bilinmeyen OpenType özelliği `{t}` @msg {label} must be a number above 0 and at most {max} {label} 0'ın üzerinde ve en fazla {max} olan bir sayı olmalıdır +@msg {label} must be at most {max} characters {label} en fazla {max} karakter olmalıdır +@msg {label} must be one character {label} tek bir karakter olmalıdır +@msg at most {max} tab stops en fazla {max} sekme durağı +@msg unknown align `{other}` bilinmeyen hizalama `{other}` @msg no character style `{name}` `{name}` karakter stili bulunamadı # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 8038529844..bd1dd12694 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4280,6 +4280,7 @@ axis Both Обидві @msg `globalAdjust` must be an object `globalAdjust` має бути об’єктом @msg `sampleSize` must be 1, 3 or 5 `sampleSize` має бути 1, 3 або 5 @msg `stops` must be an array `stops` має бути масивом +@msg `{key}` must be an array `{key}` має бути масивом @msg `svg` must be an object of SVG options (see document.formats) `svg` має бути об’єктом параметрів SVG (див. document.formats) @msg `{_1}` is no save command `{_1}` не є командою збереження @msg `{area}` is no recovery area name `{area}` не є ім’ям області відновлення @@ -6104,6 +6105,10 @@ anchor Points: Точки: @msg features lists at most {max} tags features містить не більше {max} тегів @msg unknown OpenType feature `{t}` невідома функція OpenType `{t}` @msg {label} must be a number above 0 and at most {max} {label} має бути числом більше 0 і не більше {max} +@msg {label} must be at most {max} characters {label} має містити не більше {max} символів +@msg {label} must be one character {label} має бути одним символом +@msg at most {max} tab stops не більше {max} позицій табуляції +@msg unknown align `{other}` невідоме вирівнювання `{other}` @msg no character style `{name}` Стиль символів `{name}` не знайдено # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 6178748b4f..ba35702883 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -4414,6 +4414,7 @@ theme Appearance Giao diện @msg `globalAdjust` must be an object `globalAdjust` phải là một đối tượng @msg `sampleSize` must be 1, 3 or 5 `sampleSize` phải là 1, 3 hoặc 5 @msg `stops` must be an array `stops` phải là một mảng +@msg `{key}` must be an array `{key}` phải là một mảng @msg `svg` must be an object of SVG options (see document.formats) `svg` phải là đối tượng tùy chọn SVG (xem document.formats) @msg `to` must be "corner" or "smooth" `to` phải là "corner" hoặc "smooth" @msg `fontHeightReference` must be "emBox", "capHeight", "xHeight" or "icfBox" `fontHeightReference` phải là "emBox", "capHeight", "xHeight" hoặc "icfBox" @@ -6105,6 +6106,10 @@ anchor Points: Điểm: @msg features lists at most {max} tags features chứa tối đa {max} thẻ @msg unknown OpenType feature `{t}` tính năng OpenType không xác định `{t}` @msg {label} must be a number above 0 and at most {max} {label} phải là một số lớn hơn 0 và tối đa {max} +@msg {label} must be at most {max} characters {label} chỉ được có tối đa {max} ký tự +@msg {label} must be one character {label} phải là một ký tự +@msg at most {max} tab stops tối đa {max} điểm dừng tab +@msg unknown align `{other}` căn chỉnh không xác định `{other}` @msg no character style `{name}` Không tìm thấy kiểu ký tự `{name}` # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 20ae4298b6..b24163d7ce 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -4102,6 +4102,7 @@ imageTrace Outline 轮廓 @msg `preserve` must be an object `preserve` 必须是对象 @msg `globalAdjust` must be an object `globalAdjust` 必须是对象 @msg `stops` must be an array `stops` 必须是数组 +@msg `{key}` must be an array `{key}` 必须是数组 @msg `{k}` must be [x, y] `{k}` 必须是 [x, y] @msg a gradient needs at least two stops 渐变至少需要两个色标 @msg give both `start` and `end` [x, y] (or neither) 请同时给出 `start` 和 `end` [x, y] (或都不给出) @@ -6337,6 +6338,10 @@ anchor Points: 点: @msg features lists at most {max} tags features 最多包含 {max} 个标签 @msg unknown OpenType feature `{t}` 未知的 OpenType 特性 `{t}` @msg {label} must be a number above 0 and at most {max} {label} 必须是大于 0 且不超过 {max} 的数字 +@msg {label} must be at most {max} characters {label} 最多 {max} 个字符 +@msg {label} must be one character {label} 必须是一个字符 +@msg at most {max} tab stops 最多 {max} 个制表位 +@msg unknown align `{other}` 未知的对齐方式 `{other}` @msg no character style `{name}` 找不到字符样式 `{name}` # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index ee57010551..373e7d8f53 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1902,6 +1902,7 @@ file-filter Templates 範本 @msg `preserve` must be an object `preserve` 必須是物件 @msg `globalAdjust` must be an object `globalAdjust` 必須是物件 @msg `stops` must be an array `stops` 必須是陣列 +@msg `{key}` must be an array `{key}` 必須是陣列 @msg `{k}` must be [x, y] `{k}` 必須是 [x, y] @msg a gradient needs at least two stops 漸層至少需要兩個色標 @msg give both `start` and `end` [x, y] (or neither) 請同時提供 `start` 和 `end` [x, y] (或兩者都不提供) @@ -6142,6 +6143,10 @@ anchor Points: 點: @msg features lists at most {max} tags features 最多包含 {max} 個標籤 @msg unknown OpenType feature `{t}` 未知的 OpenType 功能 `{t}` @msg {label} must be a number above 0 and at most {max} {label} 必須是大於 0 且不超過 {max} 的數字 +@msg {label} must be at most {max} characters {label} 最多 {max} 個字元 +@msg {label} must be one character {label} 必須是一個字元 +@msg at most {max} tab stops 最多 {max} 個定位點 +@msg unknown align `{other}` 未知的對齊方式 `{other}` @msg no character style `{name}` 找不到字元樣式 `{name}` # Color Effects add-on: Duotone (Effect › Color Effects) and Print Ink Fixes (Object › Plug-ins › Color Effects). diff --git a/crates/ui-egui/src/panels/tabs.rs b/crates/ui-egui/src/panels/tabs.rs index 7e58a218a5..fb12e4fdc0 100644 --- a/crates/ui-egui/src/panels/tabs.rs +++ b/crates/ui-egui/src/panels/tabs.rs @@ -20,7 +20,7 @@ const ALIGNS: [(TabAlign, &str); 4] = [(TabAlign::Left, "⇥"), (TabAlign::Cente /// The name of alignment `a`'s button: its tool tip and what screen readers call it. A line of /// vertical type runs from top to bottom, so in vertical type a Left stop is named Top-Justified /// and a Right stop is named Bottom-Justified. -fn align_name(a: TabAlign, vertical: bool) -> &'static str { +pub(crate) fn align_name(a: TabAlign, vertical: bool) -> &'static str { match (a, vertical) { (TabAlign::Left, false) => tl!("Left-Justified Tab"), (TabAlign::Left, true) => tl!("Top-Justified Tab"), diff --git a/docs/mcp.md b/docs/mcp.md index b9c23ad2d1..a8141cfa5b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -7780,7 +7780,9 @@ touches (a caret, `start == end`, touches its paragraph). Without a range they c targeted objects, as before. With a range, the character attributes of `text.setStyle` and `text.setFormat` style that range of characters. When every paragraph that `text.tabs.set` or `text.tabs.clear` targets already has those stops, the command changes nothing and records no undo step. `text.tabs.set` rejects a supplied `align`, `leader`, or -`alignOn` value that is not a string, and an empty `alignOn` value. +`alignOn` value that is not a string, an empty `alignOn` value or one of more than one character, a leader of more than +8 characters, a position outside 0 to 100000 pt, and more than 256 stops. It sorts the stops by position and keeps the +first of stops at one position. `text.tabs.get {start?}` and `paraStyle.new` / `paraStyle.redefine {id, start?}` read the paragraph at `start`; `paraStyle.list` counts paragraphs as uses, and `charStyle.list` counts runs; both count the type in @@ -7797,7 +7799,7 @@ is the document's. A loaded style that is the same as the style of its name, or of its name with a number (`Body 2`), is not added again; any other is added under a free name. The document's styles and the type that uses them keep their attributes, and the file's Normal styles are not loaded. The reply lists the names `added` and `existing` in the document and the names `skipped` in the file (a style whose -attributes do not read or are out of the range `charStyle.new` takes, or whose name is empty); `textStyle.loadAll` +attributes do not read or are out of the range `charStyle.new` or `paraStyle.new` takes, or whose name is empty); `textStyle.loadAll` replies `{charStyles, paraStyles}`, and `charStyle.load` and `paraStyle.load` list only the styles of their own kind. A Return the Type tool types (or `text.editRange` inserting `\n`) continues the style of the paragraph it splits; deleting a break keeps the first paragraph's style; `text.setText` gives every paragraph the first one's attributes. Threaded text keeps each paragraph's attributes as @@ -7862,12 +7864,19 @@ the Paragraph Styles panel, or Paragraph Style Options… in its menu, does the `paraStyle.edit {name, to, attrs}`, which takes the same arguments as `charStyle.edit` and replaces the style's name and attributes in one undo step; the paragraphs that use the style keep their local overrides. OK with no field changed records no undo step, and Preview updates the paragraphs while the dialog is open (Cancel restores them). -The `paraStyleOptions` dialog exposes `name`, `section` (`general`, `basic`, `composition`, `hyphenation` or +The `paraStyleOptions` dialog exposes `name`, `section` (`general`, `basic`, `tabs`, `composition`, `hyphenation` or `justification`) and `preview`, and these fields: - Basic Paragraph Formats: `justify` (`Auto`, `Left`, `Center`, `Right`, `JustifyLeft`, `JustifyCenter`, `JustifyRight` or `JustifyAll`), and `left_indent`, `right_indent`, `first_line_indent`, `space_before` and `space_after`, typed in the General unit of Preferences or with a unit of their own. +- Tabs: `tabs`, the style's stops, each `{position, align, leader, alignOn}`: `position` typed like the distances + above, `align` `left`, `center`, `right` or `decimal`, `leader` up to 8 characters, and `alignOn` one character + (used by decimal stops). The section edits the selected stop with Alignment, X: (Y: for vertical type), Leader: + and Align On:. Add puts a left stop 36 pt past the furthest stop (at 36 pt when there is none, and at most at + 100000 pt); it is disabled at 256 stops or when a stop is already at that position. Delete Tab removes the selected + stop, and Clear All Tabs leaves the style with no stops, which it then sets. OK sorts the stops by + position; of two stops at one position it keeps the one edited last. - Composition: `composer` (`singleLine` or `everyLine`) and the `roman_hanging` checkbox. - Hyphenation: the `hyphenate` checkbox, `words_longer_than`, `after_first`, `before_last`, `hyphen_limit`, `hyphenation_zone` and the `hyphenate_capitalized` checkbox. The options are inactive while Hyphenation is off, and @@ -7878,15 +7887,17 @@ The `paraStyleOptions` dialog exposes `name`, `section` (`general`, `basic`, `co A field left as it was keeps the attribute as the style has it, and an empty field leaves its attribute unset; an empty Hyphenation or Justification field takes its default while another field of that section is set. Attributes -the dialog does not show, such as tabs and character attributes, stay as they are. +the dialog does not show, such as character attributes, stay as they are. `paraStyle.new`, `paraStyle.setAttrs` and `paraStyle.edit` reject a changed indent, space before or space after outside -1296 to 1296 pt, Words Longer Than outside 1 to 25, After First or Before Last outside 1 to 15, Hyphen Limit outside 0 to 25, Hyphenation Zone outside 0 to 1296 pt, Word Spacing outside 0 to 1000%, Letter Spacing outside -100 to 500%, Glyph Scaling outside 50 to 200%, Minimum, Desired and Maximum values out of that order, Auto Leading outside 0 to -500%, and a tab position outside 0 to 100000 pt. A value the style already has is kept. An invalid value leaves the +500%, and changed tabs with more than 256 stops, or with a changed stop whose position is outside 0 to 100000 pt or +whose leader is longer than 8 characters. They sort changed tabs by position and keep the first of stops at one +position. A value the style already has is kept. An invalid value leaves the style, its paragraphs and Undo history unchanged, and keeps the dialog open. Reset Panel removes the section's attributes from the definition: Basic Paragraph Formats removes `justify` and the -five distances, Composition removes `composer` and `roman_hanging`, Hyphenation removes `hyphenate` and +five distances, Tabs removes `tabs`, Composition removes `composer` and `roman_hanging`, Hyphenation removes `hyphenate` and `hyphenation`, and Justification removes `justification`. `ui.paraStyleOptions.resetPanel {}` performs that reset for the section selected in Paragraph Style Options or New Paragraph Style; it is disabled on General. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9c58bcaa8c..54fec89283 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -641,7 +641,8 @@ since 2026-10-01. - **Character Style Options, Character Color and OpenType Features:** Character Style Options and New Character Style set a style's fill and stroke (None or a document swatch; a global swatch stays linked, with a Tint from 0% to 100%), stroke Weight, Overprint Fill and Overprint Stroke, and its Figure, Position, Standard and Discretionary Ligatures, Contextual, Stylistic and Titling Alternates, Swash, Ordinals, Fractions and Stylistic Sets 1 to 20; text that uses the style takes them and keeps its local overrides, and editing a global swatch later updates the style and its text. Empty controls remain unset, an explicit None, off, Default Figure or Default Position stays distinct from unset, each stylistic set is on, off or unset, a gradient or pattern paint stays until another paint is chosen, a changed OpenType control keeps the style's other feature tags, and each section's Reset Panel clears only its own attributes. Public references: [Character and paragraph styles](https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html) and [Format text with OpenType features](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/format-text-with-opentype-features.html). - **Opening style options from the style panels:** a double-click on a user character style's row opens its Character Style Options (`ui.charStyleOptions`), as the panel menu's item does; a paragraph style's row opens its Paragraph Style Options (`ui.paraStyleOptions`), as the panel menu's item does. A single click still applies the style, the second click of the double-click does not apply it again, and the built-in Normal style opens no dialog. Test `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options`. - **Character Style Options window size:** the dialog keeps a fixed width wide enough for the widest tab's controls and scrolls its sections inside a window shorter than them, so it stays on screen and its OK and Cancel buttons stay visible; the width is the same in every section and the height follows the section. Test `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list`. -- **Paragraph Style Options, General, Basic Paragraph Formats, Composition, Hyphenation and Justification:** Paragraph Style Options (`ui.paraStyleOptions`, `paraStyle.edit`) and New Paragraph Style edit a style's name, Alignment, left, right and first-line indents, space before and after, composer, Roman Hanging Punctuation, Hyphenation with its options, and Word Spacing, Letter Spacing, Glyph Scaling, Auto Leading and Single Word Justification; paragraphs that use the style take them and keep their local overrides. Fields left as they were keep the style's values, empty fields remain unset, each section's Reset Panel clears only its own attributes, Preview can be canceled without changing history, OK saves one undo step, and out-of-range values keep the dialog open. Tabs and the character formats of a paragraph style are not in the dialog yet. Public reference: [Character and paragraph styles](https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). +- **Paragraph Style Options, General, Basic Paragraph Formats, Composition, Hyphenation and Justification:** Paragraph Style Options (`ui.paraStyleOptions`, `paraStyle.edit`) and New Paragraph Style edit a style's name, Alignment, left, right and first-line indents, space before and after, composer, Roman Hanging Punctuation, Hyphenation with its options, and Word Spacing, Letter Spacing, Glyph Scaling, Auto Leading and Single Word Justification; paragraphs that use the style take them and keep their local overrides. Fields left as they were keep the style's values, empty fields remain unset, each section's Reset Panel clears only its own attributes, Preview can be canceled without changing history, OK saves one undo step, and out-of-range values keep the dialog open. The character formats of a paragraph style are not in the dialog yet. Public reference: [Character and paragraph styles](https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). +- **Paragraph Style Options, Tabs:** the Tabs section of Paragraph Style Options and New Paragraph Style lists a style's tab stops and edits the selected stop's Alignment (Left, Center, Right or Decimal), position (X:, or Y: for vertical type), Leader and Align On character; Add, Delete Tab and Clear All Tabs add a stop 36 pt past the furthest, remove the selected one, or leave the style with no stops. OK sorts the stops by position, keeps the stop edited last of two at one position, and saves one undo step; Preview and Cancel restore the paragraphs, Reset Panel removes the style's tabs, and a position outside 0 to 100000 pt, a leader longer than 8 characters or more than 256 stops keeps the dialog open. Tests `dialogs::para_style_options::tests::tabs_section_edits_the_style_stops` and `tabs_preview_cancel_and_ok_update_the_paragraphs_in_one_undo_step`. Public reference: [Character and paragraph styles](https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). - **Text styles redefined to the defaults:** New and Redefine Character Style and Paragraph Style capture every attribute of the selected text, including those at their defaults (Auto kerning, Roman baseline alignment, the default Hyphenation options, the Every-line Composer, no tab stops), and `charStyle.setAttrs` and `paraStyle.setAttrs` keep an attribute set to its default value. After a redefinition, text whose value still equals the old definition's takes the new value, a default included; local overrides stay, and text that still has the old value of an attribute the new definition leaves out goes back to the Normal style's value. The style panels show "+" only where the text differs from its style, including type with Auto alignment. Public reference: [Character and paragraph styles](https://web.archive.org/web/20260825113735/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). - **Text styles dragged onto the panel's buttons:** a style dragged from the Character Styles or Paragraph Styles panel onto Create New Style is copied (`charStyle.duplicate`, `paraStyle.duplicate`, which name the copy `<name> copy`) and the copy is highlighted; dragged onto Delete it is deleted (`charStyle.delete`, `paraStyle.delete`); the Normal style is not deleted. Each drop is one undo step. Public reference: [Character and paragraph styles](https://web.archive.org/web/20261015081534/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). - **Auto Leading outside the layout:** a paragraph's Justification… › Auto Leading (<https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/adjust-word-and-letterspacing-in-justified-text.html>) also sets the Leading the Character panel shows for Auto type (blank where the selected paragraphs differ), the leading the Alt+↑/↓ nudge (`type.step`) steps from, each paragraph's own, and the lines Fill with Placeholder Text counts in area type. Evidence: `ParaStyle::effective_leading` (`crates/doc/src/text.rs` test `paragraph_effective_leading_follows_auto_leading`), `tests_prefs::auto_leading_steps_and_fills_from_the_paragraph_auto_leading`, `panels::character` test `leading_field_shows_the_paragraph_auto_leading`.