From cde367b889399232e6809637d8dc8196aa33db8f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 17:35:06 -0800 Subject: [PATCH 01/15] Plug-ins: only an approval records a grant, and installs need one registry::grant takes a GrantToken, which the engine makes after a person's approval and when it loads grants.json. registry::install, install_bytes and folder loads refuse a module that lists permissions no recorded grant gives this very module (Error::NeedsApproval), and leave the plug-in under its id and the revision as they were. A folder load's approve callback only narrows what installs. A module without permissions, or with an empty list, installs as before. A folder load in a session with a plug-in store records the store's grant for each module it approves, so those modules still load. docs/plugins.md says what the approval does not protect against: native code linked into the app, and another program running as the same user, which can edit the Plug-ins folder and its unsigned grants.json. --- crates/engine/src/cmd/plugin.rs | 9 +- crates/engine/src/store.rs | 32 ++++++- crates/engine/src/tests/plugins.rs | 64 +++++++++++-- crates/engine/tests/plugin_import.rs | 2 +- crates/plugins/src/effect.rs | 16 ++-- crates/plugins/src/lib.rs | 4 + crates/plugins/src/permissions.rs | 26 ++++++ crates/plugins/src/registry.rs | 134 +++++++++++++++++++++++---- crates/plugins/tests/grant_sites.rs | 82 ++++++++++++++++ crates/plugins/tests/safe_mode.rs | 4 +- docs/plugins.md | 7 ++ docs/target-app-parity.md | 1 + 12 files changed, 339 insertions(+), 42 deletions(-) create mode 100644 crates/plugins/tests/grant_sites.rs diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index a5b71d538b..909c9de1c1 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -52,7 +52,7 @@ use vectorcraft_plugins::{Budget, Command, Kind, Plugin, effect, export, objects use super::{CommandSpec, EngineError, NodeId, Result, Session, always, bad, cmd, has_doc, ids_param, str_param, switch_or}; -use vectorcraft_plugins::permissions::{self, Grant, Permission}; +use vectorcraft_plugins::permissions::{self, Grant, GrantToken, Permission}; /// What a plug-in error looks like to the user. A run the caller cancelled changed nothing. fn err(e: vectorcraft_plugins::Error) -> EngineError { @@ -748,7 +748,7 @@ fn install(s: &mut Session, p: &Value) -> Result { let mut stored = (previous.is_ok() || !m.asks_permissions()) && s.store_plugin(plugin.id(), &bytes); if m.asks_permissions() { let g = Grant { id: m.id.clone(), hash: plugin.hash().to_string(), permissions: wanted }; - registry::grant(g.clone()); + registry::grant(GrantToken::after_engine_approval(g.clone())); if stored && !s.store_grant(&m.id, Some(g)) { s.put_back_plugin(plugin.id(), previous.ok().flatten().as_deref()); stored = false; @@ -786,7 +786,8 @@ fn install(s: &mut Session, p: &Value) -> Result { if changed { s.store_disabled_plugins(); } - let mut v = describe_installed(®istry::install(plugin)); + let installed = registry::install(plugin).map_err(err)?; + let mut v = describe_installed(&installed); if !unknown.is_empty() { v["unknownCommands"] = json!(unknown); } @@ -885,7 +886,7 @@ fn load_folder(s: &Session, folder: &str) -> Value { None if crate::file_access::current().is_none() => Ok(()), None => Err(format!("automation path rejected: {} isn't valid text", f.display())), }; - match registry::load_folder_where(std::path::Path::new(folder), allow, s.plugin_approver()) { + match registry::load_folder_where(std::path::Path::new(folder), allow, s.plugin_folder_approver()) { Ok(r) => { for id in &r.loaded { s.attach_plugin_storage(id); diff --git a/crates/engine/src/store.rs b/crates/engine/src/store.rs index 01ebc46526..66501f6aa0 100644 --- a/crates/engine/src/store.rs +++ b/crates/engine/src/store.rs @@ -39,6 +39,7 @@ use std::sync::{Arc, Mutex, MutexGuard}; use std::time::SystemTime; use vectorcraft_pdf::{PdfPreset, builtin_preset}; +use vectorcraft_plugins::permissions::GrantToken; use vectorcraft_plugins::safe_mode; use vectorcraft_plugins::storage::{self, Meter, Storage, StorageError}; @@ -383,9 +384,11 @@ impl Session { match grants.get(&m.id).filter(|g| grant_covers(g, &plugin)) { Some(g) => { asks_nothing.remove(&m.id); - vectorcraft_plugins::registry::grant(g.clone()); + vectorcraft_plugins::registry::grant(GrantToken::from_engine_grants_file(g.clone())); vectorcraft_plugins::registry::set_storage(&m.id, plugin_storage(Some(&dir), &m.id)); - vectorcraft_plugins::registry::install(plugin.with_source(path.display().to_string())); + if let Err(e) = vectorcraft_plugins::registry::install(plugin.with_source(path.display().to_string())) { + log::warn!("skipping the stored plug-in {}: {e}", path.display()); + } } None => { log::warn!("skipping the stored plug-in {}: the user hasn't granted this module the permissions it lists", path.display()) @@ -395,7 +398,9 @@ impl Session { Ok(plugin) => { asks_nothing.insert(plugin.id().to_string()); vectorcraft_plugins::registry::set_storage(plugin.id(), plugin_storage(Some(&dir), plugin.id())); - vectorcraft_plugins::registry::install(plugin.with_source(path.display().to_string())); + if let Err(e) = vectorcraft_plugins::registry::install(plugin.with_source(path.display().to_string())) { + log::warn!("skipping the stored plug-in {}: {e}", path.display()); + } } Err(e) => log::warn!("skipping the stored plug-in {}: {e}", path.display()), } @@ -469,6 +474,27 @@ impl Session { } } + /// [`Session::plugin_approver`] for a folder load: a module the store's grants approve gets + /// that grant recorded for the process too, since the registry installs only a module that a + /// recorded grant covers ([`vectorcraft_plugins::registry::install`]). + #[cfg(not(target_arch = "wasm32"))] + pub(crate) fn plugin_folder_approver(&self) -> impl Fn(&vectorcraft_plugins::Plugin) -> bool + use<> { + let grants = self.plugin_dir.as_deref().map(|dir| self.counted_grants(dir)); + move |plugin| { + let m = plugin.manifest(); + match &grants { + Some(grants) if m.asks_permissions() => match grants.get(&m.id).filter(|g| grant_covers(g, plugin)) { + Some(g) => { + vectorcraft_plugins::registry::grant(GrantToken::from_engine_grants_file(g.clone())); + true + } + None => false, + }, + _ => vectorcraft_plugins::registry::approved(plugin), + } + } + } + /// The grants kept in the plug-in store `dir` ([`stored_grants`]) without those this session /// doesn't count ([`Session::ignore_stored_grant`]). fn counted_grants(&self, dir: &Path) -> BTreeMap { diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index a4be6e554f..324961a71e 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -1587,7 +1587,7 @@ fn a_module_without_permissions_ends_the_grant_of_its_id() { /// that holds such a module and a grant for its id has that grant forgotten when it is attached. #[test] fn a_module_without_permissions_ends_the_grant_kept_in_the_store() { - use vectorcraft_plugins::permissions::{Grant, Permission, sha256_hex}; + use vectorcraft_plugins::permissions::{Grant, GrantToken, Permission, sha256_hex}; let folder = crate::tests_links::Folder::new("plugin-store-stale-grant"); let id = "test.engine.permissions.storedstale"; let other = "test.engine.permissions.storedother"; @@ -1634,7 +1634,11 @@ fn a_module_without_permissions_ends_the_grant_kept_in_the_store() { let future = json!({"id": id, "hash": "00", "permissions": ["doc.read", "ui.panel"]}); std::fs::write(&grants, serde_json::to_vec(&json!([future, other_entry])).unwrap()).unwrap(); vectorcraft_plugins::registry::remove(id); - vectorcraft_plugins::registry::grant(Grant { id: id.into(), hash: sha256_hex(&approved), permissions: vec![Permission::DocRead] }); + vectorcraft_plugins::registry::grant(GrantToken::after_engine_approval(Grant { + id: id.into(), + hash: sha256_hex(&approved), + permissions: vec![Permission::DocRead], + })); let mut fourth = session(); fourth.attach_plugin_store(folder.0.clone()); assert_eq!(ids(), vec![other.to_string()]); @@ -2046,7 +2050,7 @@ fn a_plugin_folder_ends_the_process_grant_of_an_id_it_replaces() { /// With a store attached, a matching grant held only by the process doesn't count. #[test] fn a_stored_grant_covers_only_the_stored_module_it_recorded() { - use vectorcraft_plugins::permissions::{Grant, Permission, sha256_hex}; + use vectorcraft_plugins::permissions::{Grant, GrantToken, Permission, sha256_hex}; let folder = crate::tests_links::Folder::new("plugin-store-recorded-module"); let id = "test.engine.permissions.storedbytes"; let first = asking(id, r#"["doc.read"]"#, &json!({"objects": []})); @@ -2091,7 +2095,11 @@ fn a_stored_grant_covers_only_the_stored_module_it_recorded() { assert!(starts()); let mut attached = session(); attached.attach_plugin_store(folder.0.clone()); - vectorcraft_plugins::registry::grant(Grant { id: id.into(), hash: sha256_hex(&changed), permissions: vec![Permission::DocRead] }); + vectorcraft_plugins::registry::grant(GrantToken::after_engine_approval(Grant { + id: id.into(), + hash: sha256_hex(&changed), + permissions: vec![Permission::DocRead], + })); let inspected = run(&mut attached, "plugin.inspect", json!({"dataBase64": vectorcraft_format::base64_encode(&changed)})); assert_eq!(inspected["needsApproval"], true, "a grant held only by the process doesn't count with a store attached"); run(&mut attached, "plugin.remove", json!({"id": id})); @@ -2361,8 +2369,15 @@ fn a_run_past_the_memory_budget_fails_before_the_plugin_starts() { params.join(",") ); let module = wat::parse_str(tpl::module(&manifest, "unreachable", "", 1)).unwrap(); - // One page of memory: the input and the parameters share 32 KiB. - registry::install(Plugin::load(&module, Limits { max_memory_bytes: 1 << 16, ..Limits::default() }).unwrap()); + // One page of memory: the input and the parameters share 32 KiB. The module lists permissions, + // so its grant is recorded first. + let plugin = Plugin::load(&module, Limits { max_memory_bytes: 1 << 16, ..Limits::default() }).unwrap(); + registry::grant(vectorcraft_plugins::permissions::GrantToken::after_engine_approval(vectorcraft_plugins::permissions::Grant { + id: id.into(), + hash: plugin.hash().to_string(), + permissions: plugin.manifest().permissions(), + })); + registry::install(plugin).unwrap(); let mut s = session(); let small = rect(&mut s, 10.0, "#ff0000"); let long = rect(&mut s, 30.0, "#00ff00"); @@ -2403,7 +2418,7 @@ fn a_run_past_the_memory_budget_fails_before_the_plugin_starts() { assert!(e.contains(says), "{says}: {e}"); assert_eq!((s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len()), before, "{says}"); } - assert!(registry::remove(id)); + assert!(registry::remove(id) && registry::revoke(id)); } /// What the user granted is kept in the plug-in store with the module: the next session installs @@ -2494,6 +2509,41 @@ fn granted_permissions_are_kept_and_folders_skip_the_rest() { run(&mut granted, "plugin.remove", json!({"id": id})); } +/// The registry installs a module that lists permissions only under a grant the process holds, so +/// a start and a `plugin.reload` record the store's grant for the module they load. A module in the +/// folder that nobody granted is reported and skipped, and records nothing. +#[test] +fn stored_and_reload_grants_still_install() { + let _folder = PREFS_FOLDER.lock().unwrap_or_else(|e| e.into_inner()); + let store = crate::tests_links::Folder::new("plugin-store-grants-reload"); + let id = "test.engine.permissions.reload"; + let lone = "test.engine.permissions.reload.lone"; + let wasm = asking(id, r#"["doc.read"]"#, &json!({"objects": []})); + let mut s = session(); + s.attach_plugin_store(store.0.clone()); + run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wasm), "grant": ["doc.read"]})); + let forget = || { + vectorcraft_plugins::registry::remove(id); + vectorcraft_plugins::registry::revoke(id); + }; + forget(); + let mut next = session(); + next.attach_plugin_store(store.0.clone()); + assert!(vectorcraft_plugins::registry::get(id).is_some(), "installed again from the store"); + assert_eq!(vectorcraft_plugins::registry::granted(id).map(|g| g.hash), Some(vectorcraft_plugins::permissions::sha256_hex(&wasm))); + forget(); + let dir = crate::tests_links::Folder::new("plugin-folder-grants-reload"); + std::fs::write(dir.0.join("granted.wasm"), &wasm).unwrap(); + std::fs::write(dir.0.join("lone.wasm"), asking(lone, r#"["doc.read"]"#, &json!({"objects": []}))).unwrap(); + let r = run(&mut next, "plugin.reload", json!({"path": dir.0.display().to_string()})); + assert_eq!(r["loaded"], json!([id]), "{r}"); + assert!(vectorcraft_plugins::registry::granted(id).is_some(), "the store's grant is the process's too"); + let why = r["failed"][0]["error"].as_str().unwrap(); + assert!(why.contains("needs approval") && r["failed"][0]["file"].as_str().unwrap().ends_with("lone.wasm"), "{r}"); + assert!(vectorcraft_plugins::registry::get(lone).is_none() && vectorcraft_plugins::registry::granted(lone).is_none()); + run(&mut next, "plugin.remove", json!({"id": id})); +} + /// A grant covers the module it was given to: another module under the id, listing the same /// permissions, isn't installed from the store at the next start, a folder load reports it as /// needing approval, and `plugin.install` asks for `grant` again, even with `replace: true`. It diff --git a/crates/engine/tests/plugin_import.rs b/crates/engine/tests/plugin_import.rs index b780c115c3..f091062308 100644 --- a/crates/engine/tests/plugin_import.rs +++ b/crates/engine/tests/plugin_import.rs @@ -326,7 +326,7 @@ fn an_importer_past_its_budget_opens_nothing() { ]; for (id, limits, _) in &cases { let wasm = wat::parse_str(tpl::module(&tpl::importer(id, r#"["tbg"]"#, "{}"), SPINS, "", 1)).unwrap(); - vectorcraft_plugins::registry::install(Plugin::load(&wasm, limits.clone()).unwrap()); + vectorcraft_plugins::registry::install(Plugin::load(&wasm, limits.clone()).unwrap()).unwrap(); } let mut s = Session::new(); for (id, _, why) in &cases { diff --git a/crates/plugins/src/effect.rs b/crates/plugins/src/effect.rs index f06d17e242..6d549ed070 100644 --- a/crates/plugins/src/effect.rs +++ b/crates/plugins/src/effect.rs @@ -552,7 +552,7 @@ mod tests { // Traps on a long input. let first = "(if (i32.gt_u (local.get $il) (i32.const 4096)) (then unreachable))"; let src = crate::wat::with_imports(&manifest, "", first, r#"{"objects":[]}"#); - registry::install(crate::Plugin::load(&wat::parse_str(&src).unwrap(), Limits::default()).unwrap()); + registry::install(crate::Plugin::load(&wat::parse_str(&src).unwrap(), Limits::default()).unwrap()).unwrap(); let doc = Document::new(100.0, 100.0); let line = |n: usize| PathData::single(SubPath::polyline(&(0..n).map(|i| Point::new(i as f64, 0.0)).collect::>(), false)); let draw = @@ -583,7 +583,7 @@ mod tests { // Traps on a long input. let first = "(if (i32.gt_u (local.get $il) (i32.const 4096)) (then unreachable))"; let src = crate::wat::with_imports(&manifest, "", first, &output); - registry::install(crate::Plugin::load(&wat::parse_str(&src).unwrap(), Limits::default()).unwrap()); + registry::install(crate::Plugin::load(&wat::parse_str(&src).unwrap(), Limits::default()).unwrap()).unwrap(); let doc = Document::new(100.0, 100.0); let line = |n: usize| PathData::single(SubPath::polyline(&(0..n).map(|i| Point::new(i as f64, 0.0)).collect::>(), false)); let draw = @@ -647,7 +647,7 @@ mod tests { crate::safe_mode::set_active(None); const ID: &str = "test.effect.budget"; let src = crate::wat::returning(&crate::wat::manifest(ID, "effect", "{}"), r#"{"objects":[]}"#); - registry::install(crate::Plugin::load(&wat::parse_str(&src).unwrap(), Limits::default()).unwrap()); + registry::install(crate::Plugin::load(&wat::parse_str(&src).unwrap(), Limits::default()).unwrap()).unwrap(); // Each run takes 900 ms by this clock (it is read before and after each run). let view = crate::deferred::Deferrals::with_clock(stepping(450)); let ran = view.view(|| (0..4).map(|i| drawn(ID, &line(i + 2), i)).collect::>()); @@ -677,7 +677,7 @@ mod tests { const ID: &str = "test.effect.deferfailed"; // Runs to its instruction budget. let src = crate::wat::spinning(&crate::wat::manifest(ID, "effect", "{}"), 1_000_000_000, r#"{"objects":[]}"#); - registry::install(crate::Plugin::load(&wat::parse_str(&src).unwrap(), Limits::default()).unwrap()); + registry::install(crate::Plugin::load(&wat::parse_str(&src).unwrap(), Limits::default()).unwrap()).unwrap(); let view = crate::deferred::Deferrals::with_clock(stepping(1)); assert!(!view.view(|| drawn(ID, &line(2), 0))); assert!(last_error(ID).is_some()); @@ -712,9 +712,9 @@ mod tests { const FAST: &str = "test.effect.cutshort.fast"; let src = crate::wat::spinning(&crate::wat::manifest(ID, "effect", "{}"), 10_000_000, r#"{"objects":[]}"#); let limits = Limits { fuel_base: 1_000_000_000, ..Limits::default() }; - registry::install(crate::Plugin::load(&wat::parse_str(&src).unwrap(), limits).unwrap()); + registry::install(crate::Plugin::load(&wat::parse_str(&src).unwrap(), limits).unwrap()).unwrap(); let fast = crate::wat::returning(&crate::wat::manifest(FAST, "effect", "{}"), r#"{"objects":[]}"#); - registry::install(crate::Plugin::load(&wat::parse_str(&fast).unwrap(), Limits::default()).unwrap()); + registry::install(crate::Plugin::load(&wat::parse_str(&fast).unwrap(), Limits::default()).unwrap()).unwrap(); // The first run starts the budget at 0 ms and ends at 1,999: the next has 1 ms left, and // ten million steps take longer. let reads = Arc::new(std::sync::atomic::AtomicU64::new(0)); @@ -737,7 +737,7 @@ mod tests { let _lock = crate::safe_mode::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); crate::safe_mode::set_active(None); let src = crate::wat::spinning(&crate::wat::manifest("test.effect.cancelled", "effect", "{}"), 1_000_000_000, r#"{"objects":[]}"#); - let plugin = registry::install(crate::Plugin::load(&wat::parse_str(&src).unwrap(), Limits::default()).unwrap()); + let plugin = registry::install(crate::Plugin::load(&wat::parse_str(&src).unwrap(), Limits::default()).unwrap()).unwrap(); let ran_first = || RUNS.lock().unwrap_or_else(|e| e.into_inner()).as_ref().is_some_and(|r| matches!(r.get(&plugin.serial()), Some(Runs::Done))); let draw = || { @@ -777,7 +777,7 @@ mod tests { crate::safe_mode::parse_marker(&std::fs::read(&json[0]).unwrap()) }; let src = crate::wat::returning(&crate::wat::manifest("test.effect.overlap", "effect", "{}"), r#"{"objects":[]}"#); - let plugin = registry::install(crate::Plugin::load(&wat::parse_str(&src).unwrap(), Limits::default()).unwrap()); + let plugin = registry::install(crate::Plugin::load(&wat::parse_str(&src).unwrap(), Limits::default()).unwrap()).unwrap(); let named = Some(crate::safe_mode::Item::of(&plugin)); assert_eq!(marker(), None); diff --git a/crates/plugins/src/lib.rs b/crates/plugins/src/lib.rs index c71200187b..b304e1721e 100644 --- a/crates/plugins/src/lib.rs +++ b/crates/plugins/src/lib.rs @@ -89,6 +89,10 @@ pub enum Error { /// Safe Mode is on ([`safe_mode`]): no plug-in loads or runs. #[error("Safe Mode is on: plug-ins don't load or run until VectorCraft starts normally")] SafeMode, + /// The module lists permissions that no recorded grant gives this very module + /// ([`registry::approved`]): it names them, comma-separated. + #[error("needs approval: it asks for {0}, which the user grants when installing it on its own")] + NeedsApproval(String), #[error("{0}")] Io(String), } diff --git a/crates/plugins/src/permissions.rs b/crates/plugins/src/permissions.rs index 7380edd568..b8c8672693 100644 --- a/crates/plugins/src/permissions.rs +++ b/crates/plugins/src/permissions.rs @@ -275,6 +275,32 @@ impl Grant { } } +/// A [`Grant`] that [`crate::registry::grant`] records. The engine makes one where it records what +/// a person approved and where it loads the grants kept in its plug-in folder; tests that set up a +/// grant use the same constructors. `tests/grant_sites.rs` fails when other shipped code makes one. +/// Native code linked into the app can still call these constructors, so the token does not +/// protect against such code. +#[derive(Debug)] +pub struct GrantToken(Grant); + +impl GrantToken { + /// The grant the engine records after a person approved the module it covers, or in a session + /// whose starter allowed requests to grant permissions (`--allow-plugin-grants`). + pub fn after_engine_approval(grant: Grant) -> Self { + Self(grant) + } + + /// A grant the engine read from the grants file of its plug-in folder, for a module it loads. + pub fn from_engine_grants_file(grant: Grant) -> Self { + Self(grant) + } + + /// The grant it holds. + pub fn grant(&self) -> &Grant { + &self.0 + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/plugins/src/registry.rs b/crates/plugins/src/registry.rs index be7f12180f..ee3732908c 100644 --- a/crates/plugins/src/registry.rs +++ b/crates/plugins/src/registry.rs @@ -7,9 +7,8 @@ use std::collections::{BTreeMap, HashSet}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex, RwLock}; -#[cfg(not(target_arch = "wasm32"))] use crate::Error; -use crate::permissions::Grant; +use crate::permissions::{Grant, GrantToken}; use crate::storage::Storage; use crate::{Limits, Plugin, Result}; @@ -35,8 +34,11 @@ pub fn storage(id: &str) -> Option> { STORAGE.read().unwrap_or_else(|e| e.into_inner()).get(id).cloned() } -/// Records what the user approved for a plug-in, replacing an earlier grant for its id. -pub fn grant(g: Grant) { +/// Records what the user approved for a plug-in, replacing an earlier grant for its id. Only a +/// [`GrantToken`] is recorded, which the engine makes after an approval or when it loads its grants +/// file. +pub fn grant(token: GrantToken) { + let g = token.grant().clone(); GRANTS.write().unwrap_or_else(|e| e.into_inner()).insert(g.id.clone(), g); } @@ -63,20 +65,27 @@ pub fn approved(plugin: &Plugin) -> bool { !m.asks_permissions() || granted(&m.id).is_some_and(|g| g.covers(&m.id, plugin.hash(), &m.permissions())) } -/// Installs `plugin`, replacing one with the same id. -pub fn install(mut plugin: Plugin) -> Arc { +/// Installs `plugin`, replacing one with the same id, when it is [`approved`]: a module that lists +/// permissions no recorded grant gives this very module is refused ([`Error::NeedsApproval`]), and +/// the plug-in installed under its id, the grants and the revision stay as they were. +pub fn install(mut plugin: Plugin) -> Result> { // The revision moves while the write lock is held, so a cache that reads the new revision and - // then the plug-ins finds the new plug-in among them. + // then the plug-ins finds the new plug-in among them. The approval is checked while it is held + // too. let mut plugins = PLUGINS.write().unwrap_or_else(|e| e.into_inner()); + if !approved(&plugin) { + return Err(Error::NeedsApproval(crate::permissions::names(&plugin.manifest().permissions()).join(", "))); + } plugin.set_serial(REVISION.fetch_add(1, Ordering::Relaxed) + 1); let p = Arc::new(plugin); plugins.insert(p.id().to_string(), p.clone()); - p + Ok(p) } -/// Loads `bytes` with the default limits and installs the plug-in. +/// Loads `bytes` with the default limits and installs the plug-in ([`install`]: one that lists +/// permissions installs only when they were granted to this very module). pub fn install_bytes(bytes: &[u8]) -> Result> { - Ok(install(Plugin::load(bytes, Limits::default())?)) + install(Plugin::load(bytes, Limits::default())?) } pub fn get(id: &str) -> Option> { @@ -162,7 +171,8 @@ pub fn load_folder(dir: &std::path::Path) -> Result { } /// [`load_folder`], loading only the files `allow` accepts, and of the plug-ins that list -/// permissions only those `approve` accepts; the others are reported as failed with the reason. A plug-in the user disabled ([`crate::safe_mode::skipped`]) is reported and +/// permissions only those `approve` accepts and a recorded grant covers ([`install`]); the others +/// are reported as failed with the reason. A plug-in the user disabled ([`crate::safe_mode::skipped`]) is reported and /// skipped, by its file before it loads or by its id after (its file is then disabled too, so the /// next load skips it before it runs); the launch marker names each file while it loads /// ([`crate::safe_mode::loading`]). In Safe Mode nothing loads ([`Error::SafeMode`]). When the @@ -206,12 +216,14 @@ pub fn load_folder_where( } Ok(p) if p.manifest().asks_permissions() && !approve(&p) => { let asked = crate::permissions::names(&p.manifest().permissions()).join(", "); - report.failed.push(( - f.display().to_string(), - format!("needs approval: it asks for {asked}, which the user grants when installing it on its own"), - )); + report.failed.push((f.display().to_string(), Error::NeedsApproval(asked).to_string())); } - Ok(p) => report.loaded.push(install(p).id().to_string()), + // `approve` only narrows what installs: `install` still refuses a module that no + // recorded grant covers. + Ok(p) => match install(p) { + Ok(p) => report.loaded.push(p.id().to_string()), + Err(e) => report.failed.push((f.display().to_string(), e.to_string())), + }, Err(e) => report.failed.push((f.display().to_string(), e.to_string())), } } @@ -231,6 +243,7 @@ mod tests { /// waiting for the set leaves the revision alone until it holds the set. #[test] fn an_install_changes_the_revision_only_while_it_holds_the_set() { + let _lock = crate::safe_mode::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let plugin = Plugin::load(include_bytes!("../tests/fixtures/desaturate.wasm"), Limits::default()).unwrap(); let id = plugin.id().to_string(); let held = PLUGINS.read().unwrap_or_else(|e| e.into_inner()); @@ -241,8 +254,95 @@ mod tests { revision() != before }); drop(held); - installing.join().unwrap(); + installing.join().unwrap().unwrap(); assert!(!moved, "the revision changed while the install waited for the set"); assert!(revision() > before && remove(&id)); } + + /// A module of a filter `id` whose manifest lists `permissions` (a JSON list, or nothing), with + /// `marker` in its code so that two markers give two SHA-256s. + fn module(id: &str, permissions: Option<&str>, marker: i64) -> Vec { + let listed = permissions.map(|p| format!(r#","permissions":{p}"#)).unwrap_or_default(); + let manifest = format!(r#"{{"id":"{id}","name":"T","kind":"filter"{listed}}}"#); + wat::parse_str(crate::wat::module(&manifest, &format!("(i64.const {marker}) (drop) (i64.const 0)"), "", 1)).unwrap() + } + + fn grant_for(id: &str, bytes: &[u8], permissions: Vec) -> Grant { + Grant { id: id.into(), hash: crate::permissions::sha256_hex(bytes), permissions } + } + + /// A module that lists permissions installs only once a recorded grant gives this very module + /// all of them: refused, nothing changes, neither the revision nor the plug-in under its id. + #[test] + fn install_bytes_refuses_unapproved_manifest_permissions() { + // The other tests that install or remove plug-ins hold it, so the revision moves only here. + let _lock = crate::safe_mode::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let id = "test.registry.unapproved"; + let plain = install_bytes(&module(id, None, 1)).unwrap(); + let before = revision(); + let e = install_bytes(&module(id, Some(r#"["doc.read"]"#), 2)).unwrap_err(); + assert_eq!(e, Error::NeedsApproval("doc.read".into())); + assert_eq!(revision(), before, "nothing installed"); + assert_eq!(get(id).map(|p| p.hash().to_string()), Some(plain.hash().to_string()), "the plug-in before it stays"); + assert!(granted(id).is_none()); + assert!(remove(id)); + } + + /// `install` itself and a folder load whose `approve` accepts everything refuse the module too. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn install_and_folder_callback_cannot_bypass_approval() { + let _lock = crate::safe_mode::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + crate::safe_mode::set_active(None); + let id = "test.registry.bypass"; + let plain = install_bytes(&module(id, Some("[]"), 3)).unwrap(); + let asking = module(id, Some(r#"["doc.read","doc.write"]"#), 4); + let e = install(Plugin::load(&asking, Limits::default()).unwrap()).unwrap_err(); + assert_eq!(e, Error::NeedsApproval("doc.read, doc.write".into())); + let dir = std::env::temp_dir().join(format!("vc-registry-bypass-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("asking.wasm"), &asking).unwrap(); + let r = load_folder_where(&dir, |_| Ok(()), |_| true).unwrap(); + std::fs::remove_dir_all(&dir).unwrap(); + assert!(r.loaded.is_empty(), "{r:?}"); + assert_eq!(r.failed.len(), 1); + assert!(r.failed[0].1.starts_with("needs approval: it asks for doc.read, doc.write"), "{r:?}"); + assert_eq!(get(id).map(|p| p.hash().to_string()), Some(plain.hash().to_string()), "the plug-in before it stays"); + assert!(remove(id)); + } + + /// A recorded grant lets in only the module with its id and SHA-256 that lists nothing it + /// doesn't grant; a module whose manifest lists no permissions, or an empty list, needs none. + #[test] + fn matching_grant_allows_only_exact_bytes() { + use crate::permissions::Permission::{DocRead, DocWrite}; + let _lock = crate::safe_mode::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let id = "test.registry.exact"; + let bytes = module(id, Some(r#"["doc.read","doc.write"]"#), 5); + let plugin = Plugin::load(&bytes, Limits::default()).unwrap(); + assert_eq!(plugin.hash(), crate::permissions::sha256_hex(&bytes), "a grant's hash is the loaded module's"); + let refused = |g: Grant, why: &str| { + let other = g.id.clone(); + revoke(id); + grant(GrantToken::after_engine_approval(g)); + assert!(matches!(install_bytes(&bytes), Err(Error::NeedsApproval(_))), "{why}"); + assert!(get(id).is_none(), "{why}"); + revoke(&other); + }; + refused(grant_for(id, &module(id, Some(r#"["doc.read","doc.write"]"#), 6), vec![DocRead, DocWrite]), "other bytes"); + refused(grant_for("test.registry.other", &bytes, vec![DocRead, DocWrite]), "another id"); + refused(Grant { id: id.into(), hash: "not a hash".into(), permissions: vec![DocRead, DocWrite] }, "a malformed hash"); + refused( + Grant { id: id.into(), hash: crate::permissions::sha256_hex(&bytes).to_uppercase(), permissions: vec![DocRead, DocWrite] }, + "an upper-case hash", + ); + refused(grant_for(id, &bytes, vec![DocRead]), "a missing permission"); + grant(GrantToken::from_engine_grants_file(grant_for(id, &bytes, vec![DocRead, DocWrite]))); + assert_eq!(install_bytes(&bytes).unwrap().hash(), plugin.hash()); + assert!(remove(id) && revoke(id)); + for listed in [None, Some("[]")] { + assert!(install_bytes(&module(id, listed, 7)).is_ok(), "{listed:?}"); + assert!(granted(id).is_none() && remove(id), "{listed:?}"); + } + } } diff --git a/crates/plugins/tests/grant_sites.rs b/crates/plugins/tests/grant_sites.rs new file mode 100644 index 0000000000..d0c0479337 --- /dev/null +++ b/crates/plugins/tests/grant_sites.rs @@ -0,0 +1,82 @@ +//! The shipped code that records a plug-in grant: only the engine's install approval and its load +//! of the plug-in folder's grants file make a `GrantToken` and call `registry::grant`. A new call +//! site fails this test until it is added to the list. It checks the source, not what native code +//! can do at run time. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +/// `(text, file, how many times)` for every place shipped code may name it. +const EXPECTED: &[(&str, &str, usize)] = &[ + ("GrantToken::after_engine_approval", "crates/engine/src/cmd/plugin.rs", 1), + ("GrantToken::from_engine_grants_file", "crates/engine/src/store.rs", 2), + ("registry::grant", "crates/engine/src/cmd/plugin.rs", 1), + ("registry::grant", "crates/engine/src/store.rs", 2), +]; + +/// Every `.rs` file under `dir`, without test files and folders. +fn sources(dir: &Path, out: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { return }; + for entry in entries.flatten() { + let path = entry.path(); + let name = entry.file_name().to_string_lossy().to_string(); + if path.is_dir() { + if !matches!(name.as_str(), "tests" | "benches" | "examples" | "target" | "fuzz") { + sources(&path, out); + } + } else if name.ends_with(".rs") && name != "tests.rs" && !name.starts_with("tests_") && !name.ends_with("_tests.rs") { + out.push(path); + } + } +} + +/// The lines of `text` that ship: not comments, and nothing from a top-level `#[cfg(test)] mod x {` +/// on. +fn shipped(text: &str) -> Vec<&str> { + let lines: Vec<&str> = text.lines().collect(); + let opens_test_module = |i: usize| { + lines[i] == "#[cfg(test)]" + && lines[i + 1..].iter().find(|l| !l.trim().is_empty()).is_some_and(|l| l.starts_with("mod ") && l.trim_end().ends_with('{')) + }; + let end = (0..lines.len()).find(|&i| opens_test_module(i)).unwrap_or(lines.len()); + lines[..end].iter().copied().filter(|l| !l.trim_start().starts_with("//")).collect() +} + +/// How many times `name` occurs in `line` as a whole path (`registry::grant`, not `registry::granted`). +fn count(line: &str, name: &str) -> usize { + line.match_indices(name).filter(|(at, _)| !line[at + name.len()..].starts_with(|c: char| c.is_alphanumeric() || c == '_')).count() +} + +#[test] +fn grant_token_call_sites_are_limited() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let mut files = Vec::new(); + for top in ["crates", "apps", "xtask"] { + sources(&root.join(top), &mut files); + } + assert!(files.len() > 100, "found only {} source files under {}", files.len(), root.display()); + let mut found: BTreeMap<(&str, String), usize> = BTreeMap::new(); + for file in &files { + let text = std::fs::read_to_string(file).unwrap(); + let rel = file.strip_prefix(&root).unwrap().to_string_lossy().replace('\\', "/"); + for line in shipped(&text) { + for name in ["GrantToken::after_engine_approval", "GrantToken::from_engine_grants_file", "registry::grant"] { + let n = count(line, name); + if n > 0 { + *found.entry((name, rel.clone())).or_default() += n; + } + } + } + } + let expected: BTreeMap<(&str, String), usize> = EXPECTED.iter().map(|&(name, file, n)| ((name, file.to_string()), n)).collect(); + assert_eq!(found, expected, "shipped code that makes a grant token or records a grant"); +} + +/// The audit's own filters: a test module and comments are left out, a longer name isn't counted. +#[test] +fn the_audit_skips_test_code_and_comments() { + let text = "fn a() { registry::grant(t); }\n// registry::grant(t)\nfn b() { registry::granted(id); }\n\n#[cfg(test)]\n\nmod tests {\n registry::grant(t);\n}\n"; + let lines = shipped(text); + assert_eq!(lines.iter().map(|l| count(l, "registry::grant")).sum::(), 1, "{lines:?}"); +} diff --git a/crates/plugins/tests/safe_mode.rs b/crates/plugins/tests/safe_mode.rs index f6278b8e39..8b3149cf83 100644 --- a/crates/plugins/tests/safe_mode.rs +++ b/crates/plugins/tests/safe_mode.rs @@ -58,9 +58,9 @@ fn temp_dir(name: &str) -> std::path::PathBuf { fn in_safe_mode_nothing_loads_or_runs() { let _f = fresh(); // Installed before Safe Mode was turned on. - let before = registry::install(Plugin::load(&filter("test.safe.before"), Limits::default()).unwrap()); + let before = registry::install(Plugin::load(&filter("test.safe.before"), Limits::default()).unwrap()).unwrap(); let fx = build(&tpl::returning(&tpl::manifest("test.safe.effect", "effect", "{}"), r#"{"objects":[]}"#)); - registry::install(Plugin::load(&fx, Limits::default()).unwrap()); + registry::install(Plugin::load(&fx, Limits::default()).unwrap()).unwrap(); let dir = temp_dir("gate"); std::fs::write(dir.join("a.wasm"), filter("test.safe.folder")).unwrap(); diff --git a/docs/plugins.md b/docs/plugins.md index 06fd89f28a..eb26c0fa2d 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -226,6 +226,13 @@ capability model proposed for PhotoCraft plug-ins `VECTORCRAFT_NO_PREFS` set there is no store, and a `grant` accepted under the flag lasts for that session only. With `--allow-plugin-grants`, pass `hash` set to the `plugin.inspect` entry's `hash` so that the module installed is the one the user saw. +- The approval protects against plug-ins and automation clients (MCP, scripts and CLI requests): none of them can + approve a module's permissions. VectorCraft's own code records a grant only after that approval and when it loads + `grants.json`, and installs a module that lists permissions only under a grant recorded for this very module, + also when it loads the Additional Plug-ins Folder. The approval does not protect against native code linked into + VectorCraft or against another program running as the same user, which can change the files in the `Plug-ins` + folder. `grants.json` is not signed, so such a program can add a grant to it. `--allow-plugin-grants` is a choice + the person starting a headless session makes. - **Replacing asks first.** When the file is another module than the plug-in installed with its id, or, with none installed, its id has settings and a cache in the `Plug-ins` folder, Install Plug-in… and File › Open show its id and SHA-256 and ask whether to install it in place of that plug-in, which gives it the settings and cache kept for diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b4c949f742..d965a54e35 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1658,6 +1658,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Filter plug-ins read type, images and symbol instances (G17, #4333):** a filter at plug-in API level 2 lists `inputs` (`type`, `image`, `symbolInstance`) with `doc.read` in its `permissions`, so installing it asks for approval, and is also given the selected objects of those kinds, read only: type with its runs, character and paragraph attributes (no text past 256 KiB or 4,096 runs or paragraphs, or 16 MiB of runs over the input), images with their size, color mode and, when embedded and at most 1 MiB in base64 (16 MiB over the input), their RGBA pixels (a CMYK image's through the working CMYK profile; never a linked file's), and symbol instances with their symbol and transform. The objects come in paint order from the bottom, and an image that reaches the decoder counts toward the 16 MiB of pixels even when decoding fails. Every object counts toward the input's half of the memory cap; one whose fields besides its pixels and runs don't fit fails the run naming it and its size, a path or compound path that doesn't fit fails it too, and so do more than 100,000 of these objects. Its output returns each as `{id}` or exactly as given; any change or omission is refused before the document changes. Filters without `inputs` get the same input as before, except that compound paths that clip a clip group and paths inside a group that clips one are left out for every filter. Regressions: `crates/plugins/src/objects/tests.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. - **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. +- **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. - **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 93ccc6fbfa498a6e9af34fa444ef30fe9dbb181e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 17:41:21 -0800 Subject: [PATCH 02/15] Transform: Align New Objects to Pixel Grid makes new paths pixel-aligned Document Setup gains alignNewObjectsToPixelGrid. When it is on, every path or compound path drawn in the document (shape tools, Pen, Curvature, Pencil, Paintbrush, Blob Brush, grids, flares and add-on shapes) gets the pixel-aligned property and snaps in the same undo step. Text, paste, duplicate, Place and import are not affected. - The Transform panel menu has an Align New Objects to Pixel Grid item for the active document. - New Document's Advanced Options has the checkbox; the Mobile and Web presets turn it on, other presets and older saved presets leave it off, and file.new, Save Preset and Recent keep it. - The setting is saved with the document only when it is on. https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/pixel-preview-mode-for-web-graphics.html Docs: docs/mcp.md, docs/target-app-parity.md, docs/ui-parity.md. --- crates/doc/src/setup.rs | 8 ++ crates/engine/src/cmd/addons/shapes.rs | 3 + crates/engine/src/cmd/create.rs | 6 +- crates/engine/src/cmd/docsetup.rs | 4 +- crates/engine/src/cmd/edit.rs | 2 +- crates/engine/src/cmd/newdoc.rs | 20 ++- crates/engine/src/cmd/object.rs | 4 +- crates/engine/src/cmd/object/pixelgrid.rs | 10 ++ crates/engine/src/tests/newdoc.rs | 51 ++++++++ crates/engine/src/tests/pixelalign.rs | 125 +++++++++++++++++++ crates/ui-egui/src/dialogs/new_document.rs | 81 +++++++++++- 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 + crates/ui-egui/src/panels/transform.rs | 7 ++ crates/ui-egui/src/panels/transform/tests.rs | 31 +++++ docs/mcp.md | 29 ++++- docs/target-app-parity.md | 1 + docs/ui-parity.md | 1 + 43 files changed, 397 insertions(+), 13 deletions(-) diff --git a/crates/doc/src/setup.rs b/crates/doc/src/setup.rs index 781bec84ea..bc558d2702 100644 --- a/crates/doc/src/setup.rs +++ b/crates/doc/src/setup.rs @@ -314,6 +314,13 @@ pub struct DocSetup { /// #[serde(default, rename = "systemLayout", skip_serializing_if = "crate::skip::is_default")] pub system_layout: bool, + /// Align New Objects to Pixel Grid (New Document › Advanced Options, Transform panel menu): + /// paths and compound paths drawn from then on are pixel-aligned. Objects already in the + /// document, and objects pasted, duplicated or placed, keep their own setting. Off by default + /// and for documents from before it, and saved only when on. + /// + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub align_new_objects_to_pixel_grid: bool, } impl Default for DocSetup { @@ -337,6 +344,7 @@ impl Default for DocSetup { export_text: ExportText::Editable, background: Background::Transparent, system_layout: false, + align_new_objects_to_pixel_grid: false, } } } diff --git a/crates/engine/src/cmd/addons/shapes.rs b/crates/engine/src/cmd/addons/shapes.rs index 24d0e4c3ac..87549e3694 100644 --- a/crates/engine/src/cmd/addons/shapes.rs +++ b/crates/engine/src/cmd/addons/shapes.rs @@ -131,6 +131,8 @@ fn insert_custom(s: &mut Session, label: &str, live: Option, cs: Cust && let Some(n) = std::sync::Arc::make_mut(&mut st.doc).node_mut(NodeId(raw_id)) { cs.write_to_node(n); + // A shape drawn pixel-aligned (Align New Objects to Pixel Grid) snaps its stored outline. + super::super::object::snap_if_aligned(n); } Ok(val) } @@ -270,6 +272,7 @@ fn box3d(s: &mut Session, p: &Value) -> Result { && let Some(n) = std::sync::Arc::make_mut(&mut st.doc).node_mut(NodeId(raw_id)) { cs.write_to_node(n); + super::super::object::snap_if_aligned(n); } Ok(val) } diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 973c82af53..2e9893b03e 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -162,7 +162,8 @@ fn constrained_turn(s: &Session, rotation: f64) -> f64 { } } -/// [`insert_look`] with the new object's bounding box turned to `bbox_angle`. +/// [`insert_look`] with the new object's bounding box turned to `bbox_angle`. A new path or +/// compound path is pixel-aligned while the document's Align New Objects to Pixel Grid is on. fn insert_look_turned( s: &mut Session, label: &str, @@ -180,6 +181,7 @@ fn insert_look_turned( let id = d.alloc_id(); let mut n = Node::new(id, kind); look.apply(d, &mut n); + super::object::align_new_object(d, &mut n); n.name = name; n.bbox_angle = bbox_angle; let mut into = None; @@ -512,6 +514,7 @@ fn flare(s: &mut Session, p: &Value) -> Result { n.name = Some(name.into()); n.blend = BlendMode::Screen; n.opacity = op; + super::object::align_new_object(d, &mut n); std::sync::Arc::new(n) }) .collect(); @@ -603,6 +606,7 @@ fn grid_group(s: &mut Session, label: &str, (paths, bbox_angle): (Vec, Node::path(d.alloc_id(), pd, Appearance::default()) }; look.apply(d, &mut n); + super::object::align_new_object(d, &mut n); n.bbox_angle = bbox_angle; std::sync::Arc::new(n) }) diff --git a/crates/engine/src/cmd/docsetup.rs b/crates/engine/src/cmd/docsetup.rs index 894d9b90ae..dadafaa1b8 100644 --- a/crates/engine/src/cmd/docsetup.rs +++ b/crates/engine/src/cmd/docsetup.rs @@ -17,7 +17,7 @@ pub fn specs() -> Vec { "Document Setup", [], None, - "{units?: \"Points\"|\"Inches\"|\"Millimeters\"|…, bleed?: pt (all sides)|[top, bottom, left, right]|{top?, bottom?, left?, right?} (0–72 pt), outlineImages?: bool (show images in Outline mode), highlightSubstitutedGlyphs?: bool (Highlight Substituted Fonts is a preference: prefs.set highlightSubstitutedFonts; a highlightSubstitutedFonts key is ignored and the reply carries a warning),gridSize?: \"small\"|\"medium\"|\"large\", gridColors?: \"Light\"|\"Medium\"|\"Dark\"|\"Red\"|\"Orange\"|\"Green\"|\"Blue\"|\"Purple\"|[colour, colour] (transparency grid; the first is also the paper colour; #rrggbbaa and #rgba are refused), simulatePaper?: bool, flattenerPreset?: \"High Resolution\"|\"Medium Resolution\"|\"Low Resolution\"|a saved preset (flattener.presets.list), discardWhiteOverprint?: bool, language?: \"English: USA\"|\"German\"|\"French\"|… (also picks its quotes), quotes?: {double?: \"“”\", single?: \"‘’\"} (beside a language: only if they differ from the current ones), typographersQuotes?: bool (typed straight quotes become the document's quotes), superscript?: {size?: %, position?: %}, subscript?: {size?: %, position?: %}, smallCapsSize?: %, exportText?: \"editable\"|\"appearance\" (SVG text as text or as outlines), backgroundContents?: \"transparent\"|\"white\" (white: raster exports are white behind the art), …the keys of its own reply (it can be passed back; the values it only reports are ignored)} change the document setup in one undo step; no params → the current setup", + "{units?: \"Points\"|\"Inches\"|\"Millimeters\"|…, bleed?: pt (all sides)|[top, bottom, left, right]|{top?, bottom?, left?, right?} (0–72 pt), outlineImages?: bool (show images in Outline mode), highlightSubstitutedGlyphs?: bool (Highlight Substituted Fonts is a preference: prefs.set highlightSubstitutedFonts; a highlightSubstitutedFonts key is ignored and the reply carries a warning),gridSize?: \"small\"|\"medium\"|\"large\", gridColors?: \"Light\"|\"Medium\"|\"Dark\"|\"Red\"|\"Orange\"|\"Green\"|\"Blue\"|\"Purple\"|[colour, colour] (transparency grid; the first is also the paper colour; #rrggbbaa and #rgba are refused), simulatePaper?: bool, flattenerPreset?: \"High Resolution\"|\"Medium Resolution\"|\"Low Resolution\"|a saved preset (flattener.presets.list), discardWhiteOverprint?: bool, language?: \"English: USA\"|\"German\"|\"French\"|… (also picks its quotes), quotes?: {double?: \"“”\", single?: \"‘’\"} (beside a language: only if they differ from the current ones), typographersQuotes?: bool (typed straight quotes become the document's quotes), superscript?: {size?: %, position?: %}, subscript?: {size?: %, position?: %}, smallCapsSize?: %, exportText?: \"editable\"|\"appearance\" (SVG text as text or as outlines), backgroundContents?: \"transparent\"|\"white\" (white: raster exports are white behind the art), alignNewObjectsToPixelGrid?: bool (Align New Objects to Pixel Grid: paths and compound paths drawn from then on are pixel-aligned, as object.setPixelAligned; art already in the document, pasted, duplicated or placed keeps its own setting), …the keys of its own reply (it can be passed back; the values it only reports are ignored)} change the document setup in one undo step; no params → the current setup", has_doc, setup )] @@ -46,6 +46,7 @@ pub(crate) fn setup_json(d: &Document) -> Value { "smallCapsSize": s.small_caps_size, "exportText": s.export_text.id(), "backgroundContents": s.background.id(), + "alignNewObjectsToPixelGrid": s.align_new_objects_to_pixel_grid, }) } @@ -140,6 +141,7 @@ fn apply(s: &mut DocSetup, units: &mut vectorcraft_doc::Unit, k: &str, v: &Value "smallCapsSize" => s.small_caps_size = percent(k, v, 1.0, vectorcraft_doc::setup::MAX_SMALL_CAPS)?, "exportText" => s.export_text = v.as_str().and_then(ExportText::parse).ok_or_else(|| bad(C, "exportText must be editable or appearance"))?, "backgroundContents" => s.background = background_param(v, C)?, + "alignNewObjectsToPixelGrid" => s.align_new_objects_to_pixel_grid = flag(k, v)?, // Read-only values `document.setup` reports. "gridColorsName" | "warnings" => {} _ => return Err(bad(C, format!("unknown setting `{k}`"))), diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 7a8a91f0af..4295205ab1 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -23,7 +23,7 @@ pub fn specs() -> Vec { "New…", ["File"], None, - "{preset?: a name from file.newPresets (start from it; the other params change it), name?|title?: document name (default Untitled-N), width?: pt=612|\"210 mm\", height?: pt=792, units?: \"Pixels\"|\"Points\"|\"Picas\"|\"Inches\"|\"Millimeters\"|\"Centimeters\"|\"Feet\"|\"Yards\"|\"Meters\"|\"Feet & Inches\" (default: prefs unitsGeneral, as print presets; screen presets: Pixels), orientation?: \"portrait\"|\"landscape\" (swaps width and height to match), artboards?: n (1–1000), artboardLayout?: {layout?: \"gridByRow\"|\"gridByColumn\"|\"row\"|\"column\", columns?: n (default: all in one row), spacing?: pt=20, rightToLeft?: bool}, bleed?: pt|[top, bottom, left, right]|{top?, …} (0–72 pt), backgroundContents?: \"transparent\"|\"white\" (a white artboard background, not an object), colorMode?: \"rgb\"|\"cmyk\" (a CMYK document starts with CMYK swatches and stores the colours applied to it as CMYK), rasterEffectsPpi?: 72|150|300 (1–2400), previewMode?: \"default\"|\"pixel\"|\"overprint\" (overprint turns Overprint Preview on, pixel Pixel Preview in the app; default leaves both), created?: Unix seconds|null (File Info's created date; default now, recorded in the journal so a replay matches), size?: a preset's size label (a preset file.newPresets lists can be passed back; ignored)} → {index, previewMode}; the size is remembered in file.newPresets' Recent", + "{preset?: a name from file.newPresets (start from it; the other params change it), name?|title?: document name (default Untitled-N), width?: pt=612|\"210 mm\", height?: pt=792, units?: \"Pixels\"|\"Points\"|\"Picas\"|\"Inches\"|\"Millimeters\"|\"Centimeters\"|\"Feet\"|\"Yards\"|\"Meters\"|\"Feet & Inches\" (default: prefs unitsGeneral, as print presets; screen presets: Pixels), orientation?: \"portrait\"|\"landscape\" (swaps width and height to match), artboards?: n (1–1000), artboardLayout?: {layout?: \"gridByRow\"|\"gridByColumn\"|\"row\"|\"column\", columns?: n (default: all in one row), spacing?: pt=20, rightToLeft?: bool}, bleed?: pt|[top, bottom, left, right]|{top?, …} (0–72 pt), backgroundContents?: \"transparent\"|\"white\" (a white artboard background, not an object), colorMode?: \"rgb\"|\"cmyk\" (a CMYK document starts with CMYK swatches and stores the colours applied to it as CMYK), rasterEffectsPpi?: 72|150|300 (1–2400), previewMode?: \"default\"|\"pixel\"|\"overprint\" (overprint turns Overprint Preview on, pixel Pixel Preview in the app; default leaves both), alignNewObjectsToPixelGrid?: bool (Advanced Options › Align New Objects to Pixel Grid: paths drawn in the document are pixel-aligned; default on for the Mobile and Web presets, else off; document.setup changes it later), created?: Unix seconds|null (File Info's created date; default now, recorded in the journal so a replay matches), size?: a preset's size label (a preset file.newPresets lists can be passed back; ignored)} → {index, previewMode}; the size is remembered in file.newPresets' Recent", always, super::newdoc::file_new ), diff --git a/crates/engine/src/cmd/newdoc.rs b/crates/engine/src/cmd/newdoc.rs index 20dc187ab5..bd15d748ca 100644 --- a/crates/engine/src/cmd/newdoc.rs +++ b/crates/engine/src/cmd/newdoc.rs @@ -7,7 +7,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::{Artboard, Background, ColorMode, Document, Unit}; use vectorcraft_geom::{Point, Rect}; -use super::{CommandSpec, Result, Session, always, bad, clock_date, cmd, date_param, str_param}; +use super::{CommandSpec, Result, Session, always, bad, clock_date, cmd, date_param, str_param, switch_or}; pub fn specs() -> Vec { vec![ @@ -16,7 +16,7 @@ pub fn specs() -> Vec { "New Document Presets", [], None, - "{category?: \"Recent\"|\"Saved\"|\"Mobile\"|\"Web\"|\"Print\"|\"Film & Video\"|\"Art & Illustration\"|\"Branding\"|\"Social\"} the New Document presets by category (Recent: the last sizes used; Saved: the user's presets) → {categories: [{name, presets: [{name, size: \"1920 × 1080 px\", width: pt, height: pt, units, orientation, artboards, artboardLayout: {layout, columns, spacing, rightToLeft}, bleed, backgroundContents, colorMode, rasterEffectsPpi, previewMode}]}]}; file.new {preset: name} starts from one", + "{category?: \"Recent\"|\"Saved\"|\"Mobile\"|\"Web\"|\"Print\"|\"Film & Video\"|\"Art & Illustration\"|\"Branding\"|\"Social\"} the New Document presets by category (Recent: the last sizes used; Saved: the user's presets) → {categories: [{name, presets: [{name, size: \"1920 × 1080 px\", width: pt, height: pt, units, orientation, artboards, artboardLayout: {layout, columns, spacing, rightToLeft}, bleed, backgroundContents, colorMode, rasterEffectsPpi, previewMode, alignNewObjectsToPixelGrid (on in the Mobile and Web presets)}]}]}; file.new {preset: name} starts from one", always, list ), @@ -144,6 +144,10 @@ pub struct DocSettings { #[serde(deserialize_with = "de_ppi")] pub raster_effects_ppi: f64, pub preview_mode: PreviewMode, + /// Align New Objects to Pixel Grid ([`vectorcraft_doc::DocSetup::align_new_objects_to_pixel_grid`]): + /// on in the Mobile and Web presets, off in the others and in a saved or Recent preset from + /// before it. + pub align_new_objects_to_pixel_grid: bool, } impl Default for DocSettings { @@ -163,6 +167,7 @@ impl Default for DocSettings { color_mode: ColorMode::Rgb, raster_effects_ppi: 72.0, preview_mode: PreviewMode::Default, + align_new_objects_to_pixel_grid: false, } } } @@ -211,6 +216,7 @@ impl DocSettings { "colorMode": color_mode_id(self.color_mode), "rasterEffectsPpi": self.raster_effects_ppi, "previewMode": self.preview_mode.id(), + "alignNewObjectsToPixelGrid": self.align_new_objects_to_pixel_grid, }) } @@ -273,6 +279,7 @@ impl DocSettings { if let Some(m) = p.get("previewMode").filter(|v| !v.is_null()) { self.preview_mode = m.as_str().and_then(PreviewMode::parse).ok_or_else(|| bad(cmd, "previewMode must be default, pixel or overprint"))?; } + self.align_new_objects_to_pixel_grid = switch_or(p, "alignNewObjectsToPixelGrid", self.align_new_objects_to_pixel_grid, cmd)?; let extent = self.boards().iter().fold(Rect::ZERO, |a, r| a.union(*r)); if extent.width().max(extent.height()) + self.bleed.iter().fold(0.0f64, |a, b| a.max(*b)) * 2.0 > crate::MAX_COORD { return Err(bad(cmd, "the artboards don't fit on the canvas: fewer artboards, less spacing or a smaller size")); @@ -330,6 +337,7 @@ impl DocSettings { d.raster_effects_ppi = self.raster_effects_ppi; d.setup.bleed = self.bleed; d.setup.background = self.background; + d.setup.align_new_objects_to_pixel_grid = self.align_new_objects_to_pixel_grid; d.artboards = self .boards() .into_iter() @@ -543,6 +551,10 @@ pub const CATEGORIES: &[Category] = &[ ), ]; +/// The built-in categories whose presets start with Align New Objects to Pixel Grid on +/// (). +const PIXEL_ALIGNED_CATEGORIES: [&str; 2] = ["Mobile", "Web"]; + /// The category tabs in order: Recent, Saved, then the built-in categories. pub fn category_names() -> impl Iterator { ["Recent", "Saved"].into_iter().chain(CATEGORIES.iter().map(|c| c.0)) @@ -551,7 +563,8 @@ pub fn category_names() -> impl Iterator { /// The built-in presets of one category: screen sizes in pixels, the others in `general` /// (Preferences ▸ Units ▸ General). fn builtin(c: &Category, general: Unit) -> impl Iterator + '_ { - let (_, color_mode, ppi, presets) = *c; + let (category, color_mode, ppi, presets) = *c; + let align = PIXEL_ALIGNED_CATEGORIES.contains(&category); presets.iter().map(move |&(name, w, h, units)| DocSettings { name: name.into(), width: units.to_pt(w), @@ -559,6 +572,7 @@ fn builtin(c: &Category, general: Unit) -> impl Iterator + ' units: if units == Unit::Pixels { units } else { general }, color_mode, raster_effects_ppi: ppi, + align_new_objects_to_pixel_grid: align, ..DocSettings::default() }) } diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index c467f06e5a..8e15e1dd48 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -36,6 +36,8 @@ pub(crate) use arrange::group_nodes; pub(crate) use bounds::{bounds_rewrite, object_bounds_transform}; pub use commands::specs; pub(super) use compound::{as_clipping_path, make_clipping_path, release_clip}; -pub(crate) use pixelgrid::{keep_aligned_in, keep_compound_aligned, keep_pixel_aligned, refresh as refresh_pixel_aligned, resnap, snap_if_aligned}; +pub(crate) use pixelgrid::{ + align_new_object, keep_aligned_in, keep_compound_aligned, keep_pixel_aligned, refresh as refresh_pixel_aligned, resnap, snap_if_aligned, +}; pub(super) use shape::recut; pub(crate) use transform::{apply_transform, preview_copies, scaling, transform_node, transform_patterns, transforms_objects}; diff --git a/crates/engine/src/cmd/object/pixelgrid.rs b/crates/engine/src/cmd/object/pixelgrid.rs index 6441fda0c3..64d47ca0a9 100644 --- a/crates/engine/src/cmd/object/pixelgrid.rs +++ b/crates/engine/src/cmd/object/pixelgrid.rs @@ -22,6 +22,16 @@ pub(crate) fn can_align(n: &Node) -> bool { matches!(n.kind, NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. }) } +/// For an object being drawn into `d`: when the document's Align New Objects to Pixel Grid is on +/// and [`can_align`] accepts `n`, turn `n`'s pixel-aligned property on. The edit that adds it snaps +/// it to the pixel grid ([`refresh`]). Pasted, duplicated, placed and imported objects do not come +/// through here. +pub(crate) fn align_new_object(d: &Document, n: &mut Node) { + if d.setup.align_new_objects_to_pixel_grid && can_align(n) { + n.extras_mut().pixel_aligned = true; + } +} + /// `object.setPixelAligned`: turn the pixel-aligned property of the objects given (or the selected /// objects) on or off. Turning it on snaps them to the pixel grid at once. Every object must be /// one [`can_align`] accepts, else nothing changes. When every object already has the value, the diff --git a/crates/engine/src/tests/newdoc.rs b/crates/engine/src/tests/newdoc.rs index 067631eeca..4bc58e5f6b 100644 --- a/crates/engine/src/tests/newdoc.rs +++ b/crates/engine/src/tests/newdoc.rs @@ -292,6 +292,57 @@ fn a_saved_preset_with_the_resolution_out_of_range_reads_in_range() { assert_eq!(s.doc().unwrap().doc.raster_effects_ppi, 1.0); } +/// Advanced Options › Align New Objects to Pixel Grid starts on in the Mobile and Web presets and +/// off in the others and without a preset; a `file.new` param overrides the preset; the document +/// takes the value; saved presets and Recent keep it, and a saved preset from before it reads off. +#[test] +fn align_new_objects_to_pixel_grid_follows_the_preset() { + let align = |s: &Session| s.doc().unwrap().doc.setup.align_new_objects_to_pixel_grid; + let mut s = Session::new(); + for category in ["Mobile", "Web"] { + assert!(presets(&mut s, category).iter().all(|p| p["alignNewObjectsToPixelGrid"] == true), "{category}"); + } + for category in ["Print", "Film & Video", "Art & Illustration", "Branding", "Social"] { + assert!(presets(&mut s, category).iter().all(|p| p["alignNewObjectsToPixelGrid"] == false), "{category}"); + } + s.execute("file.new", &json!({"preset": "Web 1920×1080"})).unwrap(); + assert!(align(&s)); + assert_eq!(presets(&mut s, "Recent")[0]["alignNewObjectsToPixelGrid"], true); + s.execute("file.new", &json!({"preset": "Phone 390×844"})).unwrap(); + assert!(align(&s)); + s.execute("file.new", &json!({"preset": "Letter"})).unwrap(); + assert!(!align(&s)); + s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap(); + assert!(!align(&s)); + s.execute("file.new", &json!({"preset": "Web 1920×1080", "alignNewObjectsToPixelGrid": false})).unwrap(); + assert!(!align(&s)); + s.execute("file.new", &json!({"preset": "A4", "alignNewObjectsToPixelGrid": true})).unwrap(); + assert!(align(&s)); + assert!(Session::new().execute("file.new", &json!({"alignNewObjectsToPixelGrid": "on"})).is_err()); + // A Recent entry passed back makes the document it was made as. + let recent = presets(&mut s, "Recent")[0].clone(); + assert_eq!((recent["name"].as_str(), recent["alignNewObjectsToPixelGrid"].as_bool()), (Some("A4"), Some(true))); + s.execute("file.new", &recent).unwrap(); + assert!(align(&s)); + // Saved presets keep it. + s.execute("file.newPresets.save", &json!({"name": "Icons", "width": 64, "height": 64, "alignNewObjectsToPixelGrid": true})).unwrap(); + s.execute("file.newPresets.save", &json!({"name": "Banner", "preset": "Web 1920×1080", "alignNewObjectsToPixelGrid": false})).unwrap(); + let saved: Vec = presets(&mut s, "Saved").iter().map(|p| p["alignNewObjectsToPixelGrid"].clone()).collect(); + assert_eq!(saved, [json!(true), json!(false)]); + s.execute("file.new", &json!({"preset": "Icons"})).unwrap(); + assert!(align(&s)); + s.execute("file.new", &json!({"preset": "Banner"})).unwrap(); + assert!(!align(&s)); + let prefs: crate::Prefs = serde_json::from_value(s.prefs.to_json()).unwrap(); + assert_eq!(prefs.new_doc_presets, s.prefs.new_doc_presets); + // A saved preset from before the setting reads off, whatever its name. + let mut old = Session::new(); + old.apply_prefs(serde_json::from_value(json!({"newDocPresets": [{"name": "My Web", "width": 100, "height": 100}]})).unwrap()); + assert_eq!(presets(&mut old, "Saved")[0]["alignNewObjectsToPixelGrid"], false); + old.execute("file.new", &json!({"preset": "My Web"})).unwrap(); + assert!(!align(&old)); +} + #[test] fn rearrange_keeps_its_grid() { let mut s = new_doc(json!({"width": 100, "height": 50, "artboards": 3})); diff --git a/crates/engine/src/tests/pixelalign.rs b/crates/engine/src/tests/pixelalign.rs index d348b13ae3..52bb9ba782 100644 --- a/crates/engine/src/tests/pixelalign.rs +++ b/crates/engine/src/tests/pixelalign.rs @@ -766,3 +766,128 @@ fn objects_on_path_members_stay_on_the_grid() { assert!(whole(&points(&s, a)), "path edited: {:?}", points(&s, a)); assert!(points(&s, a).iter().all(|(_, y)| *y > 105.0), "followed the path: {:?}", points(&s, a)); } + +fn align_new(s: &Session) -> bool { + s.doc().unwrap().doc.setup.align_new_objects_to_pixel_grid +} + +/// Align New Objects to Pixel Grid (`document.setup`): while it is on, a rectangle and an open path +/// drawn in the document are pixel-aligned and snapped in the step that makes them, and type is +/// not; art already in the document, duplicates and pastes keep their own setting; turning it off +/// leaves the next drawing as it is drawn, and the object's own setting can be turned off while +/// the document's stays on. +#[test] +fn align_new_objects_makes_new_paths_pixel_aligned() { + let mut s = new_doc(); + assert!(!align_new(&s)); + assert_eq!(s.execute("document.setup", &json!({})).unwrap()["alignNewObjectsToPixelGrid"], false); + let old = path(&mut s, "M10.3 120.6 L40.7 120.6 L40.7 130.3 L10.3 130.3 Z", 2.0); + let old_points = points(&s, old); + let steps = undo_len(&s); + let r = s.execute("document.setup", &json!({"alignNewObjectsToPixelGrid": true})).unwrap(); + assert_eq!(r["alignNewObjectsToPixelGrid"], true); + assert!(align_new(&s)); + assert_eq!(undo_len(&s), steps + 1, "one undo step"); + // Art already in the document stays as it was. + assert!(!aligned(&s, old)); + assert_eq!(points(&s, old), old_points); + // A rectangle is drawn pixel-aligned: with the 2 pt stroke `path` left as the current one, its + // edges go to whole points. + let steps = undo_len(&s); + let rect = s.execute("shape.rectangle", &json!({"x": 10.3, "y": 20.6, "width": 30.4, "height": 9.7})).unwrap()["id"].as_u64().unwrap(); + assert!(aligned(&s, rect)); + assert_eq!(points(&s, rect), [(10.0, 21.0), (41.0, 21.0), (41.0, 30.0), (10.0, 30.0)]); + assert_eq!(undo_len(&s), steps + 1, "made and snapped in one undo step"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.node(NodeId(rect)).is_none()); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(aligned(&s, rect)); + // An open path from the Pen. + let line = s.execute("path.create", &json!({"anchors": [{"x": 50.3, "y": 60.2}, {"x": 90.6, "y": 60.2}]})).unwrap()["id"].as_u64().unwrap(); + assert!(aligned(&s, line)); + // Its horizontal segment goes to a whole point. + assert!(points(&s, line).iter().all(|(_, y)| *y == 60.0), "{:?}", points(&s, line)); + // Type cannot be pixel-aligned. + let text = s.execute("text.create", &json!({"x": 10.3, "y": 200.6, "text": "Hi"})).unwrap()["id"].as_u64().unwrap(); + assert!(!aligned(&s, text)); + // A duplicate and a paste of art that is not pixel-aligned keep it so. + s.execute("select.set", &json!({"ids": [old]})).unwrap(); + s.execute("edit.duplicate", &json!({"dx": 100.3, "dy": 0.4})).unwrap(); + let copy = s.doc().unwrap().selection.objects[0].0; + assert!(copy != old && !aligned(&s, copy)); + s.execute("select.set", &json!({"ids": [old]})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + s.execute("edit.paste", &json!({})).unwrap(); + let pasted = s.doc().unwrap().selection.objects[0].0; + assert!(pasted != old && pasted != copy && !aligned(&s, pasted)); + // The object's own setting turns off while the document's stays on. + s.execute("object.setPixelAligned", &json!({"ids": [rect], "on": false})).unwrap(); + assert!(!aligned(&s, rect) && align_new(&s)); + // Off: the next rectangle is drawn as it is given, and the first one stays pixel-aligned. + s.execute("document.setup", &json!({"alignNewObjectsToPixelGrid": false})).unwrap(); + let plain = s.execute("shape.rectangle", &json!({"x": 10.3, "y": 20.6, "width": 30.4, "height": 9.7})).unwrap()["id"].as_u64().unwrap(); + assert!(!aligned(&s, plain)); + let drawn = [(10.3, 20.6), (40.7, 20.6), (40.7, 30.3), (10.3, 30.3)]; + assert!(points(&s, plain).iter().zip(drawn).all(|(a, b)| close(*a, b)), "{:?}", points(&s, plain)); + assert!(aligned(&s, line)); + // A value that is not true or false changes nothing. + let steps = undo_len(&s); + assert!(s.execute("document.setup", &json!({"alignNewObjectsToPixelGrid": "yes"})).is_err()); + assert!(!align_new(&s)); + assert_eq!(undo_len(&s), steps); +} + +/// While Align New Objects to Pixel Grid is on, the paths of a new rectangular grid, polar grid +/// and flare are pixel-aligned (the group is not), and a new 3D Box add-on shape is snapped as +/// Align to Pixel Grid snaps the same box drawn without it. +#[test] +fn align_new_objects_reaches_grids_flares_and_add_on_shapes() { + let mut s = new_doc(); + s.execute("document.setup", &json!({"alignNewObjectsToPixelGrid": true})).unwrap(); + let doc = |s: &Session| s.doc().unwrap().doc.clone(); + let children = |s: &Session, id: u64| -> Vec { doc(s).node(NodeId(id)).unwrap().children().unwrap().iter().map(|c| c.id.0).collect() }; + let grid = s.execute("shape.rectangularGrid", &json!({"x": 10.3, "y": 20.6, "width": 100.4, "height": 50.7})).unwrap()["id"].as_u64().unwrap(); + assert!(!aligned(&s, grid)); + let lines = children(&s, grid); + assert!(!lines.is_empty() && lines.iter().all(|id| aligned(&s, *id))); + // Under the 1 pt stroke, a divider sits on a half point across its length, and the frame + // has every corner on half points. + let half = |v: f64| ((v - 0.5) - (v - 0.5).round()).abs() < 1e-9; + let snapped = + |p: &[(f64, f64)]| halves(p) || (p.iter().all(|q| q.1 == p[0].1) && half(p[0].1)) || (p.iter().all(|q| q.0 == p[0].0) && half(p[0].0)); + assert!(lines.iter().all(|id| snapped(&points(&s, *id))), "{:?}", lines.iter().map(|id| points(&s, *id)).collect::>()); + let polar = s.execute("shape.polarGrid", &json!({"x": 200.3, "y": 20.6, "width": 80.4, "height": 80.7})).unwrap()["id"].as_u64().unwrap(); + assert!(!aligned(&s, polar) && children(&s, polar).iter().all(|id| aligned(&s, *id))); + let flare = s.execute("shape.flare", &json!({"cx": 100.3, "cy": 200.6})).unwrap()["id"].as_u64().unwrap(); + assert!(!aligned(&s, flare) && children(&s, flare).iter().all(|id| aligned(&s, *id))); + // Seen straight on, the box's front face has horizontal and vertical edges to snap. + let params = json!({"x": 20.3, "y": 300.6, "width": 60.4, "height": 40.7, "angleX": 0, "angleY": 0, "angleZ": 0}); + let drawn = s.execute("addon.shapes.box3d", ¶ms).unwrap()["id"].as_u64().unwrap(); + assert!(aligned(&s, drawn)); + s.execute("document.setup", &json!({"alignNewObjectsToPixelGrid": false})).unwrap(); + let later = s.execute("addon.shapes.box3d", ¶ms).unwrap()["id"].as_u64().unwrap(); + assert!(!aligned(&s, later)); + let unaligned = points(&s, later); + s.execute("object.setPixelAligned", &json!({"ids": [later]})).unwrap(); + assert_eq!(points(&s, drawn), points(&s, later)); + assert_ne!(points(&s, drawn), unaligned, "the box has segments to snap"); +} + +/// The document's Align New Objects to Pixel Grid is saved in the native file only when on and +/// read back; a file from before it opens with it off. +#[test] +fn align_new_objects_is_saved_and_read_back() { + let mut s = new_doc(); + let off = serde_json::to_value(&s.doc().unwrap().doc.setup).unwrap(); + assert!(off.get("alignNewObjectsToPixelGrid").is_none(), "{off}"); + let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + assert!(!back.setup.align_new_objects_to_pixel_grid); + s.execute("document.setup", &json!({"alignNewObjectsToPixelGrid": true})).unwrap(); + let on = serde_json::to_value(&s.doc().unwrap().doc.setup).unwrap(); + assert_eq!(on["alignNewObjectsToPixelGrid"], true); + let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + assert!(back.setup.align_new_objects_to_pixel_grid); + let mut older = on; + older.as_object_mut().unwrap().remove("alignNewObjectsToPixelGrid"); + assert!(!serde_json::from_value::(older).unwrap().align_new_objects_to_pixel_grid); +} diff --git a/crates/ui-egui/src/dialogs/new_document.rs b/crates/ui-egui/src/dialogs/new_document.rs index 8e02009339..76403ff451 100644 --- a/crates/ui-egui/src/dialogs/new_document.rs +++ b/crates/ui-egui/src/dialogs/new_document.rs @@ -8,7 +8,7 @@ //! //! Fields: `file.new`'s params (`preset`, `name`, `width`, `height` (pt), `units`, `artboards`, //! `artboardLayout`, `bleed`, `backgroundContents`, `colorMode`, `rasterEffectsPpi`, -//! `previewMode`), plus `category` (the tab), `advanced` (Advanced Options open), `bleedLinked` and +//! `previewMode`, `alignNewObjectsToPixelGrid`), plus `category` (the tab), `advanced` (Advanced Options open), `bleedLinked` and //! `presetName` (Save Preset's name). Setting `preset` fills the fields from that preset; changing //! any of them afterwards makes the settings custom. @@ -29,7 +29,7 @@ pub(super) const SPEC: DialogSpec = DialogSpec { inputs: |_| &["presetName"], .. pub(super) const MORE_SPEC: DialogSpec = SPEC; /// The fields `file.new` takes. -const PARAMS: [&str; 12] = [ +const PARAMS: [&str; 13] = [ "preset", "name", "width", @@ -42,6 +42,7 @@ const PARAMS: [&str; 12] = [ "colorMode", "rasterEffectsPpi", "previewMode", + "alignNewObjectsToPixelGrid", ]; /// The preset the fields were last filled from, as `apply` set them; a Recent or Saved card keeps /// its own units here, which the units chosen in Preset Details don't change. @@ -694,7 +695,8 @@ fn choice(ui: &mut egui::Ui, d: &mut Dialog, key: &str, options: &[(Value, Strin } } -/// The collapsible Advanced Options: Color Mode, Raster Effects and Preview Mode. +/// The collapsible Advanced Options: Color Mode, Raster Effects, Preview Mode and Align New Objects +/// to Pixel Grid. fn advanced(ui: &mut egui::Ui, d: &mut Dialog, label: f32, field: f32) { let open = d.bool("advanced"); if widgets::section_toggle(ui, tl!("Advanced Options"), open) { @@ -714,6 +716,25 @@ fn advanced(ui: &mut egui::Ui, d: &mut Dialog, label: f32, field: f32) { ] { widgets::label_row(ui, name, label, |ui| choice(ui, d, key, options, field)); } + wrapping_check(ui, d, "alignNewObjectsToPixelGrid", tl!("Align New Objects to Pixel Grid")); +} + +/// A checkbox for the boolean field `key` whose `label` (already translated) wraps within the +/// column; a click on the box or the label toggles the field. +fn wrapping_check(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) { + let t = Tokens::get(ui.ctx()); + let on = d.bool(key); + let toggled = ui + .horizontal_top(|ui| { + let boxed = widgets::check_box(ui, label, on, true); + let text = egui::RichText::new(label).font(egui::FontId::proportional(12.5)).color(t.text); + let clicked = ui.add(egui::Label::new(text).wrap().sense(egui::Sense::click())).clicked(); + boxed || clicked + }) + .inner; + if toggled { + d.fields.insert(key.into(), json!(!on)); + } } #[cfg(test)] @@ -1433,6 +1454,60 @@ mod tests { assert!(h.state().session.documents().is_empty()); } + /// Advanced Options › Align New Objects to Pixel Grid is checked for a Web preset and unchecked + /// for Letter; the checkbox changes it and makes the settings custom, Save Preset keeps it, and + /// Create makes the document with it. + #[test] + fn align_new_objects_checkbox_follows_the_preset_and_reaches_the_document() { + const KEY: &str = "alignNewObjectsToPixelGrid"; + const LABEL: &str = "Align New Objects to Pixel Grid"; + let mut h = big(); + assert_eq!((field(h.state(), "preset"), field(h.state(), KEY)), (json!("Letter"), json!(false))); + set(h.state_mut(), "category", json!("Web")); + set(h.state_mut(), "preset", json!("Web 1920×1080")); + set(h.state_mut(), "advanced", json!(true)); + h.run_steps(3); + assert_eq!(field(h.state(), KEY), json!(true)); + h.get_by_role_and_label(egui::accesskit::Role::CheckBox, LABEL).click(); + h.run_steps(3); + assert_eq!((field(h.state(), "preset"), field(h.state(), KEY)), (json!(""), json!(false)), "custom settings"); + set(h.state_mut(), "category", json!("Print")); + set(h.state_mut(), "preset", json!("Letter")); + h.run_steps(3); + assert_eq!(field(h.state(), KEY), json!(false)); + h.get_by_role_and_label(egui::accesskit::Role::Label, LABEL).click(); + h.run_steps(3); + assert_eq!(field(h.state(), KEY), json!(true), "a click on the label toggles it too"); + h.get_by_label("Save Preset").click(); + h.run_steps(4); + set(h.state_mut(), "presetName", json!("Pixel Letter")); + h.run_steps(2); + h.get_by_label("Save").click(); + h.run_steps(3); + let saved = h.state().session.prefs.new_doc_presets.iter().find(|p| p.name == "Pixel Letter").expect("saved"); + assert!(saved.align_new_objects_to_pixel_grid); + assert_eq!((field(h.state(), "preset"), field(h.state(), KEY)), (json!("Pixel Letter"), json!(true))); + h.get_by_label("Create").click(); + h.run_steps(3); + assert!(h.state().ui.dialog.is_none()); + assert!(h.state().session.active().unwrap().doc.setup.align_new_objects_to_pixel_grid); + } + + /// A long translation of Align New Objects to Pixel Grid wraps inside the Preset Details column. + #[test] + fn align_new_objects_label_wraps_inside_the_details_column() { + const LABEL: &str = "Στοίχιση νέων αντικειμένων στο πλέγμα εικονοστοιχείων"; + crate::i18n::set_current(crate::i18n::Lang::from_code("el").unwrap()); + let mut h = big(); + set(h.state_mut(), "advanced", json!(true)); + h.run_steps(3); + let boxed = h.get_by_role_and_label(egui::accesskit::Role::CheckBox, LABEL).rect(); + let text = h.get_by_role_and_label(egui::accesskit::Role::Label, LABEL).rect(); + crate::i18n::set_current(crate::i18n::Lang::EN); + assert!(text.right() <= boxed.left() + super::DETAILS + 0.5, "{boxed:?} {text:?}"); + assert!(text.height() > boxed.height(), "the label takes two lines: {text:?}"); + } + /// In a small window the pop-up stays on screen, and its button stays in view under rows that scroll. #[test] fn the_popup_stays_inside_a_small_window() { diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index d83b5ac3c6..9ad5e279db 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -6520,6 +6520,7 @@ breakText Glyphs Глифи # Align to Pixel Grid Align to Pixel Grid Подравняване към пикселната мрежа + Align New Objects to Pixel Grid Подравняване на новите обекти към пикселната мрежа @msg side is left or right, not `{s}` side е left или right, а не `{s}` Edit Custom Dictionary Редактиране на потребителски речник Entry: Запис: diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 663e95ceaa..7bf1d0335a 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -6571,6 +6571,7 @@ shape Open Otvoren # Align to Pixel Grid Align to Pixel Grid Poravnaj s mrežom piksela + Align New Objects to Pixel Grid Poravnaj nove objekte s mrežom piksela @msg side is left or right, not `{s}` side je left ili right, a ne `{s}` Edit Custom Dictionary Uredi prilagođeni rječnik Entry: Unos: diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 9c60e87e00..ca7579e187 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -6772,6 +6772,7 @@ breakText Glyphs Glifs # Align to Pixel Grid Align to Pixel Grid Alinea a la quadrícula de píxels + Align New Objects to Pixel Grid Alinea els objectes nous a la quadrícula de píxels @msg only paths and compound paths can be aligned to the pixel grid només els traçats i els traçats compostos es poden alinear a la quadrícula de píxels @msg side is left or right, not `{s}` side ha de ser left o right, no `{s}` Ignore All Ignora-ho tot diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index e8f9666fae..0d05b184ce 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6791,6 +6791,7 @@ breakText Glyphs Glyfy # Align to Pixel Grid Align to Pixel Grid Zarovnat na mřížku obrazových bodů + Align New Objects to Pixel Grid Zarovnat nové objekty na mřížku obrazových bodů @msg only paths and compound paths can be aligned to the pixel grid na mřížku obrazových bodů lze zarovnat jen cesty a složené cesty @msg side is left or right, not `{s}` side je left nebo right, ne `{s}` Ignore All Ignorovat vše diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 31915ed6bf..58445ab895 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -6534,6 +6534,7 @@ breakText Glyphs Glyffer # Align to Pixel Grid Align to Pixel Grid Juster efter pixelgitter + Align New Objects to Pixel Grid Juster nye objekter efter pixelgitter @msg side is left or right, not `{s}` side er left eller right, ikke `{s}` Edit Custom Dictionary Redigér egen ordliste Entry: Opslag: diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 2c5424d81f..047f7ca127 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -6378,6 +6378,7 @@ breakText Glyphs Glyphen # Align to Pixel Grid Align to Pixel Grid Am Pixelraster ausrichten + Align New Objects to Pixel Grid Neue Objekte am Pixelraster ausrichten @msg only paths and compound paths can be aligned to the pixel grid nur Pfade und zusammengesetzte Pfade können am Pixelraster ausgerichtet werden @msg side is left or right, not `{s}` side ist left oder right, nicht `{s}` Ignore All Alle ignorieren diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 6071ed5be4..59f16c3371 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -6743,6 +6743,7 @@ breakText Glyphs Γλύφοι # Align to Pixel Grid Align to Pixel Grid Στοίχιση στο πλέγμα εικονοστοιχείων + Align New Objects to Pixel Grid Στοίχιση νέων αντικειμένων στο πλέγμα εικονοστοιχείων @msg only paths and compound paths can be aligned to the pixel grid μόνο διαδρομές και σύνθετες διαδρομές μπορούν να στοιχιστούν στο πλέγμα εικονοστοιχείων @msg side is left or right, not `{s}` το side είναι left ή right, όχι `{s}` Ignore All Παράβλεψη όλων diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 4a74e3ae39..21b707032f 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -6734,6 +6734,7 @@ breakText Glyphs Glifos # Align to Pixel Grid Align to Pixel Grid Alinear con cuadrícula de píxeles + Align New Objects to Pixel Grid Alinear objetos nuevos con cuadrícula de píxeles @msg only paths and compound paths can be aligned to the pixel grid solo los trazados y los trazados compuestos se pueden alinear con la cuadrícula de píxeles @msg side is left or right, not `{s}` side es left o right, no `{s}` Ignore All Omitir todo diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index f2ec64f602..f50a46219b 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6826,6 +6826,7 @@ breakText Glyphs Merkit # Align to Pixel Grid Align to Pixel Grid Tasaa pikseliruudukkoon + Align New Objects to Pixel Grid Tasaa uudet objektit pikseliruudukkoon @msg only paths and compound paths can be aligned to the pixel grid vain polut ja yhdistelmäpolut voi tasata pikseliruudukkoon @msg side is left or right, not `{s}` side on left tai right, ei `{s}` Ignore All Ohita kaikki diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 807b3363b4..3603c8d8de 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6786,6 +6786,7 @@ breakText Glyphs Glyphes # Align to Pixel Grid Align to Pixel Grid Aligner sur la grille de pixels + Align New Objects to Pixel Grid Aligner les nouveaux objets sur la grille de pixels @msg only paths and compound paths can be aligned to the pixel grid seuls les tracés et les tracés transparents peuvent être alignés sur la grille de pixels @msg side is left or right, not `{s}` side vaut left ou right, pas `{s}` Ignore All Tout ignorer diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 572a19779f..06b9878650 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -8208,6 +8208,7 @@ theme Appearance अपीयरेंस # Align to Pixel Grid Align to Pixel Grid पिक्सेल ग्रिड से संरेखित करें + Align New Objects to Pixel Grid नए ऑब्जेक्ट्स को पिक्सेल ग्रिड से संरेखित करें @msg only paths and compound paths can be aligned to the pixel grid केवल पाथ और कंपाउंड पाथ को पिक्सेल ग्रिड से संरेखित किया जा सकता है @msg the name `{name}` isn't UTF-8 and was read as {label}: if it looks wrong, open the file with another legacyNameEncoding नाम `{name}` UTF-8 नहीं है और इसे {label} के रूप में पढ़ा गया: यदि यह गलत दिखे, तो फ़ाइल को किसी अन्य legacyNameEncoding से खोलें @msg the name `{name}` isn't UTF-8, and parts of it aren't valid {label} either: open the file with another legacyNameEncoding नाम `{name}` UTF-8 नहीं है, और इसके कुछ हिस्से {label} में भी मान्य नहीं हैं: फ़ाइल को किसी अन्य legacyNameEncoding से खोलें diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 8c2d2339bc..8c13bfb26f 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6775,6 +6775,7 @@ breakText Glyphs Glifák # Align to Pixel Grid Align to Pixel Grid Igazítás a pixelrácshoz + Align New Objects to Pixel Grid Új objektumok igazítása a pixelrácshoz @msg only paths and compound paths can be aligned to the pixel grid csak útvonalak és összetett útvonalak igazíthatók a pixelrácshoz @msg side is left or right, not `{s}` a side értéke left vagy right lehet, nem `{s}` Ignore All Az összes kihagyása diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 606ffae97c..7c552f5ae0 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -6534,6 +6534,7 @@ breakText Glyphs Glif # Align to Pixel Grid Align to Pixel Grid Sejajarkan ke grid piksel + Align New Objects to Pixel Grid Sejajarkan objek baru ke grid piksel @msg side is left or right, not `{s}` side adalah left atau right, bukan `{s}` Edit Custom Dictionary Edit kamus kustom Entry: Entri: diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index bf4c9e7b58..c3720f2bcd 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -6412,6 +6412,7 @@ breakText Glyphs Glifi # Align to Pixel Grid Align to Pixel Grid Allinea alla griglia pixel + Align New Objects to Pixel Grid Allinea nuovi oggetti alla griglia pixel @msg only paths and compound paths can be aligned to the pixel grid solo i tracciati e i tracciati composti possono essere allineati alla griglia pixel @msg side is left or right, not `{s}` side è left o right, non `{s}` Ignore All Ignora tutto diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 7a12afcadb..26868f5ed9 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -6477,6 +6477,7 @@ breakText Glyphs 字形 # Align to Pixel Grid Align to Pixel Grid ピクセルグリッドに整合 + Align New Objects to Pixel Grid 新規オブジェクトをピクセルグリッドに整合 @msg only paths and compound paths can be aligned to the pixel grid ピクセルグリッドに整合できるのはパスと複合パスのみです @msg side is left or right, not `{s}` side は left または right です(`{s}` ではありません) Ignore All すべて無視 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index c58480c426..82a07e5c64 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6900,6 +6900,7 @@ font-script Japanese იაპონური # Align to Pixel Grid Align to Pixel Grid პიქსელურ ბადეზე გასწორება + Align New Objects to Pixel Grid ახალი ობიექტების პიქსელურ ბადეზე გასწორება @msg only paths and compound paths can be aligned to the pixel grid პიქსელურ ბადეზე მხოლოდ ტრაექტორიების და შედგენილი ტრაექტორიების გასწორებაა შესაძლებელი @msg side is left or right, not `{s}` side არის left ან right და არა `{s}` Ignore All ყველას იგნორირება diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 39efdecf2e..5e3e026d7c 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -6373,6 +6373,7 @@ breakText Glyphs 글리프 # Align to Pixel Grid Align to Pixel Grid 픽셀 격자에 정렬 + Align New Objects to Pixel Grid 새 개체를 픽셀 격자에 정렬 @msg only paths and compound paths can be aligned to the pixel grid 패스와 컴파운드 패스만 픽셀 격자에 정렬할 수 있습니다 @msg side is left or right, not `{s}` side는 left 또는 right입니다(`{s}` 아님) Ignore All 모두 무시 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index dd1d22edc0..421fd6e372 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -6585,6 +6585,7 @@ breakText Glyphs Glyphs # Align to Pixel Grid Align to Pixel Grid Uitlijnen op pixelraster + Align New Objects to Pixel Grid Nieuwe objecten uitlijnen op pixelraster @msg side is left or right, not `{s}` side is left of right, niet `{s}` Edit Custom Dictionary Aangepast woordenboek bewerken Entry: Item: diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index dd850a3a4a..47d2e382f7 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -6647,6 +6647,7 @@ breakText Glyphs Glify # Align to Pixel Grid Align to Pixel Grid Wyrównaj do siatki pikseli + Align New Objects to Pixel Grid Wyrównaj nowe obiekty do siatki pikseli @msg only paths and compound paths can be aligned to the pixel grid do siatki pikseli można wyrównać tylko ścieżki i ścieżki złożone @msg side is left or right, not `{s}` side to left lub right, a nie `{s}` Ignore All Ignoruj wszystko diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index f67ae6cda2..6900a36ac2 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -6864,6 +6864,7 @@ swatch Yellow Green Verde-amarelado # Align to Pixel Grid Align to Pixel Grid Alinhar à Grade de Pixels + Align New Objects to Pixel Grid Alinhar Novos Objetos à Grade de Pixels @msg only paths and compound paths can be aligned to the pixel grid somente caminhos e caminhos compostos podem ser alinhados à grade de pixels @msg side is left or right, not `{s}` side é left ou right, não `{s}` Ignore All Ignorar Tudo diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 8eb5425823..238c3f650d 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -6880,6 +6880,7 @@ shape Open Открытый # Align to Pixel Grid Align to Pixel Grid Выровнять по пиксельной сетке + Align New Objects to Pixel Grid Выравнивать новые объекты по пиксельной сетке @msg only paths and compound paths can be aligned to the pixel grid по пиксельной сетке можно выровнять только контуры и составные контуры @msg side is left or right, not `{s}` side — это left или right, а не `{s}` Ignore All Пропустить все diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index fb2c463de0..543914cb1c 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -8276,6 +8276,7 @@ digits Hindi Hindi # Align to Pixel Grid Align to Pixel Grid Justera mot pixelrutnät + Align New Objects to Pixel Grid Justera nya objekt mot pixelrutnät @msg only paths and compound paths can be aligned to the pixel grid bara banor och sammansatta banor kan justeras mot pixelrutnätet @msg side is left or right, not `{s}` side är left eller right, inte `{s}` Ignore All Ignorera alla diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index d45a171cc6..e320f66075 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6747,6 +6747,7 @@ breakText Glyphs Glifler # Align to Pixel Grid Align to Pixel Grid Piksel Izgarasına Hizala + Align New Objects to Pixel Grid Yeni Nesneleri Piksel Izgarasına Hizala @msg only paths and compound paths can be aligned to the pixel grid piksel ızgarasına yalnızca yollar ve bileşik yollar hizalanabilir @msg side is left or right, not `{s}` side left veya right olmalıdır, `{s}` değil Ignore All Tümünü Yoksay diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index acaf20b6b9..627dfddf65 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -6392,6 +6392,7 @@ breakText Glyphs Гліфи # Align to Pixel Grid Align to Pixel Grid Вирівняти за піксельною сіткою + Align New Objects to Pixel Grid Вирівнювати нові об’єкти за піксельною сіткою @msg only paths and compound paths can be aligned to the pixel grid за піксельною сіткою можна вирівняти лише контури і складені контури @msg side is left or right, not `{s}` side — це left або right, а не `{s}` Ignore All Пропустити все diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 4a2bbd5a62..7233395d7b 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -6393,6 +6393,7 @@ breakText Glyphs Glyph # Align to Pixel Grid Align to Pixel Grid Căn theo lưới pixel + Align New Objects to Pixel Grid Căn đối tượng mới theo lưới pixel @msg only paths and compound paths can be aligned to the pixel grid chỉ đường dẫn và đường dẫn phức mới căn theo lưới pixel được @msg side is left or right, not `{s}` side là left hoặc right, không phải `{s}` Ignore All Bỏ qua tất cả diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 0eb7c7ebe5..fd555b20c0 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -6632,6 +6632,7 @@ breakText Glyphs 字形 # Align to Pixel Grid Align to Pixel Grid 对齐像素网格 + Align New Objects to Pixel Grid 将新对象对齐像素网格 @msg only paths and compound paths can be aligned to the pixel grid 只有路径和复合路径可以对齐像素网格 @msg side is left or right, not `{s}` side 应为 left 或 right,而不是 `{s}` Edit Custom Dictionary 编辑自定义词典 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 084a7e7708..cca290a62c 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -6430,6 +6430,7 @@ breakText Glyphs 字符 # Align to Pixel Grid Align to Pixel Grid 對齊像素格點 + Align New Objects to Pixel Grid 將新物件對齊像素格點 @msg only paths and compound paths can be aligned to the pixel grid 只有路徑和複合路徑可以對齊像素格點 @msg side is left or right, not `{s}` side 應為 left 或 right,而不是 `{s}` Edit Custom Dictionary 編輯自訂字典 diff --git a/crates/ui-egui/src/panels/transform.rs b/crates/ui-egui/src/panels/transform.rs index 25ddd40764..dc1e695481 100644 --- a/crates/ui-egui/src/panels/transform.rs +++ b/crates/ui-egui/src/panels/transform.rs @@ -581,6 +581,13 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { if menu_item(ui, tl!("Measure Type by Glyph Bounds"), true, glyphs) { set_pref(app, "typeGlyphBounds", !glyphs); } + // The active document's Align New Objects to Pixel Grid (`document.setup`). + let align_new = app.session.active().map(|st| st.doc.setup.align_new_objects_to_pixel_grid); + if menu_item(ui, tl!("Align New Objects to Pixel Grid"), align_new.is_some(), align_new == Some(true)) + && let Err(e) = app.run("document.setup", json!({"alignNewObjectsToPixelGrid": align_new != Some(true)})) + { + app.status(e); + } ui.separator(); // What the panel transforms (Transform Object Only / Pattern Only / Both), one checked. let current = pattern_mode(app); diff --git a/crates/ui-egui/src/panels/transform/tests.rs b/crates/ui-egui/src/panels/transform/tests.rs index 67cebc7cd2..dd2752cb2b 100644 --- a/crates/ui-egui/src/panels/transform/tests.rs +++ b/crates/ui-egui/src/panels/transform/tests.rs @@ -567,3 +567,34 @@ fn align_to_pixel_grid_shows_and_sets_the_property() { app.run("select.set", json!({"ids": [member]})).unwrap(); assert_eq!(pixel_aligned_state(&app).map(|s| s.1), Some(false)); } + +/// The panel menu's Align New Objects to Pixel Grid shows and toggles the active document's +/// setting, follows a switch to another document, and is unavailable with no document open. +#[test] +fn align_new_objects_menu_item_toggles_the_active_document() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let item = "Align New Objects to Pixel Grid"; + let shown = + |ctx: &egui::Context, app: &mut VectorcraftApp| menu_texts(ctx, app).into_iter().find(|t| t.trim_start_matches('✓').trim_start() == item); + assert!(shown(&ctx, &mut app).is_some_and(|t| !t.starts_with('✓')), "no document"); + menu_click(&ctx, &mut app, item); + assert!(app.session.active().is_none()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + app.run("file.new", json!({"preset": "Web 1920×1080"})).unwrap(); + let on = |app: &VectorcraftApp, i: usize| app.session.documents()[i].doc.setup.align_new_objects_to_pixel_grid; + assert_eq!((on(&app, 0), on(&app, 1)), (false, true)); + assert!(shown(&ctx, &mut app).is_some_and(|t| t.starts_with('✓')), "the Web document"); + app.session.set_active(0); + assert!(shown(&ctx, &mut app).is_some_and(|t| !t.starts_with('✓')), "the Letter-sized document"); + menu_click(&ctx, &mut app, item); + assert_eq!((on(&app, 0), on(&app, 1)), (true, true)); + assert!(shown(&ctx, &mut app).is_some_and(|t| t.starts_with('✓'))); + let r = app.run("shape.rectangle", json!({"x": 10.3, "y": 20.6, "width": 30.4, "height": 9.7})).unwrap(); + let id = vectorcraft_doc::NodeId(r["id"].as_u64().unwrap()); + assert!(app.session.active().unwrap().doc.node(id).unwrap().extras().pixel_aligned); + app.session.set_active(1); + menu_click(&ctx, &mut app, item); + assert_eq!((on(&app, 0), on(&app, 1)), (true, false)); +} diff --git a/docs/mcp.md b/docs/mcp.md index 5826c5c8c4..3f65ac06e7 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -4282,6 +4282,27 @@ files saved without it read back with it off. {"name":"run_command","arguments":{"command":"object.setPixelAligned","params":{"ids":[12],"on":false}}} ``` +### Align New Objects to Pixel Grid + +`document.setup {alignNewObjectsToPixelGrid: true|false}` turns the document's Align New Objects to Pixel Grid on or +off in one undo step, as the New Document dialog's Advanced Options checkbox and the Transform panel menu's checked item +do (described on the help page, +); +`document.setup` with no params reports it. While it is on, every path and compound path drawn in the document (the +shape, line, arc, spiral, Pen, Curvature, Pencil, Paintbrush, Blob Brush and `addon.shapes.*` commands, and the paths +in a new rectangular grid, polar grid or flare) takes the pixel-aligned property and is snapped in the undo step that +makes it. These rules are VectorCraft's own: type, images, groups and guides do not take it; art already in the document stays as it is when the setting changes; objects that Paste, Duplicate, Place and +import bring in keep their own property; and `object.setPixelAligned` turns one object's property off without changing +the document's setting. `file.new {alignNewObjectsToPixelGrid}` sets it for the new document. The Mobile and Web presets +start with it on and the other built-in presets with it off; saved presets and Recent entries keep the value they were +made with, and a saved preset from before the setting reads off. The setting is saved in the native file only when on, +and files saved without it open with it off. + +```json +{"name":"run_command","arguments":{"command":"document.setup","params":{"alignNewObjectsToPixelGrid":true}}} +{"name":"run_command","arguments":{"command":"file.new","params":{"preset":"Letter","alignNewObjectsToPixelGrid":true}}} +``` + ## Live Corners `object.setLiveShape {id?, ids?, radius?, kind?, corners?, items?}` sets the corners of any path (one undo step): a live @@ -4966,8 +4987,9 @@ Exporting and importing preferences is an optional VectorCraft feature. as a red outline around each artboard), the transparency grid (size and two colours; the first is also the simulated paper colour), the flattener preset and Discard White Overprint (Overprint Preview keeps white overprints visible while it is on), the type options (language and its quotes, Use Typographer's Quotes for typed straight quotes and a typed `”` where a quote opens, superscript, subscript -and small caps proportions, SVG text export) and the background contents (white: raster exports are white behind the -art). Pass any of these keys to change them in one undo step; `document.setUnits` stays as an alias for `units`. +and small caps proportions, SVG text export), the background contents (white: raster exports are white behind the +art) and Align New Objects to Pixel Grid (see [Align New Objects to Pixel Grid](#align-new-objects-to-pixel-grid)). +Pass any of these keys to change them in one undo step; `document.setUnits` stays as an alias for `units`. A file whose bleed (0 to 72 pt a side), superscript and subscript sizes (1 to 500%) and positions (-500 to 500%) or small caps size (1 to 200%) lies outside those ranges opens with the nearer end of each range. @@ -4980,7 +5002,8 @@ small caps size (1 to 200%) lies outside those ranges opens with the nearer end `file.new` takes everything New Document sets: `preset` (a name from `file.newPresets`), `name`, `width`/`height` (points, or lengths such as `"210 mm"`), `units`, `orientation`, `artboards` with `artboardLayout {layout, columns, spacing, rightToLeft}`, `bleed`, `backgroundContents`, `colorMode`, `rasterEffectsPpi` (1 to 2,400; a saved preset's -value outside that range is read as the nearer end) and `previewMode`. +value outside that range is read as the nearer end), `previewMode` and `alignNewObjectsToPixelGrid` (see +[Align New Objects to Pixel Grid](#align-new-objects-to-pixel-grid)). `file.newPresets {category?}` lists the categories and presets (Recent: the last sizes used; Saved: the user's presets, kept in the preferences by `file.newPresets.save` and removed by `file.newPresets.delete`). A listed preset can be passed straight back to `file.new`. Print presets and sizes without `units` start in `unitsGeneral`; screen presets diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b4c949f742..2e5982da8c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -799,6 +799,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The New Document from State button at the bottom of the History panel (`edit.newDocumentFromState`) makes a new document, which becomes active, from the state the panel has selected ([public source](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/undo-edits-and-manage-design-history.html)). VectorCraft's own rules: the new document takes the next Untitled-N name, is marked unsaved, has nothing selected and has no steps to undo or redo, and the source document and its steps stay as they are. - The Transform panel menu's Transform Object Only, Transform Pattern Only and Transform Both (`transformPanelPatterns`) choose whether the fields, flips and menu flips of the Transform panel and the Properties panel's Transform section move the object and leave its pattern tiles, move the tiles alone, or both (until a choice they follow General › Transform Pattern Tiles, as before); Use Registration Point for Symbol (`useRegistrationPointForSymbol`) measures and transforms one selected symbol instance from its symbol's registration point, where the instance's flips, rotations and shears put it ([Transform panel overview](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/transform-panel-overview.html); `panels::transform::tests::the_menu_chooses_whether_the_object_its_pattern_or_both_transform`, `use_registration_point_for_symbol_measures_an_instance_from_its_registration_point`, `use_registration_point_for_symbol_follows_a_transformed_instance`, `tests_prefs::transform_panel_patterns_defaults_to_following_the_general_preference`). - The Transform panel's Align to Pixel Grid checkbox (`object.setPixelAligned`) gives paths and compound paths a pixel-aligned property, saved in the native file: their horizontal and vertical straight segments sit on whole points (half points under an odd centered stroke) with whole stroke weights, and every move, scale, rotation, reflection, Transform Each, Align move, Transform panel change, move of direct-selected anchors or of one anchor, move of a compound path's path on its own, artboard move, resize or copy that carries the art, Duplicate or paste that moves a copy, stroke edit and appearance edit that adds, removes, hides, repaints or reweighs a stroke snaps them again, while diagonal segments and curves keep their shape; a compound path made from an aligned path or compound path is aligned and snaps its paths with its own stroke; the box shows a dash for a mixed selection and is unavailable for type, images and groups ([Pixel-aligned property](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/pixel-preview-mode-for-web-graphics.html); `tests::pixelalign::turning_it_on_snaps_the_path_in_one_undo_step`, `it_stays_aligned_through_transforms`, `it_stays_aligned_when_its_artboard_moves`, `diagonal_segments_and_curves_keep_their_shape`, `type_and_images_cannot_be_aligned`, `the_property_is_saved_and_read_back`, `stroke_edits_keep_it_aligned`, `a_compound_path_of_aligned_paths_is_aligned`, `a_compound_path_of_an_aligned_compound_path_is_aligned`, `a_stroke_without_paint_snaps_to_whole_points`, `moving_direct_selected_anchors_keeps_it_aligned`, `moving_one_anchor_keeps_it_aligned`, `moving_a_path_of_an_aligned_compound_path_keeps_it_aligned`, `artboard_options_and_artboard_copies_keep_it_aligned`, `appearance_edits_keep_it_aligned`, `placed_gradients_follow_the_snapped_path`, `duplicates_and_pastes_keep_it_aligned`, `paths_added_to_or_moved_in_an_aligned_compound_path_keep_it_aligned`, `the_art_of_a_linked_opacity_mask_keeps_it_aligned`, `a_linked_opacity_mask_follows_a_snap`, `aligning_direct_selected_anchors_keeps_it_aligned`, `global_edit_keeps_its_members_aligned`, `a_path_with_live_corners_snaps_its_inner_edges`, `geometry_edits_keep_it_aligned`, `other_path_edits_keep_it_aligned`, `panels::transform::tests::align_to_pixel_grid_shows_and_sets_the_property`). + - Align New Objects to Pixel Grid (`document.setup {alignNewObjectsToPixelGrid}`, a checkbox in New Document's Advanced Options and a checked item in the Transform panel menu) gives every path and compound path drawn in the document, the paths of a new grid or flare included, the pixel-aligned property, snapped in the step that draws it; it is on in the Mobile and Web presets and off in the others, saved presets and Recent keep it, and the document saves it. Type, images and groups do not take it, and art already in the document, pasted, duplicated or placed keeps its own property (VectorCraft's own rules) ([Pixel-aligned property](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/pixel-preview-mode-for-web-graphics.html); `tests::pixelalign::align_new_objects_makes_new_paths_pixel_aligned`, `align_new_objects_reaches_grids_flares_and_add_on_shapes`, `align_new_objects_is_saved_and_read_back`, `tests::newdoc::align_new_objects_to_pixel_grid_follows_the_preset`, `panels::transform::tests::align_new_objects_menu_item_toggles_the_active_document`, `dialogs::new_document::tests::align_new_objects_checkbox_follows_the_preset_and_reaches_the_document`). - Object › Transform › Reset Bounding Box squares the box of every object inside the selected ones as well: the children of groups, compound paths, clip groups, blends, envelopes, repeats and intertwines, and the art of linked opacity masks. Ungroup and the release commands then bring back boxes square to the page (VectorCraft's own behavior; `bboxrotate::reset_bounding_box_squares_the_objects_inside_a_group`, `reset_bounding_box_squares_the_turned_members_of_a_square_group`, `reset_bounding_box_then_release_clipping_mask` and the other `reset_bounding_box_then_*` tests, `reset_bounding_box_leaves_the_art_of_an_unlinked_opacity_mask`). - Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool. Native menus explicitly end tracking on command selection, outside window presses and Escape; menu updates wait until tracking ends, and a dismissing press never starts a canvas gesture (M14.8). - The errors of the blend spine commands (Move Spine Point, Reshape Spine Segment, Add Spine Point and Delete Spine Point) show in the status bar in the interface language: each has a row in every catalog that translates all status and error messages and in Bulgarian (`blend_spine_param_errors_are_translated`). diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 296826671f..a1d72ed8ce 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -271,6 +271,7 @@ tools, never checked side by side) isn't captured row by row. | Split Into Grid dialog: number, height or width, gutter and total for rows and columns, Add Guides, Preview | done | #1462; opens at the selection's size; several paths become one grid painted as the topmost one; a typed height or width keeps the gutter and changes the total (our choice) | | Reference point locator (nine squares) in Control bar and Transform panel | done | | | Transform panel menu: Transform Object Only, Transform Pattern Only, Transform Both (one checked), Use Registration Point for Symbol | done | `transformPanelPatterns` (`object`, `pattern`, `both`) sends `objects` and `patterns` with every field, flip and menu flip of the Transform panel and the Properties panel's Transform section; `useRegistrationPointForSymbol` makes them measure and transform one selected symbol instance from its symbol's registration point (Symbol Options › Registration), where the instance's flips, rotations and shears put it, instead of the reference point; the Properties panel's rotation and flips turn and mirror about it too ([Transform panel overview](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/transform-panel-overview.html)); until a choice is made (`default`) the panels follow General › Transform Pattern Tiles as before, the menu checking Transform Both while it is on and Transform Object Only while it is off | +| Transform panel menu and New Document › Advanced Options: Align New Objects to Pixel Grid | done | `document.setup {alignNewObjectsToPixelGrid}` and `file.new {alignNewObjectsToPixelGrid}`: paths and compound paths drawn while it is on are pixel-aligned; on in the Mobile and Web presets; art already in the document, pasted, duplicated or placed keeps its own property (our choice) | | Transform panel, Properties and Control bar X/Y/W/H of the anchor points selected with Direct Selection | done | #1839 (fixes #1824): they show and edit the selected points' bounds; a whole-object selection is unchanged | ## Nudging, numeric entry and precision From 2d29087ac2b947c1b9200f0ba709d3d76e932927 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 18:25:50 -0800 Subject: [PATCH 03/15] Graphic Styles: built-in Textures library with grain, canvas, craquelure and tile styles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Window › Graphic Style Libraries › Textures, after Hand-Drawn, holds eight styles that paint their fills and strokes with the texture effects: Fine Grain (Grain), Canvas Weave and Burlap Sack (Texturizer), Cracked Glaze (Craquelure), Mosaic Floor (Mosaic Tiles), Quilted Squares (Patchwork), Leaded Glass (Stained Glass) and Damp Paper (Water Paper). The styles, their names and their settings are VectorCraft's own. Each catalog has rows for the library and style names. Docs: docs/mcp.md, docs/gaps.md, docs/target-app-parity.md. --- crates/doc/src/style_libs.rs | 51 ++++++++++++++++++++++++++++ crates/engine/src/tests/stylelib.rs | 52 +++++++++++++++++++++++++++++ crates/ui-egui/src/i18n/bg.tsv | 9 +++++ crates/ui-egui/src/i18n/bs.tsv | 9 +++++ crates/ui-egui/src/i18n/ca.tsv | 9 +++++ crates/ui-egui/src/i18n/cs.tsv | 9 +++++ crates/ui-egui/src/i18n/da.tsv | 9 +++++ crates/ui-egui/src/i18n/de.tsv | 9 +++++ crates/ui-egui/src/i18n/el.tsv | 9 +++++ crates/ui-egui/src/i18n/es.tsv | 9 +++++ crates/ui-egui/src/i18n/fi.tsv | 9 +++++ crates/ui-egui/src/i18n/fr.tsv | 9 +++++ crates/ui-egui/src/i18n/hi.tsv | 9 +++++ crates/ui-egui/src/i18n/hu.tsv | 9 +++++ crates/ui-egui/src/i18n/id.tsv | 9 +++++ crates/ui-egui/src/i18n/it.tsv | 9 +++++ crates/ui-egui/src/i18n/ja.tsv | 9 +++++ crates/ui-egui/src/i18n/ka.tsv | 9 +++++ crates/ui-egui/src/i18n/ko.tsv | 9 +++++ crates/ui-egui/src/i18n/nl.tsv | 9 +++++ crates/ui-egui/src/i18n/pl.tsv | 9 +++++ crates/ui-egui/src/i18n/pt-br.tsv | 9 +++++ crates/ui-egui/src/i18n/ru.tsv | 9 +++++ crates/ui-egui/src/i18n/sv.tsv | 9 +++++ crates/ui-egui/src/i18n/tr.tsv | 9 +++++ crates/ui-egui/src/i18n/uk.tsv | 9 +++++ crates/ui-egui/src/i18n/vi.tsv | 9 +++++ crates/ui-egui/src/i18n/zh-hans.tsv | 9 +++++ crates/ui-egui/src/i18n/zh-hant.tsv | 9 +++++ docs/gaps.md | 11 +++--- docs/mcp.md | 5 ++- docs/target-app-parity.md | 3 +- 32 files changed, 358 insertions(+), 7 deletions(-) diff --git a/crates/doc/src/style_libs.rs b/crates/doc/src/style_libs.rs index f64bdb4300..97d46b7cda 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: "textures", name: "Textures", make: textures }, 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,44 @@ fn hand_drawn() -> Vec { ] } +/// Textures: fills and strokes painted with the catalog's texture effects (grain, canvas and +/// burlap surfaces, craquelure, tiles, patchwork, stained glass and water paper). +fn textures() -> Vec { + let surface = |texture: &str, scaling: f64, relief: f64, light: &str| { + fx("texture.texturizer", json!({"texture": texture, "scaling": scaling, "relief": relief, "lightDirection": light, "invert": false})) + }; + vec![ + style( + "Fine Grain", + vec![F(fill("#c9ada7")), S(stroke("#4a4e69", 1.0))], + vec![fx("texture.grain", json!({"intensity": 35.0, "contrast": 55.0, "grainType": "regular"}))], + ), + style("Canvas Weave", vec![F(fill("#e9d8a6"))], vec![surface("canvas", 100.0, 6.0, "topLeft")]), + style("Burlap Sack", vec![F(fill("#bc8a5f")), S(stroke("#6f4518", 1.5))], vec![surface("burlap", 120.0, 8.0, "top")]), + style( + "Cracked Glaze", + vec![F(fill("#94d2bd")), S(stroke("#005f73", 1.0))], + vec![fx("texture.craquelure", json!({"crackSpacing": 12.0, "crackDepth": 5.0, "crackBrightness": 8.0}))], + ), + style( + "Mosaic Floor", + vec![F(fill("#0a9396"))], + vec![fx("texture.mosaicTiles", json!({"tileSize": 10.0, "groutWidth": 3.0, "lightenGrout": 8.0}))], + ), + style("Quilted Squares", vec![F(fill("#ee9b00"))], vec![fx("texture.patchwork", json!({"squareSize": 5.0, "relief": 10.0}))]), + style( + "Leaded Glass", + vec![F(FillLayer::new(linear(-45.0, &[(0.0, "#ae2012", 1.0), (0.5, "#ee9b00", 1.0), (1.0, "#005f73", 1.0)])))], + vec![fx("texture.stainedGlass", json!({"cellSize": 8.0, "borderThickness": 4.0, "lightIntensity": 3.0}))], + ), + style( + "Damp Paper", + vec![F(fill("#4895ef")), S(stroke("#3f37c9", 1.0))], + vec![fx("sketch.waterPaper", json!({"fiberLength": 12.0, "brightness": 55.0, "contrast": 60.0}))], + ), + ] +} + fn gradient_finishes() -> Vec { let rim = |h: &str| S(stroke(h, 1.0)); vec![ @@ -550,6 +589,18 @@ mod tests { assert_eq!(status.name, "Status Effects"); let status_names: Vec<&str> = status.styles.iter().map(|g| g.name.as_str()).collect(); assert_eq!(status_names, ["Poison Tint", "Freeze Edge", "Burn Aura", "Stun Pulse", "Stealth Fade", "Shield Buff"]); + let textures = builtin_style_library("textures").unwrap(); + assert_eq!(textures.name, "Textures"); + let texture_names: Vec<&str> = textures.styles.iter().map(|g| g.name.as_str()).collect(); + let expected = + ["Fine Grain", "Canvas Weave", "Burlap Sack", "Cracked Glaze", "Mosaic Floor", "Quilted Squares", "Leaded Glass", "Damp Paper"]; + assert_eq!(texture_names, expected); + for b in STYLE_LIBRARIES.iter().filter(|b| b.id != "textures") { + let lib = builtin_style_library(b.id).unwrap(); + assert!(lib.styles.iter().all(|g| !expected.contains(&g.name.as_str())), "{} shares a name with Textures", b.id); + } + let ids: Vec<&str> = STYLE_LIBRARIES.iter().map(|b| b.id).collect(); + assert_eq!(ids.iter().position(|&i| i == "textures"), ids.iter().position(|&i| i == "hand-drawn").map(|i| i + 1), "after Hand-Drawn"); } #[test] diff --git a/crates/engine/src/tests/stylelib.rs b/crates/engine/src/tests/stylelib.rs index 5124fb4502..d1d4155904 100644 --- a/crates/engine/src/tests/stylelib.rs +++ b/crates/engine/src/tests/stylelib.rs @@ -44,6 +44,7 @@ fn builtin_libraries_are_listed_with_original_styles_using_catalogued_effects() assert!(libs.len() >= 8 && libs.iter().all(|l| l["category"] == "builtIn"), "{libs:?}"); assert!(libs.iter().any(|l| l["id"] == "game-hud" && l["name"] == "Game HUD"), "{libs:?}"); assert!(libs.iter().any(|l| l["id"] == "status-effects" && l["name"] == "Status Effects"), "{libs:?}"); + assert!(libs.iter().any(|l| l["id"] == "textures" && l["name"] == "Textures" && l["count"] == 8), "{libs:?}"); for l in libs { let id = l["id"].as_str().unwrap(); let g = run(&mut s, "graphicStyle.library", json!({"library": id})); @@ -64,6 +65,10 @@ fn builtin_libraries_are_listed_with_original_styles_using_catalogued_effects() let status = run(&mut s, "graphicStyle.library", json!({"library": "status-effects"})); let status_names: Vec<&str> = status["styles"].as_array().unwrap().iter().map(|x| x["name"].as_str().unwrap()).collect(); assert_eq!(status_names, ["Poison Tint", "Freeze Edge", "Burn Aura", "Stun Pulse", "Stealth Fade", "Shield Buff"]); + let textures = run(&mut s, "graphicStyle.library", json!({"library": "textures"})); + let texture_names: Vec<&str> = textures["styles"].as_array().unwrap().iter().map(|x| x["name"].as_str().unwrap()).collect(); + let expected = ["Fine Grain", "Canvas Weave", "Burlap Sack", "Cracked Glaze", "Mosaic Floor", "Quilted Squares", "Leaded Glass", "Damp Paper"]; + assert_eq!(texture_names, expected); let id = rect(&mut s, 10.0, 20.0, 40.0, 30.0); let r = run(&mut s, "graphicStyle.addFromLibrary", json!({"library": "game-hud", "name": "Mana Glow", "apply": true, "ids": [id.0]})); assert_eq!((names(&r["added"]), r["applied"].as_str()), (vec!["Mana Glow"], Some("Mana Glow"))); @@ -167,6 +172,53 @@ fn add_with_null_ids_applies_to_selection() { assert_eq!(doc(&s).node(id).unwrap().extras().graphic_style, Some(style.id)); } +#[test] +fn textures_styles_apply_their_texture_effects_and_keep_their_params_through_save_and_open() { + let mut s = session(); + let (_, lib) = cmd::stylelib::library(&s, "textures").unwrap(); + let mut ids = vec![]; + for (i, g) in lib.styles.iter().enumerate() { + let id = rect(&mut s, (i % 4) as f64 * 50.0, (i / 4) as f64 * 50.0, 40.0, 40.0); + let before = undo_len(&s); + let r = run(&mut s, "graphicStyle.addFromLibrary", json!({"library": "textures", "name": g.name, "apply": true, "ids": [id.0]})); + assert_eq!((names(&r["added"]), r["applied"].as_str()), (vec![g.name.as_str()], Some(g.name.as_str()))); + assert_eq!(undo_len(&s), before + 1, "{}: added and applied as one step", g.name); + ids.push(id); + } + let effects = |s: &Session| -> Vec> { + ids.iter().map(|id| doc(s).node(*id).unwrap().appearance.effects.iter().map(|e| (e.id.clone(), e.params.clone())).collect()).collect() + }; + let applied = effects(&s); + let effect_ids: Vec> = applied.iter().map(|fx| fx.iter().map(|(id, _)| id.as_str()).collect()).collect(); + assert_eq!( + effect_ids, + [ + ["texture.grain"], + ["texture.texturizer"], + ["texture.texturizer"], + ["texture.craquelure"], + ["texture.mosaicTiles"], + ["texture.patchwork"], + ["texture.stainedGlass"], + ["sketch.waterPaper"], + ] + ); + assert_eq!(applied[1][0].1["texture"], "canvas"); + assert_eq!(applied[3][0].1["crackSpacing"], 12.0); + for (fx, g) in applied.iter().zip(&lib.styles) { + let want: Vec<(String, Value)> = g.appearance.effects.iter().map(|e| (e.id.clone(), e.params.clone())).collect(); + assert_eq!(fx, &want, "{} applies the library's params", g.name); + } + let b64 = run(&mut s, "document.serialize", json!({"format": "vectorcraft"}))["dataBase64"].as_str().unwrap().to_string(); + run(&mut s, "document.open", json!({"name": "textures.vectorcraft", "dataBase64": b64})); + assert_eq!(effects(&s), applied, "the params survive a save and open"); + for g in &lib.styles { + let saved = doc(&s).graphic_style(&g.name).unwrap(); + assert!(saved.same_look(g), "{} keeps its look", g.name); + assert!(ids.iter().any(|id| doc(&s).node(*id).unwrap().extras().graphic_style == Some(saved.id)), "{} stays linked", g.name); + } +} + /// A document with pattern `name` (a dot) and its swatch. fn add_pattern(s: &mut Session, name: &str, radius: f64) { s.edit("Pattern", |d, _| { diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index d83b5ac3c6..cb3e8f3762 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -3080,6 +3080,15 @@ digits Malayalam Малаялам Stun Pulse Зашеметяване Stealth Fade Скритост Shield Buff Щит + Textures Текстури + Fine Grain Фино зърно + Canvas Weave Тъкано платно + Burlap Sack Зебло + Cracked Glaze Напукана глазура + Mosaic Floor Мозаечен под + Quilted Squares Капитонирани квадрати + Leaded Glass Оловно стъкло + Damp Paper Влажна хартия Shape Фигура Shape Builder Създател на фигури Shape Builder Regions Области на създателя на фигури diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 663e95ceaa..e49bd0736a 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -784,6 +784,15 @@ Stun Pulse Omamljenost Stealth Fade Nevidljivost Shield Buff Štit + Textures Teksture + Fine Grain Fino zrno + Canvas Weave Tkano platno + Burlap Sack Jutena vreća + Cracked Glaze Ispucala glazura + Mosaic Floor Mozaični pod + Quilted Squares Prošiveni kvadrati + Leaded Glass Olovno staklo + Damp Paper Vlažni papir Outlines and Rules Obrisi i linije Hand-Drawn Ručno crtano Gradient Finishes Gradijentne završnice diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 9c60e87e00..bfd24ce4ea 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -3976,6 +3976,15 @@ digits Malayalam Malaiàlam Stun Pulse Atordiment Stealth Fade Sigil Shield Buff Escut + Textures Textures + Fine Grain Gra fi + Canvas Weave Teixit de llenç + Burlap Sack Sac de jute + Cracked Glaze Vidrat esquerdat + Mosaic Floor Terra de mosaic + Quilted Squares Quadrats encoixinats + Leaded Glass Vitrall emplomat + Damp Paper Paper humit Shape Forma Shape Builder Constructor de formes Shape Builder Delete Suprimeix del constructor de formes diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index e8f9666fae..359f9eeb11 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -792,6 +792,15 @@ Stun Pulse Omráčení Stealth Fade Plížení Shield Buff Štít + Textures Textury + Fine Grain Jemné zrno + Canvas Weave Tkané plátno + Burlap Sack Pytlovina + Cracked Glaze Popraskaná glazura + Mosaic Floor Mozaiková podlaha + Quilted Squares Prošívané čtverce + Leaded Glass Olovnaté sklo + Damp Paper Vlhký papír Outlines and Rules Obrysy a linky Hand-Drawn Ručně kreslené Gradient Finishes Přechodové povrchy diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 31915ed6bf..fd45b408b4 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -730,6 +730,15 @@ Stun Pulse Bedøvelse Stealth Fade Snige Shield Buff Skjold + Textures Teksturer + Fine Grain Fin kornethed + Canvas Weave Lærredsvævning + Burlap Sack Jutesæk + Cracked Glaze Krakeleret glasur + Mosaic Floor Mosaikgulv + Quilted Squares Quiltede firkanter + Leaded Glass Blyindfattet glas + Damp Paper Fugtigt papir Outlines and Rules Konturer og linjer Hand-Drawn Håndtegnet Gradient Finishes Gradientfinish diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 2c5424d81f..736b7b45ba 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3127,6 +3127,15 @@ digits Malayalam Malayalam Stun Pulse Betäubung Stealth Fade Schleichen Shield Buff Schild + Textures Texturen + Fine Grain Feine Körnung + Canvas Weave Leinwandgewebe + Burlap Sack Jutesack + Cracked Glaze Craquelé-Glasur + Mosaic Floor Mosaikboden + Quilted Squares Gesteppte Quadrate + Leaded Glass Bleiglas + Damp Paper Feuchtes Papier Shape Form Shape Builder Formerstellung Shape Builder Regions Formerstellungsbereiche diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 6071ed5be4..ce26391278 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3635,6 +3635,15 @@ digits Malayalam Μαλαγιάλαμ Stun Pulse Ζάλη Stealth Fade Αορασία Shield Buff Ασπίδα + Textures Υφές + Fine Grain Λεπτός κόκκος + Canvas Weave Ύφανση καμβά + Burlap Sack Λινάτσα + Cracked Glaze Ραγισμένο γλάσο + Mosaic Floor Ψηφιδωτό δάπεδο + Quilted Squares Καπιτονέ τετράγωνα + Leaded Glass Μολυβδόδετο γυαλί + Damp Paper Υγρό χαρτί Shape Builder Regions Περιοχές δημιουργίας σχημάτων Shape Effects Εφέ σχημάτων Shape Mode Λειτουργία σχήματος diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 4a74e3ae39..bc3df856c7 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3166,6 +3166,15 @@ digits Malayalam Malayálam Stun Pulse Aturdimiento Stealth Fade Sigilo Shield Buff Escudo + Textures Texturas + Fine Grain Grano fino + Canvas Weave Tejido de lienzo + Burlap Sack Saco de arpillera + Cracked Glaze Esmalte agrietado + Mosaic Floor Suelo de mosaico + Quilted Squares Cuadros acolchados + Leaded Glass Vidrio emplomado + Damp Paper Papel húmedo Shape Forma Shape Builder Generador de formas Shape Builder Regions Regiones del generador de formas diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index f2ec64f602..8e097780e2 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -783,6 +783,15 @@ Stun Pulse Tainnutus Stealth Fade Hiipiminen Shield Buff Kilpi + Textures Tekstuurit + Fine Grain Hieno rae + Canvas Weave Kanvaskudos + Burlap Sack Juuttisäkki + Cracked Glaze Krakeloitu lasite + Mosaic Floor Mosaiikkilattia + Quilted Squares Tikatut ruudut + Leaded Glass Lyijylasi + Damp Paper Kostea paperi Outlines and Rules Ääriviivat ja viivat Hand-Drawn Käsin piirretyt Gradient Finishes Liukuväriviimeistelyt diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 807b3363b4..6289592ab8 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3158,6 +3158,15 @@ digits Malayalam Malayalam Stun Pulse Étourdissement Stealth Fade Furtivité Shield Buff Bouclier + Textures Textures + Fine Grain Grain fin + Canvas Weave Toile tissée + Burlap Sack Sac de jute + Cracked Glaze Glaçure craquelée + Mosaic Floor Sol en mosaïque + Quilted Squares Carrés matelassés + Leaded Glass Vitrail au plomb + Damp Paper Papier humide Shape Forme Shape Builder Concepteur de forme Shape Builder Regions Zones du Concepteur de forme diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 572a19779f..bfa4534d7b 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -4288,6 +4288,15 @@ antiAlias Sharp शार्प Stun Pulse स्तब्ध Stealth Fade छिपना Shield Buff ढाल + Textures बनावटें + Fine Grain महीन दाना + Canvas Weave कैनवास बुनावट + Burlap Sack टाट की बोरी + Cracked Glaze दरकी ग्लेज़ + Mosaic Floor मोज़ेक फ़र्श + Quilted Squares रज़ाईदार वर्ग + Leaded Glass सीसा जड़ा काँच + Damp Paper नम काग़ज़ Shaft: दस्ता: Shape शेप Shape Builder शेप बिल्डर diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 8c2d2339bc..b8628e0d66 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3781,6 +3781,15 @@ mark-style Japanese Japán Stun Pulse Kábítás Stealth Fade Lopakodás Shield Buff Pajzs + Textures Textúrák + Fine Grain Finom szemcse + Canvas Weave Vászonszövés + Burlap Sack Jutazsák + Cracked Glaze Repedezett máz + Mosaic Floor Mozaikpadló + Quilted Squares Steppelt négyzetek + Leaded Glass Ólomüveg + Damp Paper Nedves papír Shape Alakzat Shape Builder Alakzatépítő Shape Builder Regions Alakzatépítő területei diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 606ffae97c..1f2b68b556 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -730,6 +730,15 @@ Stun Pulse Pingsan Stealth Fade Menghilang Shield Buff Perisai + Textures Tekstur + Fine Grain Butiran Halus + Canvas Weave Anyaman Kanvas + Burlap Sack Karung Goni + Cracked Glaze Glasir Retak + Mosaic Floor Lantai Mosaik + Quilted Squares Kotak Quilt + Leaded Glass Kaca Patri + Damp Paper Kertas Basah Outlines and Rules Garis luar dan garis Hand-Drawn Gambar tangan Gradient Finishes Finishing gradien diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index bf4c9e7b58..7b38a58d1e 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -3203,6 +3203,15 @@ digits Malayalam Malayalam Stun Pulse Stordimento Stealth Fade Furtività Shield Buff Scudo + Textures Texture + Fine Grain Grana fine + Canvas Weave Trama di tela + Burlap Sack Sacco di iuta + Cracked Glaze Smalto craquelé + Mosaic Floor Pavimento a mosaico + Quilted Squares Quadrati trapuntati + Leaded Glass Vetro piombato + Damp Paper Carta umida Shape Forma Shape Builder Generatore forme Shape Builder Regions Aree del Generatore forme diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 7a12afcadb..2f151443b4 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -816,6 +816,15 @@ digits Malayalam マラヤーラム数字 Stun Pulse 気絶 Stealth Fade ステルス Shield Buff シールド + Textures テクスチャ集 + Fine Grain 細かい粒子 + Canvas Weave キャンバス織り + Burlap Sack 麻袋 + Cracked Glaze ひび割れ釉 + Mosaic Floor モザイクの床 + Quilted Squares キルトの四角 + Leaded Glass 鉛ガラス + Damp Paper 湿った紙 Outlines and Rules アウトラインと罫線 Hand-Drawn 手描き風 Gradient Finishes グラデーション仕上げ diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index c58480c426..ff46b5c4c6 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -689,6 +689,15 @@ Stun Pulse დაბნეულობა Stealth Fade შენიღბვა Shield Buff ფარი + Textures ტექსტურები + Fine Grain წვრილი მარცვალი + Canvas Weave ტილოს ქსოვილი + Burlap Sack ჯუთის ტომარა + Cracked Glaze დამსკდარი მინანქარი + Mosaic Floor მოზაიკის იატაკი + Quilted Squares ქვილთის კვადრატები + Leaded Glass ტყვიის მინა + Damp Paper ნესტიანი ქაღალდი Outlines and Rules კონტურები და ხაზები Hand-Drawn ხელით დახატული Gradient Finishes გრადიენტული ზედაპირები diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 39efdecf2e..e67bc17ac6 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -809,6 +809,15 @@ digits Malayalam 말라얄람어 Stun Pulse 기절 Stealth Fade 은신 Shield Buff 방패 + Textures 텍스처 + Fine Grain 고운 입자 + Canvas Weave 캔버스 직조 + Burlap Sack 마대 자루 + Cracked Glaze 갈라진 유약 + Mosaic Floor 모자이크 바닥 + Quilted Squares 퀼트 사각형 + Leaded Glass 납유리 + Damp Paper 젖은 종이 Outlines and Rules 윤곽선과 괘선 Hand-Drawn 손그림 Gradient Finishes 그레이디언트 마감 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index dd1d22edc0..e82e7eefbf 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -744,6 +744,15 @@ Stun Pulse Verdoving Stealth Fade Sluipen Shield Buff Schild + Textures Texturen + Fine Grain Fijne korrel + Canvas Weave Canvasweefsel + Burlap Sack Jutezak + Cracked Glaze Gecraqueleerd glazuur + Mosaic Floor Mozaïekvloer + Quilted Squares Gequilte vierkanten + Leaded Glass Glas-in-lood + Damp Paper Vochtig papier Outlines and Rules Omtrekken en lijnen Hand-Drawn Handgetekend Gradient Finishes Verloopafwerkingen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index dd850a3a4a..cb4cd0c1c4 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -739,6 +739,15 @@ Stun Pulse Ogłuszenie Stealth Fade Ukrycie Shield Buff Tarcza + Textures Tekstury + Fine Grain Drobne ziarno + Canvas Weave Splot płótna + Burlap Sack Worek jutowy + Cracked Glaze Spękana glazura + Mosaic Floor Mozaikowa posadzka + Quilted Squares Pikowane kwadraty + Leaded Glass Witraż ołowiany + Damp Paper Wilgotny papier Outlines and Rules Kontury i linie Hand-Drawn Rysowane odręcznie Gradient Finishes Wykończenia gradientowe diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index f67ae6cda2..a571b349c2 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2658,6 +2658,15 @@ digits Malayalam Malaiala Stun Pulse Atordoamento Stealth Fade Furtividade Shield Buff Escudo + Textures Texturas + Fine Grain Grão fino + Canvas Weave Trama de tela + Burlap Sack Saco de juta + Cracked Glaze Esmalte craquelado + Mosaic Floor Piso de mosaico + Quilted Squares Quadrados acolchoados + Leaded Glass Vitral de chumbo + Damp Paper Papel úmido Shape Forma Shape Builder Construtor de Formas Shape Builder Regions Regiões do Construtor de Formas diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 8eb5425823..7a9d41460b 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3198,6 +3198,15 @@ digits Malayalam Малаялам Stun Pulse Оглушение Stealth Fade Скрытность Shield Buff Щит + Textures Текстуры + Fine Grain Мелкое зерно + Canvas Weave Холстяное плетение + Burlap Sack Мешковина + Cracked Glaze Потрескавшаяся глазурь + Mosaic Floor Мозаичный пол + Quilted Squares Стёганые квадраты + Leaded Glass Витражное стекло + Damp Paper Влажная бумага Shape Фигура Shape Builder Создание фигур Shape Builder Regions Области создания фигур diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index fb2c463de0..88aa55a55d 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -3090,6 +3090,15 @@ digits Malayalam Malayalam Stun Pulse Bedövning Stealth Fade Smyga Shield Buff Sköld + Textures Texturer + Fine Grain Fin kornighet + Canvas Weave Kanvasväv + Burlap Sack Jutesäck + Cracked Glaze Krackelerad glasyr + Mosaic Floor Mosaikgolv + Quilted Squares Quiltade rutor + Leaded Glass Blyinfattat glas + Damp Paper Fuktigt papper Shape Form Shape Builder Formbyggare Shape Builder Regions Formbyggarområden diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index d45a171cc6..591c151a0d 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2994,6 +2994,15 @@ digits Malayalam Malayalam Stun Pulse Sersemletme Stealth Fade Gizlilik Shield Buff Kalkan + Textures Dokular + Fine Grain İnce Gren + Canvas Weave Kanvas Dokuma + Burlap Sack Çuval Bezi + Cracked Glaze Çatlak Sır + Mosaic Floor Mozaik Zemin + Quilted Squares Kapitone Kareler + Leaded Glass Kurşunlu Cam + Damp Paper Nemli Kâğıt Shape Şekil Shape Builder Şekil Oluşturucu Shape Builder Regions Şekil Oluşturucu Bölgeleri diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index acaf20b6b9..cde65eb5f0 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3195,6 +3195,15 @@ digits Malayalam Малаялам Stun Pulse Оглушення Stealth Fade Невидимість Shield Buff Щит + Textures Текстури + Fine Grain Дрібне зерно + Canvas Weave Полотняне плетіння + Burlap Sack Мішковина + Cracked Glaze Потріскана глазур + Mosaic Floor Мозаїчна підлога + Quilted Squares Стьобані квадрати + Leaded Glass Вітражне скло + Damp Paper Вологий папір Shape Фігура Shape Builder Створення фігур Shape Builder Regions Області створення фігур diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 4a2bbd5a62..4aeca5291b 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3527,6 +3527,15 @@ mark-style Japanese Kiểu Nhật Stun Pulse Choáng Stealth Fade Ẩn thân Shield Buff Khiên + Textures Kết cấu + Fine Grain Hạt mịn + Canvas Weave Vải canvas + Burlap Sack Bao bố + Cracked Glaze Men rạn + Mosaic Floor Sàn khảm + Quilted Squares Ô chần bông + Leaded Glass Kính chì + Damp Paper Giấy ẩm Shape Hình dạng Shape Builder Tạo hình dạng Shape Builder Regions Vùng Tạo hình dạng diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 0eb7c7ebe5..31ca3270f8 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3033,6 +3033,15 @@ digits Malayalam 马拉雅拉姆数字 Stun Pulse 眩晕 Stealth Fade 隐身 Shield Buff 护盾 + Textures 纹理 + Fine Grain 细颗粒 + Canvas Weave 帆布纹 + Burlap Sack 麻布袋 + Cracked Glaze 裂纹釉 + Mosaic Floor 马赛克地面 + Quilted Squares 绗缝方格 + Leaded Glass 铅条玻璃 + Damp Paper 湿纸 Shape 形状 Shape Builder 形状生成器 Shape Builder Regions 形状生成器区域 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 084a7e7708..8225e42d52 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -4145,6 +4145,15 @@ digits Malayalam 馬來亞拉姆數字 Stun Pulse 暈眩 Stealth Fade 隱身 Shield Buff 護盾 + Textures 紋理 + Fine Grain 細顆粒 + Canvas Weave 帆布紋 + Burlap Sack 麻布袋 + Cracked Glaze 裂紋釉 + Mosaic Floor 馬賽克地面 + Quilted Squares 絎縫方格 + Leaded Glass 鉛條玻璃 + Damp Paper 濕紙 Shape 形狀 Shape Builder 形狀建立 Shape Builder Regions 形狀建立區域 diff --git a/docs/gaps.md b/docs/gaps.md index b722ace84c..ad33f7e126 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -645,11 +645,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 - 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 + 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 Textures with Fine Grain, + Canvas Weave, Burlap Sack, Cracked Glaze, Mosaic Floor, Quilted Squares, Leaded Glass and Damp Paper, painted with + the texture effects; 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 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 5826c5c8c4..6889fe48ff 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3174,7 +3174,10 @@ 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, Textures, Gradient Finishes, Shape Effects, Blends and Transparency, Game HUD, Status Effects). Textures +(`textures`) paints its fills and strokes with the texture effects: Fine Grain (`texture.grain`), Canvas Weave and Burlap +Sack (`texture.texturizer`), Cracked Glaze (`texture.craquelure`), Mosaic Floor (`texture.mosaicTiles`), Quilted Squares +(`texture.patchwork`), Leaded Glass (`texture.stainedGlass`) and Damp Paper (`sketch.waterPaper`). `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 diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b4c949f742..6211083728 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1331,7 +1331,8 @@ 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 Textures) save and load as `.vcstyles`. + The built-in Textures graphic style library (`graphicStyle.addFromLibrary {library: "textures"}`) has eight styles that paint their fills and strokes with the texture effects: Fine Grain (Grain), Canvas Weave and Burlap Sack (Texturizer), Cracked Glaze (Craquelure), Mosaic Floor (Mosaic Tiles), Quilted Squares (Patchwork), Leaded Glass (Stained Glass) and Damp Paper (Water Paper). The help page names new graphic styles for textures ([What's new](https://helpx.adobe.com/illustrator/desktop/new-features/whats-new.html)) without naming them, so the styles, their names and their settings are VectorCraft's own (`tests::stylelib::textures_styles_apply_their_texture_effects_and_keep_their_params_through_save_and_open`). In the Graphic Styles panel, Shift-click selects a range of styles and Ctrl/Cmd-click adds or removes one. - **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). - **Load text styles from another document:** the Character Styles and Paragraph Styles panel menus have Load Character Styles… or Load Paragraph Styles… and Load All Styles…, which copy the styles of a document picked in an open dialog, with the swatches and patterns they paint with, in one undo step (`charStyle.load`, `paraStyle.load`, `textStyle.loadAll`); a style the document already has, under its name or a numbered one, is not added again, any other whose name is taken gets a number, and the file's Normal styles are not loaded. Public reference: [Character and paragraph styles](https://web.archive.org/web/20261015081534/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). From 2a5f8d8d5dbf7fb7dbf6bfff2cd96ef619022e92 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 18:31:51 -0800 Subject: [PATCH 04/15] Selection: a click on a drop shadow or glow selects the object under it Clicks, the hover pointer and marquees with the selection tools already leave out the area that raster effects paint beyond an object. New tests in crates/engine/src/tests/select_effects.rs hold that in place for drop shadows, Outer Glow and Gaussian Blur on paths, groups, point type, symbol art and images, for Shift-click, Direct Selection, the hover pointer, a drag from a selected object's shadow and a marquee. hit_leaf gets a doc comment, and docs/target-app-parity.md gets a line citing https://helpx.adobe.com/illustrator/using/selecting-objects.html. --- crates/doc/src/hit.rs | 4 + crates/engine/src/tests.rs | 1 + crates/engine/src/tests/select_effects.rs | 236 ++++++++++++++++++++++ docs/target-app-parity.md | 1 + 4 files changed, 242 insertions(+) create mode 100644 crates/engine/src/tests/select_effects.rs diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index 94fbb5ff52..6eae9c1488 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -795,6 +795,10 @@ thread_local! { static LEAVES_TESTED: std::cell::Cell = const { std::cell::Cell::new(0) }; } +/// What a click at `p` hits on leaf object `n`: its outline, strokes and fills, its frame or its +/// bounds, by kind. Raster effects (drop shadows, glows, blurs) do not take clicks: the area they +/// paint beyond the object is left out here and in [`cull_reach`], so a click there reaches the +/// object under it. fn hit_leaf(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Option { #[cfg(test)] LEAVES_TESTED.with(|c| c.set(c.get() + 1)); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ca0501c8f7..db2d3beb37 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -159,6 +159,7 @@ mod script_include; mod script_io; mod script_run; mod scripts; +mod select_effects; mod select_ids_first; mod shaper; mod shapetools; diff --git a/crates/engine/src/tests/select_effects.rs b/crates/engine/src/tests/select_effects.rs new file mode 100644 index 0000000000..b30d0ef70e --- /dev/null +++ b/crates/engine/src/tests/select_effects.rs @@ -0,0 +1,236 @@ +//! Clicks, hovers and marquees with the selection tools leave out the area that raster effects +//! (drop shadows, glows, blurs) paint beyond an object: there they reach the object under the +//! effect, or nothing. + +use serde_json::{Value, json}; +use vectorcraft_doc::NodeId; +use vectorcraft_geom::{Point, Rect}; +use vectorcraft_tools::{Cursor, Mods, PointerEvent, PointerKind}; + +use crate::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + s +} + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}")) +} + +fn id_of(v: &Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +/// A rectangle filled with `color`. +fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64, color: &str) -> NodeId { + let id = id_of(&run(s, "shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h}))); + run(s, "select.set", json!({"ids": [id.0]})); + run(s, "paint.setFill", json!({"color": color})); + id +} + +/// A drop shadow on the whole of `id`, `dx` to the right, with no blur. +fn shadow(s: &mut Session, id: NodeId, dx: f64) { + run(s, "effect.apply", json!({"effect": "stylize.dropShadow", "ids": [id.0], "params": {"x": dx, "y": 0, "blur": 0}, "item": null})); +} + +fn pixel(s: &Session, x: u32, y: u32) -> [u8; 4] { + let doc = s.doc().unwrap().doc.clone(); + let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + vectorcraft_render::Renderer::new().render(&doc, 200, 200, vectorcraft_geom::Affine::IDENTITY, &opts).pixel(x, y) +} + +fn press(s: &mut Session, tool: &str, from: (f64, f64), to: (f64, f64), mods: Mods) -> Vec { + let v = ViewInfo::default(); + s.select_tool(tool, v).unwrap(); + for (k, (x, y)) in [(PointerKind::Down, from), (PointerKind::Drag, to), (PointerKind::Up, to)] { + if k == PointerKind::Drag && from == to { + continue; + } + s.pointer(&PointerEvent::new(k, x, y).with_mods(mods), v).unwrap(); + } + s.active().unwrap().selection.objects.clone() +} + +/// Click with the Selection tool at (x, y) on a 100% view: what is selected afterwards. +fn click(s: &mut Session, x: f64, y: f64) -> Vec { + press(s, "selection", (x, y), (x, y), Mods::default()) +} + +fn bounds(s: &Session, id: NodeId) -> Rect { + s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap() +} + +const WHITE: [u8; 4] = [255; 4]; +const BLUE: [u8; 4] = [0, 0, 255, 255]; + +/// Blue B at x 100 to 160, red A over its left edge's side at x 20 to 80, and A's shadow +/// 40 pt to the right, from x 60 to 120: over the canvas from 80 to 100 and over B from 100 to 120. +fn shadow_over_b(s: &mut Session) -> (NodeId, NodeId) { + let b = rect(s, 100.0, 20.0, 60.0, 60.0, "#0000ff"); + let a = rect(s, 20.0, 20.0, 60.0, 60.0, "#ff0000"); + shadow(s, a, 40.0); + (a, b) +} + +#[test] +fn a_click_on_a_drop_shadow_selects_the_object_under_it() { + let mut s = session(); + let (a, b) = shadow_over_b(&mut s); + // The shadow paints over B and over the canvas. + assert_ne!(pixel(&s, 110, 50), BLUE); + assert_ne!(pixel(&s, 90, 50), WHITE); + run(&mut s, "select.none", json!({})); + assert_eq!(click(&mut s, 110.0, 50.0), [b]); + assert_eq!(click(&mut s, 50.0, 50.0), [a]); +} + +#[test] +fn a_click_on_a_drop_shadow_over_the_canvas_selects_nothing_and_clears_the_selection() { + let mut s = session(); + let (a, _) = shadow_over_b(&mut s); + run(&mut s, "select.set", json!({"ids": [a.0]})); + assert!(click(&mut s, 90.0, 50.0).is_empty()); +} + +#[test] +fn a_click_on_an_outer_glow_or_a_gaussian_blur_outside_the_path_selects_what_is_under_it() { + let mut s = session(); + let a = rect(&mut s, 20.0, 20.0, 60.0, 60.0, "#ff0000"); + run(&mut s, "effect.apply", json!({"effect": "stylize.outerGlow", "ids": [a.0], "params": {"blur": 20, "mode": "normal"}, "item": null})); + assert_ne!(pixel(&s, 88, 50), WHITE); + run(&mut s, "select.none", json!({})); + assert!(click(&mut s, 88.0, 50.0).is_empty()); + // A red square whose blur reaches over the canvas and over the left edge of a blue one. + let mut s = session(); + let b = rect(&mut s, 92.0, 20.0, 60.0, 60.0, "#0000ff"); + let a = rect(&mut s, 20.0, 20.0, 60.0, 60.0, "#ff0000"); + run(&mut s, "effect.apply", json!({"effect": "blur.gaussian", "ids": [a.0], "params": {"radius": 20}, "item": null})); + assert_ne!(pixel(&s, 98, 50), BLUE); + assert_ne!(pixel(&s, 87, 50), WHITE); + run(&mut s, "select.none", json!({})); + assert_eq!(click(&mut s, 98.0, 50.0), [b]); + // (With B deselected, so that the press does not take its bounding box handle.) + run(&mut s, "select.none", json!({})); + assert!(click(&mut s, 87.0, 50.0).is_empty()); +} + +/// The first point of `area` (x0, y0, x1, y1) that the canvas paints, scanning rows. +fn painted_in(s: &Session, (x0, y0, x1, y1): (u32, u32, u32, u32)) -> Option<(f64, f64)> { + let doc = s.doc().unwrap().doc.clone(); + let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let img = vectorcraft_render::Renderer::new().render(&doc, 200, 200, vectorcraft_geom::Affine::IDENTITY, &opts); + (y0..y1) + .flat_map(|y| (x0..x1).map(move |x| (x, y))) + .find(|&(x, y)| img.pixel(x, y) != WHITE) + .map(|(x, y)| (f64::from(x) + 0.5, f64::from(y) + 0.5)) +} + +#[test] +fn a_click_on_the_drop_shadow_of_a_group_or_of_point_type_selects_neither() { + let mut s = session(); + let a = rect(&mut s, 20.0, 20.0, 30.0, 30.0, "#ff0000"); + let b = rect(&mut s, 60.0, 20.0, 30.0, 30.0, "#ff0000"); + run(&mut s, "select.set", json!({"ids": [a.0, b.0]})); + let g = id_of(&run(&mut s, "object.group", json!({}))); + shadow(&mut s, g, 80.0); + // The group's shadow lies from x 100 to 170. + assert_ne!(pixel(&s, 110, 35), WHITE); + run(&mut s, "select.none", json!({})); + assert!(click(&mut s, 110.0, 35.0).is_empty()); + assert_eq!(click(&mut s, 35.0, 35.0), [g]); + // Type 40 pt high at y 150 with its shadow 90 pt to the right. + let t = id_of(&run(&mut s, "text.create", json!({"x": 20, "y": 150, "text": "HH", "size": 40}))); + shadow(&mut s, t, 90.0); + let right = bounds(&s, t).x1; + assert!(right < 100.0, "{right}"); + let at = painted_in(&s, (right.ceil() as u32 + 10, 110, 199, 160)).expect("the type's shadow paints"); + run(&mut s, "select.none", json!({})); + assert!(click(&mut s, at.0, at.1).is_empty(), "{at:?}"); +} + +#[test] +fn a_click_on_the_drop_shadow_of_a_symbols_art_does_not_select_the_instance() { + let mut s = session(); + let a = rect(&mut s, 20.0, 20.0, 60.0, 60.0, "#ff0000"); + shadow(&mut s, a, 40.0); + run(&mut s, "select.set", json!({"ids": [a.0]})); + let inst = id_of(&run(&mut s, "symbol.new", json!({"name": "Shadowed"}))); + assert_ne!(pixel(&s, 100, 50), WHITE); + run(&mut s, "select.none", json!({})); + assert!(click(&mut s, 100.0, 50.0).is_empty()); + assert_eq!(click(&mut s, 50.0, 50.0), [inst]); +} + +#[test] +fn a_click_on_the_drop_shadow_of_an_image_outside_its_frame_does_not_select_it() { + let mut s = session(); + let mut png = vec![]; + image::RgbaImage::from_pixel(30, 30, image::Rgba([255, 0, 0, 255])) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .unwrap(); + let r = run(&mut s, "file.place", json!({"name": "red.png", "dataBase64": vectorcraft_format::base64_encode(&png), "at": [65, 65]})); + let im = NodeId(r["ids"][0].as_u64().unwrap()); + // The frame lies from x 50 to 80 and the shadow from 90 to 120. + shadow(&mut s, im, 40.0); + assert_ne!(pixel(&s, 105, 65), WHITE); + run(&mut s, "select.none", json!({})); + assert!(click(&mut s, 105.0, 65.0).is_empty()); + assert_eq!(click(&mut s, 65.0, 65.0), [im]); +} + +#[test] +fn shift_click_direct_selection_and_hover_on_a_drop_shadow_pass_through_it() { + let mut s = session(); + let (a, b) = shadow_over_b(&mut s); + // Shift-click on the shadow over B adds B and keeps A. + run(&mut s, "select.set", json!({"ids": [a.0]})); + let shift = Mods { shift: true, ..Mods::default() }; + let mut sel = press(&mut s, "selection", (110.0, 50.0), (110.0, 50.0), shift); + sel.sort_by_key(|n| n.0); + let mut both = vec![a, b]; + both.sort_by_key(|n| n.0); + assert_eq!(sel, both); + // Direct Selection takes B through the shadow, and nothing over the canvas. + run(&mut s, "select.none", json!({})); + let sel = press(&mut s, "directSelection", (110.0, 50.0), (110.0, 50.0), Mods::default()); + assert!(sel.contains(&b) && !sel.contains(&a), "{sel:?}"); + run(&mut s, "select.none", json!({})); + assert!(press(&mut s, "directSelection", (90.0, 50.0), (90.0, 50.0), Mods::default()).is_empty()); + // With A selected, the pointer over its shadow is the plain arrow, and over A the move pointer. + run(&mut s, "select.set", json!({"ids": [a.0]})); + let v = ViewInfo::default(); + s.select_tool("selection", v).unwrap(); + assert_eq!(s.cursor(Point::new(50.0, 50.0), Mods::default(), v), Cursor::Move); + assert_eq!(s.cursor(Point::new(110.0, 50.0), Mods::default(), v), Cursor::Arrow); + assert_eq!(s.cursor(Point::new(92.0, 50.0), Mods::default(), v), Cursor::Arrow); +} + +#[test] +fn a_drag_from_the_drop_shadow_of_a_selected_object_does_not_move_it() { + let mut s = session(); + let (a, b) = shadow_over_b(&mut s); + run(&mut s, "select.set", json!({"ids": [a.0]})); + let before = bounds(&s, a); + // From the shadow over the canvas: a marquee that touches nothing. + assert!(press(&mut s, "selection", (92.0, 50.0), (96.0, 60.0), Mods::default()).is_empty()); + assert_eq!(bounds(&s, a), before); + // From the shadow over B: B moves, A stays. + run(&mut s, "select.set", json!({"ids": [a.0]})); + let b_before = bounds(&s, b); + assert_eq!(press(&mut s, "selection", (110.0, 50.0), (110.0, 60.0), Mods::default()), [b]); + assert_eq!(bounds(&s, a), before); + assert!((bounds(&s, b).y0 - b_before.y0 - 10.0).abs() < 1e-6, "{:?}", bounds(&s, b)); +} + +#[test] +fn a_marquee_that_touches_only_a_drop_shadow_does_not_select_its_object() { + let mut s = session(); + let (_, b) = shadow_over_b(&mut s); + run(&mut s, "select.none", json!({})); + assert!(press(&mut s, "selection", (85.0, 40.0), (95.0, 60.0), Mods::default()).is_empty()); + // From above B, over B and the shadow: B only. + assert_eq!(press(&mut s, "selection", (105.0, 5.0), (115.0, 30.0), Mods::default()), [b]); +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b4c949f742..57dff6da93 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -595,6 +595,7 @@ since 2026-10-01. - **Copy and Cut of anchors picked with a lent Direct Selection (#6123):** with the Pen or the Curvature tool, Cmd/Ctrl held lends Direct Selection for the gesture; the anchors and segments it picks are what Edit › Copy and Cut take once the key is let go, as with Direct Selection chosen, while the last anchor the Pen leaves selected as it draws still makes Copy take the whole path (#2122). A Cmd gesture that leaves the selection as it was (a Shift marquee over nothing) picks nothing. Regression test: `tests::draw2::pen_cmd_selected_anchors_are_what_copy_and_cut_take`. - **Cut of picked anchors (#6361):** Edit › Cut removes direct-selected anchors from their path and from a compound path's member, with the segments attached to them, as Clear does. Picked segments still lose only those segments. When a path also has a picked segment, Cut removes and copies only its picked segments and ignores its picked anchors, on every contour, as Clear does; an anchor left without a surviving segment is removed with them. Regression tests: `cut_*_anchors_*` and `cut_of_an_anchor_with_a_segment_on_another_contour_leaves_the_source_as_clear_does` in `crates/engine/tests/it/copy_direct_selection.rs`. - **Escape in Selection tool drags:** Escape during a Selection tool move or Alt-drag copy, a bounding-box scale or rotation, a marquee, or a corner widget, pie widget or bracket drag cancels it: the art returns to its state at the press, no copy is left, no undo step is added, the release does nothing, and the tool stays chosen. This is VectorCraft's own behavior. Regression tests: `escape_cancels_*` in `select/tests.rs` and `pie.rs`, and `escape_cancels_a_selection_tool_move_copy_scale_or_rotation` in `tests/selectescape.rs`. +- **Clicks pass through raster effects:** clicks with the Selection and Direct Selection tools, and the Selection tool's pointer and marquee, leave out the area that a drop shadow, a glow, a blur or another raster effect paints beyond an object. A click on a shadow selects the object under it, or nothing over an empty canvas, and a press on the shadow of a selected object does not move it ([the help page](https://helpx.adobe.com/illustrator/using/selecting-objects.html)). No setting changes this. This covers paths, groups, type, symbol instances and images. Regression tests: `crates/engine/src/tests/select_effects.rs`. - **Global Edit: Shift-click leaves a match out:** while Global Edit is on, a Shift-click with the Selection tool on a highlighted match, or on an object inside a matched group, leaves that match out (`select.globalEditExclude`): it is no longer highlighted and later edits leave it as it is; the object the edit started from stays, and Global Edit ends when fewer than two matches are left ([Update assets quickly with Global Edit](https://helpx.adobe.com/ph_fil/illustrator/how-to/global-editing-sensei.html)). Regression tests: `a_shift_click_*`, `an_object_left_out_*`, `a_group_left_out_*` and `leaving_out_all_but_one_*` in `crates/engine/src/tests/globaledit.rs`. - **Global Edit Options in the Control bar:** while Select › Start Global Edit can run, the Control bar shows a Global Edit Options dropdown that opens the same options as the Properties panel's (Match: Appearance, Size; On Artboards; Include Objects On Canvas; Result(s)); both set the same options. The dropdown is off while Global Edit is on; it sits after Recolor Artwork, ahead of the X/Y/W/H fields ([Edit similar objects globally](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-similar-objects.html)). Regression test: `the_control_bar_shows_the_global_edit_options` in `crates/ui-egui/src/chrome/tests.rs`. - **Global Edit's enabled check on large paths:** the check that decides whether Select › Start Global Edit can run on the selected path, compound path or symbol instance (which the Control bar makes every frame) measures the box of its anchors and handles instead of building and normalizing a copy of every anchor, with the same answer. On a 200,000-anchor path the check takes 0.7 ms instead of 2.2 to 3.0 ms. Regression tests: `support_*` in `crates/engine/src/cmd/globaledit.rs`. From 4bc50fa21f93d67f30822db796a18313171c5d82 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 17:11:54 -0800 Subject: [PATCH 05/15] Type: Dynamic Text Presets put type on a circle, arch or bow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Type › Dynamic Text Presets › Circle, Arch and Bow (`type.dynamicTextPreset {preset, position?, flip?, ids?}`) put the selected point type and type on a path on a new path of that shape and turn Dynamic Text on, so the text is sized to fill it. - Circle is a circle as wide as the text, the text running clockwise from its bottom; Arch and Bow are a 120° arc whose chord is as wide as the text, bulging up or down. The shape is upright on the page when the object is rotated or scaled. - `position` above, center or below sets Align to Path to Descender, Center or Ascender; `flip` turns the text to the other side of its path. Effect and Spacing stay. - Area type is left as it is, threaded type on a path is refused, and a preset that changes nothing records no undo step. Docs: docs/mcp.md, docs/target-app-parity.md. Part of #4134. --- crates/engine/src/cmd/typemenu.rs | 199 ++++++++++++++++++++++++ crates/engine/src/tests/dynamic_text.rs | 194 +++++++++++++++++++++++ crates/ui-egui/src/i18n/bg.tsv | 2 + crates/ui-egui/src/i18n/bs.tsv | 2 + crates/ui-egui/src/i18n/ca.tsv | 3 + crates/ui-egui/src/i18n/cs.tsv | 3 + crates/ui-egui/src/i18n/da.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 3 + crates/ui-egui/src/i18n/el.tsv | 3 + crates/ui-egui/src/i18n/es.tsv | 3 + crates/ui-egui/src/i18n/fi.tsv | 3 + crates/ui-egui/src/i18n/fr.tsv | 3 + crates/ui-egui/src/i18n/hi.tsv | 3 + crates/ui-egui/src/i18n/hu.tsv | 3 + crates/ui-egui/src/i18n/id.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 3 + crates/ui-egui/src/i18n/ja.tsv | 3 + crates/ui-egui/src/i18n/ka.tsv | 3 + crates/ui-egui/src/i18n/ko.tsv | 3 + crates/ui-egui/src/i18n/nl.tsv | 2 + crates/ui-egui/src/i18n/pl.tsv | 3 + crates/ui-egui/src/i18n/pt-br.tsv | 3 + crates/ui-egui/src/i18n/ru.tsv | 3 + crates/ui-egui/src/i18n/sv.tsv | 3 + crates/ui-egui/src/i18n/tr.tsv | 3 + crates/ui-egui/src/i18n/uk.tsv | 3 + crates/ui-egui/src/i18n/vi.tsv | 3 + crates/ui-egui/src/i18n/zh-hans.tsv | 3 + crates/ui-egui/src/i18n/zh-hant.tsv | 3 + crates/ui-egui/src/menus/tests.rs | 35 +++++ crates/ui-egui/src/menus/tree.rs | 8 + docs/mcp.md | 27 +++- docs/target-app-parity.md | 1 + 33 files changed, 539 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/typemenu.rs b/crates/engine/src/cmd/typemenu.rs index 03681c556a..faaaf2145d 100644 --- a/crates/engine/src/cmd/typemenu.rs +++ b/crates/engine/src/cmd/typemenu.rs @@ -69,6 +69,15 @@ pub fn specs() -> Vec { has_dynamic_type, dynamic_text ), + cmd!( + "type.dynamicTextPreset", + "Dynamic Text Presets", + ["Type", "Dynamic Text Presets"], + None, + "{preset: \"circle\"|\"arch\"|\"bow\", position?: \"above\"|\"center\"|\"below\", flip?: bool, ids?|id?} put the selected point type and type on a path on a new path of the preset's shape with Dynamic Text on, so the text is sized to fill it (see type.dynamicText). Circle: a circle as wide as the text, the text running clockwise round all of it from its bottom; Arch: an arc of a circle bulging upward over 120°, as wide as the text; Bow: the same arc bulging downward. The width is, for point type, the length on the page of the longer side of the text's bounds, and for type on a path the longer side of its path's bounds on the page (so the same preset again leaves it as it is); the new path is centered where they were, and the shape is upright on the page when the object is rotated or scaled. position (omitted: above) sets Type on a Path Options › Align to Path: above stands the type on the path (descender), center centers it on the path (center), below hangs it from the path (ascender); flip true then turns the text to the other side of its path, running the other way. Type on a Path Options › effect and spacing stay. Area type in the selection is left as it is; a selection holding type on a path in a thread is refused. When nothing changes, no undo step is recorded → {ids, fitScale (the first object's factor)}", + has_preset_type, + dynamic_text_preset + ), cmd!( "type.fillPlaceholder", "Fill with Placeholder Text", @@ -648,6 +657,196 @@ pub(crate) fn bake_dynamic_text(t: &mut TextObject) -> bool { true } +/// The shapes of `type.dynamicTextPreset`. +const PRESETS: [&str; 3] = ["circle", "arch", "bow"]; + +/// Point type and type on a path among `ids` and their descendants: the type +/// `type.dynamicTextPreset` puts on a path. +fn preset_ids(d: &Document, ids: &[NodeId]) -> Vec { + text_ids(d, ids) + .into_iter() + .filter(|id| matches!(d.node(*id).map(|n| &n.kind), Some(NodeKind::Text(t)) if matches!(t.kind, TextKind::Point | TextKind::OnPath { .. }))) + .collect() +} + +/// `type.dynamicTextPreset` is enabled for a selection holding point type or type on a path, and +/// no type on a path in a thread. +fn has_preset_type(s: &Session) -> std::result::Result<(), String> { + has_selection(s)?; + let st = s.active().ok_or("no document open")?; + let d = &st.doc; + let ids = preset_ids(d, &st.selection.objects); + if ids.is_empty() { + return Err("select point type or type on a path".into()); + } + let frames = threaded_ids(d, &ids); + if !frames.is_empty() { + return Err(format!("type on a path in a thread cannot use Dynamic Text: {frames}")); + } + Ok(()) +} + +/// The path of Dynamic Text preset `preset` (one of [`PRESETS`]) `w` wide, centered on `c`, with +/// the start bracket the text starts at. Circle: a circle `w` across, run clockwise from its +/// bottom; Arch and Bow: a 120° arc of a circle whose chord is `w`, bulging up or down, run from +/// left to right. +fn preset_path(preset: &str, c: vectorcraft_geom::Point, w: f64) -> (vectorcraft_geom::PathData, f64) { + let h = w / 2.0; + if preset == "circle" { + // `shapes::ellipse` runs clockwise on screen from its left: its bottom is 3/4 of the way. + return (shapes::ellipse(Rect::new(c.x - h, c.y - h, c.x + h, c.y + h)), 0.75); + } + // A chord of `w` under 120°: radius w/√3, and the arc rises a half radius above its chord. + let r = w / 3f64.sqrt(); + let arch = preset == "arch"; + let cy = if arch { c.y - r / 4.0 + r } else { c.y + r / 4.0 - r }; + let circle = Rect::new(c.x - r, cy - r, c.x + r, cy + r); + // `shapes::ellipse_arc` runs counterclockwise on screen: right to left over the top. + let mut path = if arch { + shapes::ellipse_arc(circle, 30.0, 150.0, shapes::EllipseMode::Arc) + } else { + shapes::ellipse_arc(circle, 210.0, 330.0, shapes::EllipseMode::Arc) + }; + if arch { + path.reverse(); + } + (path, 0.0) +} + +/// `t` (point type or type on a path) on the path of `preset` with Dynamic Text on, Align to Path +/// `align`, and flipped when `flip`. The shape is made upright on the page and mapped back through +/// the object's transform, so a rotated or scaled object still shows a circle, an arch or a bow. +/// Point type: the path is as wide as the longer side of the text's layout bounds is long on the +/// page, centered on them; with no layout area (empty text), four ems of its first character +/// wide, centered on its estimated bounds. Type on a path: as wide as the longer side of its +/// path's bounds on the page, centered on them, so the same preset again gives the same path. +/// When the object's transform can't be undone, or undoing it gives a path that isn't finite, the +/// shape is made in the text's own space. +fn with_preset(t: &TextObject, preset: &str, align: vectorcraft_doc::PathAlign, flip: bool) -> TextObject { + let invertible = t.xf.is_finite() && t.xf.determinant() != 0.0 && t.xf.inverse().is_finite(); + // The transform the shape was made upright through, with the shape. + let ((path, start, w), xf) = invertible + .then(|| preset_shape(t, preset, t.xf).map(|s| (s, t.xf))) + .flatten() + .or_else(|| preset_shape(t, preset, Affine::IDENTITY).map(|s| (s, Affine::IDENTITY))) + .unwrap_or_else(|| { + let (path, start) = preset_path(preset, vectorcraft_geom::Point::ZERO, 48.0); + ((path, start, 48.0), Affine::IDENTITY) + }); + let mut out = t.clone(); + out.kind = TextKind::OnPath { path, start, end: None }; + out.path_align = align; + out.dynamic_text = true; + if flip { + out.flip_on_path(); + } + // The same preset again measures the path it made, which can differ in the last bits: a path + // within a billionth of its width of the old one on the page keeps the old one, so nothing + // changes. + if let (TextKind::OnPath { path: old, .. }, TextKind::OnPath { path: new, .. }) = (&t.kind, &mut out.kind) + && same_path(&old.transformed(xf), &new.transformed(xf), w * 1e-9) + { + *new = old.clone(); + } + refresh_bounds(&mut out); + out +} + +/// The path of `preset` for `t` made upright through transform `xf` (see [`with_preset`]), in the +/// text's space, with its start bracket and its width on the page; None when the path isn't +/// finite. +fn preset_shape(t: &TextObject, preset: &str, xf: Affine) -> Option<(vectorcraft_geom::PathData, f64, f64)> { + // How long `(x, y)` in the text's space is on the page. + let on_page = |x: f64, y: f64| (xf * vectorcraft_geom::Point::new(x, y) - xf * vectorcraft_geom::Point::ZERO).hypot(); + // The center on the page, and the width measured (0 when nothing can be measured). + let (c, longer) = match &t.kind { + TextKind::OnPath { path, .. } => path + .transformed(xf) + .bounds() + .filter(|b| b.is_finite()) + .map_or((xf * vectorcraft_geom::Point::ZERO, 0.0), |b| (b.center(), b.width().max(b.height()))), + _ => { + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).bounds; + match Some(lay).filter(|b| b.area() > 0.0 && b.is_finite()) { + Some(b) => (xf * b.center(), on_page(b.width(), 0.0).max(on_page(0.0, b.height()))), + None => (xf * Some(t.estimate_bounds()).filter(|b| b.is_finite()).map_or(vectorcraft_geom::Point::ZERO, |b| b.center()), 0.0), + } + } + }; + // Without a measured width: four ems of the first character, else 48 pt. + let size = t.first_style().size; + let w = if longer.is_finite() && longer > 1e-6 { + longer + } else if size.is_finite() && size > 0.0 && on_page(size * 4.0, 0.0) > 1e-6 { + on_page(size * 4.0, 0.0) + } else { + 48.0 + }; + let (path, start) = preset_path(preset, c, w); + let path = path.transformed(xf.inverse()); + let finite = path.subpaths.iter().flat_map(|s| &s.anchors).all(|a| a.p.is_finite() && a.h_in.is_finite() && a.h_out.is_finite()); + (finite && w.is_finite()).then_some((path, start, w)) +} + +/// Whether paths `a` and `b` have the same subpaths, closed alike, with every anchor and handle +/// within `eps` of each other. +fn same_path(a: &vectorcraft_geom::PathData, b: &vectorcraft_geom::PathData, eps: f64) -> bool { + let near = |p: vectorcraft_geom::Point, q: vectorcraft_geom::Point| (p - q).hypot() <= eps; + a.subpaths.len() == b.subpaths.len() + && a.subpaths.iter().zip(&b.subpaths).all(|(s, t)| { + s.closed == t.closed + && s.anchors.len() == t.anchors.len() + && s.anchors.iter().zip(&t.anchors).all(|(x, y)| x.kind == y.kind && near(x.p, y.p) && near(x.h_in, y.h_in) && near(x.h_out, y.h_out)) + }) +} + +/// Type › Dynamic Text Presets: put the selected point type and type on a path on a new path of +/// the preset's shape with Dynamic Text on, in one undo step, and none when nothing changes. +fn dynamic_text_preset(s: &mut Session, p: &Value) -> Result { + const C: &str = "type.dynamicTextPreset"; + let key = "preset"; + let preset = choice(p, C, key, &PRESETS)?.ok_or_else(|| bad(C, format!("`{key}` must be one of {}", PRESETS.join(", "))))?; + let align = match choice(p, C, "position", &["above", "center", "below"])? { + Some("center") => vectorcraft_doc::PathAlign::Center, + Some("below") => vectorcraft_doc::PathAlign::Ascender, + _ => vectorcraft_doc::PathAlign::Descender, + }; + let flip = switch_or(p, "flip", false, C)?; + let (ids, changed) = { + let d = &s.doc()?.doc; + let ids = preset_ids(d, &targets(s, p)?); + if ids.is_empty() { + return Err(bad(C, "select point type or type on a path")); + } + let frames = threaded_ids(d, &ids); + if !frames.is_empty() { + return Err(bad(C, format!("type on a path in a thread cannot use Dynamic Text: {frames}"))); + } + let changed: Vec<(NodeId, TextObject)> = ids + .iter() + .filter_map(|id| match d.node(*id).map(|n| &n.kind) { + Some(NodeKind::Text(t)) => Some((*id, with_preset(t, preset, align, flip))).filter(|(_, new)| new != &**t), + _ => None, + }) + .collect(); + (ids, changed) + }; + if !changed.is_empty() { + s.edit("Dynamic Text", |d, _| { + for (id, new) in &changed { + if let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) { + **t = new.clone(); + } + } + Ok(()) + })?; + } + let d = &s.doc()?.doc; + let fit = + ids.first().and_then(|id| path_text(d.node(*id))).map_or(1.0, |t| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).fit_scale); + Ok(json!({"ids": ids.iter().map(|i| i.0).collect::>(), "fitScale": fit})) +} + // ---------- placeholder / insert ---------- /// VectorCraft's own placeholder copy (not the classical Latin text). diff --git a/crates/engine/src/tests/dynamic_text.rs b/crates/engine/src/tests/dynamic_text.rs index 3512db9c59..ae1a72fa08 100644 --- a/crates/engine/src/tests/dynamic_text.rs +++ b/crates/engine/src/tests/dynamic_text.rs @@ -234,6 +234,200 @@ fn svg_export_writes_the_drawn_size() { assert!(!sizes.iter().any(|v| (v - 12.0).abs() < 1e-3), "the stored size is not written: {sizes:?}"); } +/// 12 pt point type at (100, 100), selected, with its layout bounds. +fn point(s: &mut Session, content: &str) -> (NodeId, vectorcraft_geom::Rect) { + let r = s.execute("text.create", &json!({"x": 100, "y": 100, "text": content, "size": 12})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + let b = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(s, id)).bounds; + (id, b) +} + +/// Type on a path's path, start bracket and end bracket. +fn on_path(t: &TextObject) -> (vectorcraft_geom::PathData, f64, Option) { + match &t.kind { + vectorcraft_doc::TextKind::OnPath { path, start, end } => (path.clone(), *start, *end), + k => panic!("not type on a path: {k:?}"), + } +} + +fn close(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-6 +} + +#[test] +fn presets_put_point_type_on_a_fitted_circle_arch_or_bow() { + let mut s = session(); + let (id, b) = point(&mut s, "Round and round"); + let w = b.width().max(b.height()); + let before = undo_len(&s); + let r = s.execute("type.dynamicTextPreset", &json!({"preset": "circle"})).unwrap(); + assert_eq!(r["ids"], json!([id.0])); + assert_eq!(undo_len(&s), before + 1); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Dynamic Text"); + let t = text(&s, id); + assert!(t.dynamic_text); + assert_eq!(t.path_align, vectorcraft_doc::PathAlign::Descender, "above by default"); + let (path, start, end) = on_path(&t); + assert!(path.is_closed()); + assert_eq!((start, end), (0.75, None)); + let pb = path.bounds().unwrap(); + assert!(close(pb.width(), w) && close(pb.height(), w), "{pb:?} vs {w}"); + assert!(close(pb.center().x, b.center().x) && close(pb.center().y, b.center().y), "{pb:?} vs {b:?}"); + // The start bracket, 3/4 of the way round, is the circle's bottom. + let bottom = path.subpaths[0].anchors[3].p; + assert!(close(bottom.y, pb.y1) && close(bottom.x, pb.center().x), "{bottom:?}"); + // Round a circle as wide as the text, the text grows to fill it and does not overflow. + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); + assert!(lay.fit_scale > 2.0 && !lay.overflow, "{} {}", lay.fit_scale, lay.overflow); + assert_eq!(r["fitScale"], json!(lay.fit_scale)); + assert_eq!(t.cached_bounds, Some(lay.bounds)); + // Undo gives the point type back. + s.execute("edit.undo", &json!({})).unwrap(); + assert!(matches!(text(&s, id).kind, vectorcraft_doc::TextKind::Point) && !text(&s, id).dynamic_text); + + for (preset, up) in [("arch", true), ("bow", false)] { + s.execute("type.dynamicTextPreset", &json!({"preset": preset, "ids": [id.0]})).unwrap(); + let t = text(&s, id); + let (path, start, end) = on_path(&t); + assert!(!path.is_closed() && t.dynamic_text); + assert_eq!((start, end), (0.0, None)); + let a = &path.subpaths[0].anchors; + let (first, middle, last) = (a[0].p, a[1].p, a[a.len() - 1].p); + // Left to right, the chord as wide as the text, the arc rising (or falling) half its radius. + assert!(first.x < last.x && close(last.x - first.x, w) && close(first.y, last.y), "{preset}: {first:?} {last:?}"); + let pb = path.bounds().unwrap(); + assert!(close(pb.height(), w / 3f64.sqrt() / 2.0), "{preset}: {pb:?}"); + assert!(close(pb.center().y, b.center().y), "{preset}: {pb:?} vs {b:?}"); + assert_eq!(middle.y < first.y, up, "{preset}: {middle:?} {first:?}"); + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); + assert!(lay.fit_scale > 1.0 && !lay.overflow, "{preset}: {}", lay.fit_scale); + s.execute("edit.undo", &json!({})).unwrap(); + } +} + +#[test] +fn preset_position_and_flip_set_align_to_path_and_direction() { + let mut s = session(); + let (id, _) = point(&mut s, "Over the hill"); + for (position, align) in [ + ("above", vectorcraft_doc::PathAlign::Descender), + ("center", vectorcraft_doc::PathAlign::Center), + ("below", vectorcraft_doc::PathAlign::Ascender), + ] { + s.execute("type.dynamicTextPreset", &json!({"preset": "arch", "position": position})).unwrap(); + assert_eq!(text(&s, id).path_align, align, "{position}"); + } + // Flipped, the arch runs right to left and the text stands on its other side. + s.execute("type.dynamicTextPreset", &json!({"preset": "arch", "flip": true})).unwrap(); + let t = text(&s, id); + let (path, start, end) = on_path(&t); + let a = &path.subpaths[0].anchors; + assert!(a[0].p.x > a[a.len() - 1].p.x, "{path:?}"); + assert_eq!((start, end), (0.0, None)); + assert_eq!(t.path_align, vectorcraft_doc::PathAlign::Descender); + assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow); + // A flipped circle runs the other way round from its bottom. + s.execute("type.dynamicTextPreset", &json!({"preset": "circle", "flip": true})).unwrap(); + let (path, start, end) = on_path(&text(&s, id)); + assert_eq!((start, end), (0.25, None)); + let at = path.subpaths[0].anchors[1].p; + assert!(close(at.y, path.bounds().unwrap().y1), "reversed, the bottom is a quarter of the way: {at:?}"); + // The effect and Spacing of Type on a Path Options stay. + s.execute("type.pathOptions", &json!({"effect": "skew", "spacing": 5})).unwrap(); + s.execute("type.dynamicTextPreset", &json!({"preset": "bow"})).unwrap(); + let t = text(&s, id); + assert_eq!((t.path_effect.id(), t.path_spacing), ("skew", 5.0)); +} + +#[test] +fn presets_fit_type_on_a_path_to_its_path_and_record_nothing_when_nothing_changes() { + let mut s = session(); + let id = on_line(&mut s, "Along the line"); + s.execute("type.dynamicTextPreset", &json!({"preset": "bow"})).unwrap(); + let (path, ..) = on_path(&text(&s, id)); + let a = &path.subpaths[0].anchors; + // The bow is as wide as the 400 pt line, centered where it was. + assert!(close(a[0].p.x, 100.0) && close(a[a.len() - 1].p.x, 500.0), "{a:?}"); + assert!(close(path.bounds().unwrap().center().y, 300.0)); + // The same preset again: nothing changes and no undo step is recorded. + let before = undo_len(&s); + let r = s.execute("type.dynamicTextPreset", &json!({"preset": "bow"})).unwrap(); + assert_eq!(r["ids"], json!([id.0])); + assert_eq!(undo_len(&s), before); + // Another position is a change. + s.execute("type.dynamicTextPreset", &json!({"preset": "bow", "position": "below"})).unwrap(); + assert_eq!(undo_len(&s), before + 1); + // The fit follows later edits to the text. + let f = fit(&s, id); + s.execute("text.setText", &json!({"ids": [id.0], "text": "Along the line and on past it"})).unwrap(); + assert!(fit(&s, id) < f * 0.8, "{} vs {f}", fit(&s, id)); + // On a rotated object the arch still bulges up the page, and on a squashed one the circle is + // round on the page. + let (turned, _) = point(&mut s, "Turned"); + s.execute("object.rotate", &json!({"ids": [turned.0], "angle": 45})).unwrap(); + s.execute("type.dynamicTextPreset", &json!({"preset": "arch", "ids": [turned.0]})).unwrap(); + let t = text(&s, turned); + let page = on_path(&t).0.transformed(t.xf); + let a = &page.subpaths[0].anchors; + assert!(close(a[0].p.y, a[2].p.y) && a[1].p.y < a[0].p.y && a[0].p.x < a[2].p.x, "{a:?}"); + // The same preset again leaves the rotated arch as it is. + let before = undo_len(&s); + s.execute("type.dynamicTextPreset", &json!({"preset": "arch", "ids": [turned.0]})).unwrap(); + assert_eq!(undo_len(&s), before); + let (squashed, _) = point(&mut s, "Squashed"); + s.execute("object.scale", &json!({"ids": [squashed.0], "sx": 200, "sy": 50})).unwrap(); + s.execute("type.dynamicTextPreset", &json!({"preset": "circle", "ids": [squashed.0]})).unwrap(); + let t = text(&s, squashed); + let pb = on_path(&t).0.transformed(t.xf).bounds().unwrap(); + assert!(close(pb.width(), pb.height()), "{pb:?}"); + // Empty 12 pt point type gets a circle four ems (48 pt) across. + let (empty, _) = point(&mut s, ""); + s.execute("type.dynamicTextPreset", &json!({"preset": "circle"})).unwrap(); + let (path, ..) = on_path(&text(&s, empty)); + let pb = path.bounds().unwrap(); + assert!(close(pb.width(), 48.0) && close(pb.height(), 48.0), "{pb:?}"); +} + +#[test] +fn presets_refuse_bad_params_area_type_and_threads() { + let mut s = session(); + let (id, _) = point(&mut s, "Point"); + let before = undo_len(&s); + for p in [ + json!({}), + json!({"preset": "square"}), + json!({"preset": 1}), + json!({"preset": "arch", "position": "top"}), + json!({"preset": "arch", "position": true}), + json!({"preset": "arch", "flip": "yes"}), + ] { + let e = err(s.execute("type.dynamicTextPreset", &p)); + let key = ["position", "flip"].into_iter().find(|k| p.get(k).is_some()).unwrap_or("preset"); + assert!(e.contains(key), "{p}: {e}"); + } + assert_eq!(undo_len(&s), before); + assert!(matches!(text(&s, id).kind, vectorcraft_doc::TextKind::Point)); + // Area type alone is refused; beside point type it is left as it is. + let area = + s.execute("text.create", &json!({"x": 10, "y": 300, "text": "Area", "area": {"width": 100, "height": 50}})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [area]})).unwrap(); + assert!(err(s.execute("type.dynamicTextPreset", &json!({"preset": "circle"}))).contains("select point type or type on a path")); + s.execute("select.set", &json!({"ids": [area, id.0]})).unwrap(); + let r = s.execute("type.dynamicTextPreset", &json!({"preset": "circle"})).unwrap(); + assert_eq!(r["ids"], json!([id.0])); + assert!(matches!(text(&s, NodeId(area)).kind, vectorcraft_doc::TextKind::Area { .. }) && !text(&s, NodeId(area)).dynamic_text); + // Type on a path in a thread is refused by id. + let threaded = on_line(&mut s, "Threaded story"); + let frame = s.execute("shape.rectangle", &json!({"x": 100, "y": 400, "width": 200, "height": 100})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [threaded.0, frame]})).unwrap(); + s.execute("text.thread.create", &json!({})).unwrap(); + let before = undo_len(&s); + let e = err(s.execute("type.dynamicTextPreset", &json!({"preset": "arch", "ids": [threaded.0, id.0]}))); + assert!(e.contains("in a thread") && e.contains(&threaded.0.to_string()), "{e}"); + assert_eq!(undo_len(&s), before); +} + /// The id of the first text object in `s`'s document. fn first_text(s: &Session) -> NodeId { let mut found = None; diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 35765c6e79..b7d6648b6b 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2264,6 +2264,8 @@ font-technology Variable Променливи Optical Оптично Optical Margin Alignment Подравняване по оптично поле Dynamic Text Динамичен текст + Dynamic Text Presets Шаблони за динамичен текст + Arch Арка Optimize for fast web view Оптимизиране за бърз уеб преглед Optimized Оптимизирано Options Опции diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index cb0d927fdd..5b9cd95daf 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -400,6 +400,8 @@ Create Outlines Kreiraj obrise Optical Margin Alignment Optičko poravnanje margina Dynamic Text Dinamički tekst + Dynamic Text Presets Predlošci dinamičkog teksta + Arch Svod Fill with Placeholder Text Ispuni tekstom čuvara mjesta Insert Inline Symbol Umetni simbol u redu Show Hidden Characters Prikaži skrivene znakove diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 3b186f8661..3a3763e974 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -2892,6 +2892,9 @@ digits Malayalam Malaiàlam Optical Òptic Optical Margin Alignment Alineació òptica de marges Dynamic Text Text dinàmic + Dynamic Text Presets Valors predefinits de text dinàmic + Arch Volta +@msg select point type or type on a path seleccioneu text de punt o text sobre un traçat @msg type on a path in a thread cannot use Dynamic Text: {frames} el text sobre un traçat d’un text enllaçat no pot fer servir el text dinàmic: {frames} Optimize for fast web view Optimitza per a la visualització web ràpida Optimized Optimitzat diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index d0469b1f44..7eb7d8c3bc 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -407,6 +407,9 @@ Create Outlines Převést na křivky Optical Margin Alignment Optické zarovnání okrajů Dynamic Text Dynamický text + Dynamic Text Presets Předvolby dynamického textu + Arch Klenba +@msg select point type or type on a path vyberte bodový text nebo text na cestě @msg type on a path in a thread cannot use Dynamic Text: {frames} text na cestě ve zřetězeném textu nemůže používat dynamický text: {frames} Fill with Placeholder Text Vyplnit zástupným textem Insert Inline Symbol Vložit symbol do textu diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 239a5236aa..1ff55af55b 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -368,6 +368,8 @@ Create Outlines Opret konturer Optical Margin Alignment Optisk marginjustering Dynamic Text Dynamisk tekst + Dynamic Text Presets Forudindstillinger for dynamisk tekst + Arch Hvælving Fill with Placeholder Text Udfyld med pladsholdertekst Insert Inline Symbol Indsæt indlejret symbol Show Hidden Characters Vis skjulte tegn diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 1a62bc991e..362ed03b90 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2290,6 +2290,9 @@ file-filter Templates Vorlagen Optical Optisch Optical Margin Alignment Optischer Randausgleich Dynamic Text Dynamischer Text + Dynamic Text Presets Vorgaben für dynamischen Text + Arch Rundbogen +@msg select point type or type on a path wählen Sie Punkttext oder Pfadtext aus @msg type on a path in a thread cannot use Dynamic Text: {frames} Pfadtext in verkettetem Text kann keinen dynamischen Text verwenden: {frames} Optimize for fast web view Für schnelle Webanzeige optimieren Optimized Optimiert diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index dfc550e667..bdf0e9c5be 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3062,6 +3062,9 @@ theme Appearance Εμφάνιση Optical Οπτική Optical Margin Alignment Οπτική στοίχιση περιθωρίου Dynamic Text Δυναμικό κείμενο + Dynamic Text Presets Προεπιλογές δυναμικού κειμένου + Arch Αψίδα +@msg select point type or type on a path επιλέξτε κείμενο σημείου ή κείμενο σε διαδρομή @msg type on a path in a thread cannot use Dynamic Text: {frames} το κείμενο σε διαδρομή μέσα σε συνδεδεμένο κείμενο δεν μπορεί να χρησιμοποιήσει δυναμικό κείμενο: {frames} Optimize for fast web view Βελτιστοποίηση για γρήγορη προβολή στον ιστό Options for Selection… Επιλογές για την επιλογή… diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 8644b85013..e0b4edb01f 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2336,6 +2336,9 @@ font-technology Variable Variables Optical Óptico Optical Margin Alignment Alineación óptica de márgenes Dynamic Text Texto dinámico + Dynamic Text Presets Ajustes preestablecidos de texto dinámico + Arch Bóveda +@msg select point type or type on a path seleccione texto de punto o texto en trazado @msg type on a path in a thread cannot use Dynamic Text: {frames} el texto en trazado de un texto enlazado no puede usar texto dinámico: {frames} Optimize for fast web view Optimizar para vista web rápida Optimized Optimizado diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index d0d870dce7..5d2e8470b5 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -404,6 +404,9 @@ Create Outlines Muunna ääriviivoiksi Optical Margin Alignment Optinen reunustasaus Dynamic Text Dynaaminen teksti + Dynamic Text Presets Dynaamisen tekstin esiasetukset + Arch Holvi +@msg select point type or type on a path valitse pisteteksti tai polkuteksti @msg type on a path in a thread cannot use Dynamic Text: {frames} ketjutetun tekstin polkuteksti ei voi käyttää dynaamista tekstiä: {frames} Fill with Placeholder Text Täytä paikkamerkkitekstillä Insert Inline Symbol Lisää tekstinsisäinen symboli diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 42f8d664d0..d03566bc25 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2328,6 +2328,9 @@ font-technology Variable Variables Optical Optique Optical Margin Alignment Alignement optique des marges Dynamic Text Texte dynamique + Dynamic Text Presets Paramètres prédéfinis du texte dynamique + Arch Voûte +@msg select point type or type on a path sélectionnez du texte de point ou du texte curviligne @msg type on a path in a thread cannot use Dynamic Text: {frames} le texte curviligne d’un texte lié ne peut pas utiliser le texte dynamique : {frames} Optimize for fast web view Optimiser pour un affichage web rapide Optimized Optimisé diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 55707d5110..ddeb3355f9 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -3147,6 +3147,9 @@ antiAlias Sharp शार्प Optical ऑप्टिकल Optical Margin Alignment ऑप्टिकल मार्जिन संरेखण Dynamic Text डायनैमिक टेक्स्ट + Dynamic Text Presets डायनैमिक टेक्स्ट प्रीसेट + Arch मेहराब +@msg select point type or type on a path पॉइंट टाइप या पाथ पर टाइप चुनें @msg type on a path in a thread cannot use Dynamic Text: {frames} थ्रेडेड टेक्स्ट में पाथ पर टाइप डायनैमिक टेक्स्ट का उपयोग नहीं कर सकता: {frames} Optimize for fast web view तेजी से वेब दृश्य के लिए ऑप्टिमाइज़ Optimized ऑप्टिमाइज़्ड diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index acd57f1fe7..adf79ad42d 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2830,6 +2830,9 @@ file-filter Templates Sablonok Optical Optikai Optical Margin Alignment Optikai margóigazítás Dynamic Text Dinamikus szöveg + Dynamic Text Presets Dinamikus szöveg előbeállításai + Arch Boltív +@msg select point type or type on a path jelöljön ki pontszöveget vagy útvonalszöveget @msg type on a path in a thread cannot use Dynamic Text: {frames} a láncolt szövegben lévő útvonalszöveg nem használhat dinamikus szöveget: {frames} Optimize for fast web view Optimalizálás gyors webes megtekintéshez Optimized Optimalizált diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 8a37686d3e..53e04a5ea9 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -368,6 +368,8 @@ Create Outlines Buat garis luar Optical Margin Alignment Perataan margin optik Dynamic Text Teks dinamis + Dynamic Text Presets Preset teks dinamis + Arch Pelengkung Fill with Placeholder Text Isi dengan teks placeholder Insert Inline Symbol Sisipkan simbol sebaris Show Hidden Characters Tampilkan karakter tersembunyi diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index a91895759f..48dfe0c26f 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2366,6 +2366,9 @@ file-filter Templates Modelli Optical Ottica Optical Margin Alignment Allineamento ottico dei margini Dynamic Text Testo dinamico + Dynamic Text Presets Predefiniti testo dinamico + Arch Volta +@msg select point type or type on a path seleziona testo per punti o testo su tracciato @msg type on a path in a thread cannot use Dynamic Text: {frames} il testo su tracciato in un testo concatenato non può usare il testo dinamico: {frames} Optimize for fast web view Ottimizza per visualizzazione web veloce Optimized Ottimizzato diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 5dee1a8f16..bee01610ad 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -435,6 +435,9 @@ digits Malayalam マラヤーラム数字 Create Outlines アウトラインを作成 Optical Margin Alignment 最適なマージン揃え Dynamic Text ダイナミックテキスト + Dynamic Text Presets ダイナミックテキストプリセット + Arch アーチ +@msg select point type or type on a path ポイント文字またはパス上文字を選択してください @msg type on a path in a thread cannot use Dynamic Text: {frames} スレッドテキスト内のパス上文字にはダイナミックテキストを使用できません: {frames} Fill with Placeholder Text サンプルテキストの割り付け Insert Inline Symbol インラインシンボルを挿入 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index c71c586f05..0f95f1ef03 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -333,6 +333,9 @@ Create Outlines კონტურებად გარდაქმნა Optical Margin Alignment ოპტიკური არშიის სწორება Dynamic Text დინამიკური ტექსტი + Dynamic Text Presets დინამიკური ტექსტის წინასწარი პარამეტრები + Arch თაღი +@msg select point type or type on a path აირჩიეთ წერტილოვანი ტექსტი ან ტრაექტორიაზე ტექსტი @msg type on a path in a thread cannot use Dynamic Text: {frames} დაკავშირებულ ტექსტში ტრაექტორიაზე ტექსტი ვერ გამოიყენებს დინამიკურ ტექსტს: {frames} Fill with Placeholder Text შემავსებელი ტექსტით შევსება Insert Inline Symbol ჩადგმული სიმბოლოს ჩასმა diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 3e582ae896..82b7ecf741 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -428,6 +428,9 @@ digits Malayalam 말라얄람어 Create Outlines 윤곽선 만들기 Optical Margin Alignment 시각적 여백 정렬 Dynamic Text 동적 텍스트 + Dynamic Text Presets 동적 텍스트 사전 설정 + Arch 아치 +@msg select point type or type on a path 포인트 문자 또는 패스 위 문자를 선택하십시오 @msg type on a path in a thread cannot use Dynamic Text: {frames} 연결된 문자의 패스 위 문자에는 동적 텍스트를 사용할 수 없습니다: {frames} Fill with Placeholder Text 자리 표시 문자로 채우기 Insert Inline Symbol 인라인 심볼 삽입 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index ec13492d2d..33a7706b44 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -382,6 +382,8 @@ Create Outlines Omzetten in curven Optical Margin Alignment Optische marge-uitlijning Dynamic Text Dynamische tekst + Dynamic Text Presets Voorinstellingen voor dynamische tekst + Arch Gewelf Fill with Placeholder Text Vullen met plaatshoudertekst Insert Inline Symbol Inline-symbool invoegen Show Hidden Characters Verborgen tekens tonen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index c32c2e2609..7ec4b3b402 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -366,6 +366,9 @@ Create Outlines Utwórz kontury Optical Margin Alignment Optyczne wyrównanie marginesów Dynamic Text Tekst dynamiczny + Dynamic Text Presets Ustawienia predefiniowane tekstu dynamicznego + Arch Sklepienie +@msg select point type or type on a path zaznacz tekst punktowy lub tekst na ścieżce @msg type on a path in a thread cannot use Dynamic Text: {frames} tekst na ścieżce w tekście wątkowym nie może używać tekstu dynamicznego: {frames} Fill with Placeholder Text Wypełnij tekstem zastępczym Insert Inline Symbol Wstaw symbol w tekście diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 4875326924..2859a67358 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1932,6 +1932,9 @@ antiAlias Sharp Nítido Optical Óptico Optical Margin Alignment Alinhamento de Margem Óptica Dynamic Text Texto Dinâmico + Dynamic Text Presets Predefinições de Texto Dinâmico + Arch Abóbada +@msg select point type or type on a path selecione texto de ponto ou texto em um caminho @msg type on a path in a thread cannot use Dynamic Text: {frames} o texto em um caminho de um texto encadeado não pode usar Texto Dinâmico: {frames} Optimize for fast web view Otimizar para visualização rápida na web Optimized Otimizado diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index ea69c1fca2..4d7cd88885 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2361,6 +2361,9 @@ file-filter Templates Шаблоны Optical Оптический Optical Margin Alignment Оптическое выравнивание полей Dynamic Text Динамический текст + Dynamic Text Presets Наборы параметров динамического текста + Arch Арка +@msg select point type or type on a path выберите точечный текст или текст по контуру @msg type on a path in a thread cannot use Dynamic Text: {frames} текст по контуру в связанном тексте не может использовать динамический текст: {frames} Optimize for fast web view Оптимизировать для быстрого просмотра в web Optimized Оптимизировано diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index eb40d3630a..9798d3a05e 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -2275,6 +2275,9 @@ font-technology Variable Variabla Optical Optisk Optical Margin Alignment Optisk marginaljustering Dynamic Text Dynamisk text + Dynamic Text Presets Förinställningar för dynamisk text + Arch Valv +@msg select point type or type on a path markera punkttext eller text på bana @msg type on a path in a thread cannot use Dynamic Text: {frames} text på bana i länkad text kan inte använda dynamisk text: {frames} Optimize for fast web view Optimera för snabb webbvisning Optimized Optimerad diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index adc8328bbb..615a6309df 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2147,6 +2147,9 @@ shape Open Açık Optical Optik Optical Margin Alignment Optik Kenar Boşluğu Hizalaması Dynamic Text Dinamik Metin + Dynamic Text Presets Dinamik Metin Hazır Ayarları + Arch Kemer +@msg select point type or type on a path nokta metni veya yol üzerindeki yazıyı seçin @msg type on a path in a thread cannot use Dynamic Text: {frames} zincirli metindeki yol üzerindeki yazı dinamik metin kullanamaz: {frames} Optimize for fast web view Hızlı web görünümü için optimize edin Optimized Optimize edilmiş diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 86d6241c5e..cfee441ded 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2358,6 +2358,9 @@ file-filter Templates Шаблони Optical Оптичний Optical Margin Alignment Оптичне вирівнювання полів Dynamic Text Динамічний текст + Dynamic Text Presets Набори параметрів динамічного тексту + Arch Арка +@msg select point type or type on a path виберіть точковий текст або текст за контуром @msg type on a path in a thread cannot use Dynamic Text: {frames} текст за контуром у пов’язаному тексті не може використовувати динамічний текст: {frames} Optimize for fast web view Оптимізувати для швидкого перегляду web Optimized Оптимізовано diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 3d7277d62a..f6433ad2de 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2643,6 +2643,9 @@ digits Hindi Hindi Optical Quang học Optical Margin Alignment Căn lề quang học Dynamic Text Văn bản động + Dynamic Text Presets Thiết lập sẵn văn bản động + Arch Vòm +@msg select point type or type on a path chọn chữ điểm hoặc chữ trên đường dẫn @msg type on a path in a thread cannot use Dynamic Text: {frames} chữ trên đường dẫn trong văn bản chuỗi không thể dùng văn bản động: {frames} Optimize for fast web view Tối ưu để xem web nhanh Optimized Đã tối ưu diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 68bb795a2d..fbd8064001 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2234,6 +2234,9 @@ file-filter Templates 模板 Optical 视觉 Optical Margin Alignment 视觉边界对齐 Dynamic Text 动态文本 + Dynamic Text Presets 动态文本预设 + Arch 拱形 +@msg select point type or type on a path 请选择点文字或路径文字 @msg type on a path in a thread cannot use Dynamic Text: {frames} 串接文本中的路径文字不能使用动态文本:{frames} Optimize for fast web view 优化快速 Web 查看 Optimized 优化 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index b50bed47d7..27fd3db16e 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3314,6 +3314,9 @@ file-filter Templates 範本 Optical 視覺 Optical Margin Alignment 視覺邊界對齊 Dynamic Text 動態文字 + Dynamic Text Presets 動態文字預設集 + Arch 拱形 +@msg select point type or type on a path 請選取點狀文字或路徑文字 @msg type on a path in a thread cannot use Dynamic Text: {frames} 串連文字中的路徑文字無法使用動態文字:{frames} Optimize for fast web view 最佳化以便快速網頁檢視 Optimized 最佳化 diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index 8591c4d89d..3d1577e1be 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -358,6 +358,41 @@ fn dynamic_text_is_a_checked_type_menu_item() { assert_eq!(checked(&app, id, &Value::Null), Some(true)); } +/// Type › Dynamic Text Presets › Circle, Arch and Bow run `type.dynamicTextPreset` with their +/// preset, enabled for point type and type on a path but not for area type alone. +#[test] +fn dynamic_text_presets_are_a_type_submenu() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let items: Vec<(&str, &str, Value)> = menu_tree() + .into_iter() + .filter(|(menu, _)| *menu == "Type") + .flat_map(|(_, items)| items) + .filter_map(|item| match item { + Item::Sub("Dynamic Text Presets", children) => Some(children), + _ => None, + }) + .flatten() + .filter_map(|item| match item { + Item::Cmd(label, id, p) => Some((label, id, p)), + _ => None, + }) + .collect(); + let id = "type.dynamicTextPreset"; + assert_eq!(items, [("Circle", id, json!({"preset": "circle"})), ("Arch", id, json!({"preset": "arch"})), ("Bow", id, json!({"preset": "bow"}))]); + assert!(!enabled(&app, id)); + let area = app.run("text.create", json!({"x": 10, "y": 200, "text": "Area", "area": {"width": 100, "height": 50}})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [area]})).unwrap(); + assert!(!enabled(&app, id), "area type"); + let point = app.run("text.create", json!({"x": 10, "y": 10, "text": "Point"})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [point]})).unwrap(); + assert!(enabled(&app, id)); + app.run(id, json!({"preset": "arch"})).unwrap(); + // Now type on a path with Dynamic Text on, so Type › Dynamic Text is checked. + assert!(enabled(&app, id)); + assert_eq!(checked(&app, "type.dynamicText", &Value::Null), Some(true)); +} + /// Type › Optical Margin Alignment menu check uses `shared_text_flag`; mixed values stay unchecked. #[test] fn optical_margin_alignment_menu_unchecked_when_shared_flag_none() { diff --git a/crates/ui-egui/src/menus/tree.rs b/crates/ui-egui/src/menus/tree.rs index 20dc91566a..916b8b311a 100644 --- a/crates/ui-egui/src/menus/tree.rs +++ b/crates/ui-egui/src/menus/tree.rs @@ -400,6 +400,14 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec)> { .collect(), ), c("Dynamic Text", "type.dynamicText"), + sub( + "Dynamic Text Presets", + vec![ + cp("Circle", "type.dynamicTextPreset", json!({"preset": "circle"})), + cp("Arch", "type.dynamicTextPreset", json!({"preset": "arch"})), + cp("Bow", "type.dynamicTextPreset", json!({"preset": "bow"})), + ], + ), sub( "Threaded Text", vec![ diff --git a/docs/mcp.md b/docs/mcp.md index ef2b8df02f..817abfd6e5 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1444,7 +1444,7 @@ These commands refuse a string, a number or another value that is not true or fa and `text.createInPath`; `underline`, `strikethrough`, `allCaps`, `proportionalMetrics`, `tateChuYoko`, `standardVerticalRoman` and `noBreak` in `text.setRangeStyle` and `text.setFormat`, which also checks `hyphenate`, `hyphenateCapitalized` and `romanHangingPunctuation`; `effective` and `story` in `text.getRange`; `story` in `text.setRangeStyle`; `on` in `type.opticalMarginAlignment`, `type.dynamicText`, `type.systemLayout` -and `type.fractionalWidths`; `remove` and `overhang` in `type.ruby`; `expandInstances` in `symbol.delete`; `alt` in +and `type.fractionalWidths`; `flip` in `type.dynamicTextPreset`; `remove` and `overhang` in `type.ruby`; `expandInstances` in `symbol.delete`; `alt` in `symbol.spray` and `symbol.adjust`; `spot` and `global` in `swatch.new` and `swatch.edit`; `unlink` in `swatch.delete`; `fromArtwork`, `toGlobal` and `includeTints` in `swatch.newGroup`; `global` and `selection` in `swatch.addUsedColors`; `visible` in `appearance.setItem`; `copy` in `appearance.transfer`; `reverse` and `append` in @@ -7592,6 +7592,31 @@ so in a warning. {"name":"run_command","arguments":{"command":"type.dynamicText","params":{"on":true}}} ``` +`type.dynamicTextPreset {preset: "circle"|"arch"|"bow", position?: "above"|"center"|"below", flip?: bool, ids?}` +(Type › Dynamic Text Presets › Circle, Arch and Bow) puts the selected point type and type on a path on a new path +of the preset's shape and turns Dynamic Text on, so the text is sized to fill it. Circle puts the text on a circular +path, Arch on an upward-curving path and Bow on a downward-curving one; `position` sets where the text sits against +the path (above, center or below) and `flip` turns its direction +([Dynamic Text overview](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/dynamic-text-overview.html)). +The shapes and the rest of this entry are VectorCraft's own rules. The circle is as wide as the text, and the text +runs clockwise round all of it from its bottom. Arch and Bow are a 120° arc of a circle whose chord is as wide as the +text, bulging up or down, and the text runs along it from left to right. The width is, for point type, the length on the +page of the longer side of the text's bounds, and for type on a path the longer side of its path's bounds on the +page, so the same preset again leaves it as it is; the new path is centered where they were, and +the shape is upright on the page when the object is rotated or scaled. Empty point type gets a path four ems of its +first character wide. `position` (omitted: +above) sets Type on a Path Options › Align to Path: above is Descender, center is Center and below is Ascender. +`flip: true` then turns the text to the other side of its path, running the other way, as Type on a Path Options › +Flip does. Type on a Path Options › effect and Spacing stay as they are, and Type on a Path Options, the brackets and +Type › Dynamic Text change the result afterwards. Area type in the selection is left as it is; with no point type +or type on a path selected the command refuses with `` select point type or type on a path ``, and type on a path in +a thread is refused as `type.dynamicText` refuses it. A preset that would leave every object as it is records no +undo step. It replies `{ids, fitScale}` (the first object's factor). + +```json +{"name":"run_command","arguments":{"command":"type.dynamicTextPreset","params":{"preset":"arch","position":"center"}}} +``` + ## Paragraph attributes per paragraph Each paragraph (text split at `\n`) has its own alignment, indents, space before and after, hyphenation, diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 490dfb38dc..faa328344b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1170,6 +1170,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur 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`). + 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. Type › Optical Margin Alignment (`type.opticalMarginAlignment`) hangs the punctuation and letter edges at the ends of a type object's lines outside its margins: at the left of left-aligned lines, at the right of right-aligned lines, and at both ends of centered and justified lines, in point and area type of left-to-right paragraphs. Quotation marks, hyphens, full stops and commas hang wholly; asterisks, tildes, ellipses, en and em dashes, colons and semicolons hang by half; and A, V, W, Y, v, w and y hang by a tenth of their width. It is off by default and saved with the type object. Justification's Glyph Scaling leaves the glyphs that hang at their width, so they hang exactly their share, and Standard burasagari counts what hangs at a line's start as room before hanging a comma; `crates/text/src/tests/hang.rs` checks whole paragraphs with both composers, ragged, centred and justified, with Line-end Punctuation Half Width and bidirectional lines. From 7559607037ded06fc461269d842c04a7923087b0 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 18:43:30 -0800 Subject: [PATCH 06/15] Document Setup: a Color tab sets the color mode and the assigned profiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document Setup gets a Color tab with the document color mode (RGB Color or CMYK Color), a Convert to CMYK or Convert to RGB box for a mode change (cleared, the colors keep their numbers), and the RGB and CMYK profiles assigned to the document, with a working-space entry for none. When the tab changes something, OK runs document.setup, then edit.assignProfile, then file.documentColorMode in one command.batch, and the document's changes are one undo step. Assign Profile runs first, so a mode change converts through the profile picked with it. An unchanged Color tab adds no color commands, and Cancel, or OK with nothing changed, records no undo step. A Color tab field with a value of the wrong kind is an error, and the dialog stays open with nothing changed. File › Document Color Mode and Edit › Assign Profile… stay where they are. Catalogs: "Working RGB (don't tag)" and "Assign Profile" rows in every complete catalog, and the "`{key}` must be a profile name or null" message in every complete-message catalog. Docs: docs/mcp.md, docs/control-protocol.md, docs/target-app-parity.md. --- crates/ui-egui/src/dialogs/document_setup.rs | 314 ++++++++++++++++++- crates/ui-egui/src/i18n/bg.tsv | 2 + crates/ui-egui/src/i18n/bs.tsv | 2 + crates/ui-egui/src/i18n/ca.tsv | 3 + crates/ui-egui/src/i18n/cs.tsv | 3 + crates/ui-egui/src/i18n/da.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 3 + crates/ui-egui/src/i18n/el.tsv | 3 + crates/ui-egui/src/i18n/es.tsv | 3 + crates/ui-egui/src/i18n/fi.tsv | 3 + crates/ui-egui/src/i18n/fr.tsv | 3 + crates/ui-egui/src/i18n/hi.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 3 + crates/ui-egui/src/i18n/id.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 3 + crates/ui-egui/src/i18n/ja.tsv | 3 + crates/ui-egui/src/i18n/ka.tsv | 3 + crates/ui-egui/src/i18n/ko.tsv | 3 + crates/ui-egui/src/i18n/nl.tsv | 2 + crates/ui-egui/src/i18n/pl.tsv | 3 + crates/ui-egui/src/i18n/pt-br.tsv | 3 + crates/ui-egui/src/i18n/ru.tsv | 3 + crates/ui-egui/src/i18n/sv.tsv | 2 + crates/ui-egui/src/i18n/tr.tsv | 3 + crates/ui-egui/src/i18n/uk.tsv | 3 + crates/ui-egui/src/i18n/vi.tsv | 3 + crates/ui-egui/src/i18n/zh-hans.tsv | 3 + crates/ui-egui/src/i18n/zh-hant.tsv | 3 + crates/ui-egui/src/panels/separations.rs | 2 +- docs/control-protocol.md | 8 +- docs/mcp.md | 3 + docs/target-app-parity.md | 1 + 32 files changed, 387 insertions(+), 14 deletions(-) diff --git a/crates/ui-egui/src/dialogs/document_setup.rs b/crates/ui-egui/src/dialogs/document_setup.rs index a449156e74..1946cc86b2 100644 --- a/crates/ui-egui/src/dialogs/document_setup.rs +++ b/crates/ui-egui/src/dialogs/document_setup.rs @@ -1,13 +1,19 @@ //! File → Document Setup: General (units, bleed, Edit Artboards, outline images, the substituted //! glyphs highlight), Transparency (grid size and colours, simulated paper, flattener preset, white -//! overprint) and Type (language, quotes, superscript/subscript/small caps, text export) tabs. +//! overprint), Type (language, quotes, superscript/subscript/small caps, text export) and Color +//! (document color mode and assigned profiles) tabs. //! -//! Fields: what `document.setup` reports (and takes back), plus `tab` and `bleedLinked`. OK runs -//! `document.setup` with them: one undo step. On an error the dialog stays open. +//! Fields: what `document.setup` reports (and takes back), plus `tab`, `bleedLinked` and the Color +//! tab's `colorMode`, `convertColors`, `rgbProfile` and `cmykProfile` (null: the working space). +//! OK runs `document.setup` with them: one undo step. When the Color tab changed something, OK runs +//! `document.setup`, `edit.assignProfile` and `file.documentColorMode` in one `command.batch`, +//! and the document's changes are still one undo step. On an error the dialog stays open. -use serde_json::{Value, json}; +use serde_json::{Map, Value, json}; +use vectorcraft_color::cms::ProfileKind; use vectorcraft_doc::setup::{GRID_COLOR_PRESETS, LANGUAGES}; -use vectorcraft_doc::{GridSize, Quotes, Unit}; +use vectorcraft_doc::{ColorMode, GridSize, Quotes, Unit}; +use vectorcraft_engine::cmd::colormgmt::doc_profiles; use super::{DialogSpec, form}; use crate::state::Dialog; @@ -15,10 +21,13 @@ use crate::theme::Tokens; use crate::{VectorcraftApp, widgets}; pub(super) const KIND: &str = "documentSetup"; -const TABS: [&str; 3] = ["General", "Transparency", "Type"]; +const TABS: [&str; 4] = ["General", "Transparency", "Type", "Color"]; /// Dialog-only fields `document.setup` doesn't take. -const UI_ONLY: [&str; 2] = ["tab", "bleedLinked"]; +const UI_ONLY: [&str; 6] = ["tab", "bleedLinked", "colorMode", "convertColors", "rgbProfile", "cmykProfile"]; pub(super) const LABEL: f32 = 130.0; +/// The profile rows' entry for no assigned profile: the document uses the working space. +const WORKING_RGB: &str = "Working RGB (don't tag)"; +const WORKING_CMYK: &str = "Working CMYK (don't tag)"; pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("Document Setup").into(), body, confirm, min_width: 540.0, max_width: Some(540.0), ..DialogSpec::FORM }; @@ -29,13 +38,76 @@ pub fn open(app: &mut VectorcraftApp) -> Result { let linked = fields["bleed"].as_array().is_some_and(|b| b.windows(2).all(|w| w[0] == w[1])); fields["bleedLinked"] = json!(linked); fields["tab"] = json!(TABS[0]); + if let Some(st) = app.session.active() { + let (rgb, cmyk) = doc_profiles(&st.doc); + fields["colorMode"] = json!(mode_id(st.doc.color_mode)); + fields["rgbProfile"] = json!(rgb); + fields["cmykProfile"] = json!(cmyk); + } + fields["convertColors"] = json!(true); app.ui.dialog = Some(Dialog::new(KIND, fields)); Ok(Value::Null) } +/// The `mode` param of `file.documentColorMode` for `m`. +fn mode_id(m: ColorMode) -> &'static str { + match m { + ColorMode::Rgb => "rgb", + ColorMode::Cmyk => "cmyk", + } +} + +/// The commands the Color tab's changes run, as `command.batch` steps, in the order OK runs them: +/// Edit › Assign Profile first, so that a mode change converts through the profiles it assigns, +/// then File › Document Color Mode. Empty when the tab matches the active document. A field the +/// dialog holds with a value of the wrong kind is an error, and then nothing runs; an omitted +/// field leaves its setting as it is (`convertColors`: converts). +fn color_commands(app: &VectorcraftApp, d: &Dialog) -> Result, String> { + let Some(st) = app.session.active() else { return Ok(vec![]) }; + let mut out = vec![]; + let (rgb, cmyk) = doc_profiles(&st.doc); + let mut assign = Map::new(); + for (key, param, current) in [("rgbProfile", "rgb", rgb), ("cmykProfile", "cmyk", cmyk)] { + let chosen = match d.fields.get(key) { + None => continue, + Some(Value::Null) => None, + Some(Value::String(n)) => Some(n.clone()), + Some(_) => return Err(format!("`{key}` must be a profile name or null")), + }; + if chosen != current { + assign.insert(param.into(), json!(chosen)); + } + } + let mode = match d.fields.get("colorMode") { + None => None, + Some(Value::String(m)) if m.eq_ignore_ascii_case("rgb") => Some(ColorMode::Rgb), + Some(Value::String(m)) if m.eq_ignore_ascii_case("cmyk") => Some(ColorMode::Cmyk), + Some(_) => return Err("`colorMode` must be one of rgb, cmyk".into()), + }; + let key = "convertColors"; + let convert = match d.fields.get(key) { + None => true, + Some(Value::Bool(b)) => *b, + Some(_) => return Err(format!("`{key}` must be true or false")), + }; + if !assign.is_empty() { + out.push(json!({"command": "edit.assignProfile", "params": assign})); + } + if let Some(mode) = mode.filter(|m| *m != st.doc.color_mode) { + out.push(json!({"command": "file.documentColorMode", "params": {"mode": mode_id(mode), "convert": convert}})); + } + Ok(out) +} + fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { - let params = d.fields.iter().filter(|(k, _)| !UI_ONLY.contains(&k.as_str())).map(|(k, v)| (k.clone(), v.clone())).collect(); - let r = app.run("document.setup", Value::Object(params)); + let params: Map = d.fields.iter().filter(|(k, _)| !UI_ONLY.contains(&k.as_str())).map(|(k, v)| (k.clone(), v.clone())).collect(); + let color = color_commands(app, d)?; + let r = if color.is_empty() { + app.run("document.setup", Value::Object(params)) + } else { + let commands: Vec = std::iter::once(json!({"command": "document.setup", "params": params})).chain(color).collect(); + app.run("command.batch", json!({"label": "Document Setup", "commands": commands})) + }; if r.is_ok() { app.ui.dialog = None; } @@ -54,7 +126,8 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { match tab { 0 => close = general(app, ui, d), 1 => transparency(app, ui, d), - _ => typography(ui, d), + 2 => typography(ui, d), + _ => color(app, ui, d), } }); close @@ -208,10 +281,40 @@ fn typography(ui: &mut egui::Ui, d: &mut Dialog) { choice(ui, d, "exportText", tl!("Export:"), &[("editable", tl!("Preserve Text Editability")), ("appearance", tl!("Preserve Text Appearance"))]); } +/// The Color tab: the document color mode, whether a mode change converts the colors (unchecked: +/// they keep their numbers), and the RGB and CMYK profiles assigned to the document. +fn color(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { + widgets::subheader(ui, tl!("Document Color Mode")); + choice(ui, d, "colorMode", tl!("Color Mode:"), &[("rgb", "RGB Color"), ("cmyk", "CMYK Color")]); + let mode = d.str("colorMode"); + let changes = app.session.active().is_some_and(|st| mode_id(st.doc.color_mode) != mode); + let label = if mode == "cmyk" { tl!("Convert to CMYK") } else { tl!("Convert to RGB") }; + widgets::label_row(ui, "", LABEL, |ui| { + let on = d.bool("convertColors"); + if widgets::check(ui, label, on, changes) { + d.fields.insert("convertColors".into(), json!(!on)); + } + }); + ui.add_space(14.0); + widgets::subheader(ui, tl!("Assign Profile")); + for (key, label, kind, working) in + [("rgbProfile", tl!("RGB:"), ProfileKind::Rgb, WORKING_RGB), ("cmykProfile", tl!("CMYK:"), ProfileKind::Cmyk, WORKING_CMYK)] + { + let current = d.fields.get(key).and_then(Value::as_str).unwrap_or(working).to_string(); + widgets::label_row(ui, label, LABEL, |ui| { + if let Some(n) = crate::panels::separations::profile_dropdown(ui, key, ¤t, kind, Some(working)) { + d.fields.insert(key.into(), if n == working { Value::Null } else { json!(n) }); + } + }); + } +} + #[cfg(test)] mod tests { use egui::{Event, PointerButton, Pos2, Rect, vec2}; use serde_json::json; + use vectorcraft_color::{Color, Paint, cms}; + use vectorcraft_doc::{ColorMode, NodeId}; use vectorcraft_engine::Session; use crate::VectorcraftApp; @@ -302,7 +405,7 @@ mod tests { let mut app = VectorcraftApp::new(Session::new(), Default::default()); app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); app.run("file.documentSetup", json!({})).unwrap(); - for tab in ["General", "Transparency", "Type"] { + for tab in ["General", "Transparency", "Type", "Color"] { app.ui.dialog.as_mut().unwrap().fields.insert("tab".into(), json!(tab)); frame(&mut app); } @@ -343,4 +446,193 @@ mod tests { crate::dialogs::confirm(&mut app).unwrap(); assert!(app.ui.dialog.is_none()); } + + /// A new document with a red square → the app and the square. + fn red_square() -> (VectorcraftApp, NodeId) { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap(); + app.run("paint.setFill", json!({"ids": [id], "color": "#ff0000"})).unwrap(); + (app, NodeId(id)) + } + + fn fill(app: &VectorcraftApp, id: NodeId) -> Paint { + app.session.active().unwrap().doc.node(id).unwrap().appearance.fill().unwrap().paint.clone() + } + + fn undo_steps(app: &VectorcraftApp) -> usize { + app.session.active().unwrap().history.undo.len() + } + + /// Opens Document Setup on the Color tab and sets `fields` in it. + fn color_tab(app: &mut VectorcraftApp, fields: serde_json::Value) { + app.run("file.documentSetup", json!({})).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("tab".into(), json!("Color")); + for (k, v) in fields.as_object().unwrap() { + d.fields.insert(k.clone(), v.clone()); + } + } + + /// The Color tab opens on the document's color mode and assigned profiles, and shows them. + /// The CMYK profile assigned is the working one, so the process-wide color settings stay as + /// they are for the other tests. + #[test] + fn color_tab_shows_the_color_mode_and_profiles() { + let (mut app, _) = red_square(); + let working = cms::active_settings().cmyk; + app.run("file.documentColorMode", json!({"mode": "cmyk"})).unwrap(); + app.run("edit.assignProfile", json!({"cmyk": working})).unwrap(); + let steps = undo_steps(&app); + color_tab(&mut app, json!({})); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.str("colorMode"), d.str("cmykProfile"), d.fields["rgbProfile"].clone()), ("cmyk".into(), working.clone(), json!(null))); + assert!(d.bool("convertColors")); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + crate::theme::apply(&ctx, Default::default()); + dialog_frame(&mut app, &ctx, vec![]); + let texts: Vec = dialog_frame(&mut app, &ctx, vec![]).into_iter().map(|(t, _)| t).collect(); + for shown in ["CMYK Color", "Convert to CMYK", "Working RGB (don't tag)", working.as_str()] { + assert!(texts.iter().any(|t| t == shown), "no {shown} in {texts:?}"); + } + // Picking the other mode names the conversion OK makes. + app.ui.dialog.as_mut().unwrap().fields.insert("colorMode".into(), json!("rgb")); + let texts: Vec = dialog_frame(&mut app, &ctx, vec![]).into_iter().map(|(t, _)| t).collect(); + assert!(texts.iter().any(|t| t == "Convert to RGB"), "{texts:?}"); + app.ui.dialog.as_mut().unwrap().fields.insert("colorMode".into(), json!("cmyk")); + // OK with nothing changed records nothing. + crate::dialogs::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(undo_steps(&app), steps); + } + + /// OK with a new mode converts the colors as File › Document Color Mode does, together with the + /// dialog's other changes, in one undo step. + #[test] + fn ok_converts_the_color_mode_with_the_other_changes_in_one_undo_step() { + let (mut app, id) = red_square(); + let (mut menu, menu_id) = red_square(); + menu.run("file.documentColorMode", json!({"mode": "cmyk"})).unwrap(); + let steps = undo_steps(&app); + color_tab(&mut app, json!({"colorMode": "cmyk", "bleed": [9, 9, 9, 9]})); + crate::dialogs::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + let st = app.session.active().unwrap(); + assert_eq!((st.doc.color_mode, st.doc.setup.bleed), (ColorMode::Cmyk, [9.0; 4])); + assert_eq!(fill(&app, id), fill(&menu, menu_id)); + assert!(matches!(fill(&app, id), Paint::Solid { color: Color::Cmyk { .. }, .. }), "{:?}", fill(&app, id)); + assert_eq!(undo_steps(&app), steps + 1); + app.run("edit.undo", json!({})).unwrap(); + let st = app.session.active().unwrap(); + assert_eq!((st.doc.color_mode, st.doc.setup.bleed), (ColorMode::Rgb, [0.0; 4])); + assert_eq!(fill(&app, id), Paint::solid(Color::from_hex("ff0000").unwrap())); + } + + /// With Convert unchecked the mode changes and the colors keep their numbers. + #[test] + fn ok_without_convert_keeps_the_color_numbers() { + let (mut app, id) = red_square(); + let before = fill(&app, id); + let steps = undo_steps(&app); + color_tab(&mut app, json!({"colorMode": "cmyk", "convertColors": false})); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(app.session.active().unwrap().doc.color_mode, ColorMode::Cmyk); + assert_eq!(fill(&app, id), before); + assert_eq!(undo_steps(&app), steps + 1); + } + + /// OK assigns the profiles picked, with the dialog's other changes, in one undo step; the + /// working-space entry assigns none. + #[test] + fn ok_assigns_the_profiles_with_the_other_changes_in_one_undo_step() { + let (mut app, _) = red_square(); + let working = cms::active_settings().cmyk; + let steps = undo_steps(&app); + color_tab(&mut app, json!({"cmykProfile": working, "bleed": [9, 9, 9, 9]})); + crate::dialogs::confirm(&mut app).unwrap(); + let st = app.session.active().unwrap(); + assert_eq!((st.doc.color_profiles.cmyk.clone(), st.doc.color_profiles.rgb.clone()), (Some(working.clone()), None)); + assert_eq!((st.doc.setup.bleed, st.doc.color_mode), ([9.0; 4], ColorMode::Rgb)); + assert_eq!(undo_steps(&app), steps + 1); + // Back to the working space: no profile assigned. + color_tab(&mut app, json!({"cmykProfile": null})); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(app.session.active().unwrap().doc.color_profiles.cmyk, None); + app.run("edit.undo", json!({})).unwrap(); + app.run("edit.undo", json!({})).unwrap(); + let st = app.session.active().unwrap(); + assert_eq!((st.doc.color_profiles.cmyk.clone(), st.doc.setup.bleed), (None, [0.0; 4])); + } + + /// OK assigns the RGB profile picked, in one undo step. + #[test] + fn ok_assigns_the_rgb_profile() { + let (mut app, _) = red_square(); + let working = cms::active_settings().rgb; + let steps = undo_steps(&app); + color_tab(&mut app, json!({"rgbProfile": working})); + crate::dialogs::confirm(&mut app).unwrap(); + let st = app.session.active().unwrap(); + assert_eq!((st.doc.color_profiles.rgb.clone(), st.doc.color_profiles.cmyk.clone()), (Some(working), None)); + assert_eq!(undo_steps(&app), steps + 1); + } + + /// A profile and a mode changed in one OK: Assign Profile runs before Document Color Mode, so + /// the conversion goes through the profile picked. + #[test] + fn a_profile_and_a_mode_change_assign_the_profile_first() { + let (mut app, _) = red_square(); + color_tab(&mut app, json!({"colorMode": "cmyk", "cmykProfile": cms::DEVICE_CMYK})); + let commands = super::color_commands(&app, app.ui.dialog.as_ref().unwrap()).unwrap(); + assert_eq!( + commands, + vec![ + json!({"command": "edit.assignProfile", "params": {"cmyk": cms::DEVICE_CMYK}}), + json!({"command": "file.documentColorMode", "params": {"mode": "cmyk", "convert": true}}), + ] + ); + } + + /// A Color tab field with a value of the wrong kind is an error: the dialog stays open and + /// nothing changes, the profile picked with it included. + #[test] + fn a_bad_color_field_is_an_error_and_changes_nothing() { + let bad = [ + ("colorMode", json!("lab")), + ("colorMode", json!(null)), + ("rgbProfile", json!(5)), + ("cmykProfile", json!(true)), + ("convertColors", json!("no")), + ]; + for (key, value) in bad { + let (mut app, id) = red_square(); + let before = fill(&app, id); + let steps = undo_steps(&app); + color_tab(&mut app, json!({"colorMode": "cmyk", "cmykProfile": cms::active_settings().cmyk})); + app.ui.dialog.as_mut().unwrap().fields.insert(key.into(), value.clone()); + assert!(super::color_commands(&app, app.ui.dialog.as_ref().unwrap()).is_err(), "{key}: {value}"); + assert!(crate::dialogs::confirm(&mut app).is_err(), "{key}: {value}"); + assert!(app.ui.dialog.is_some()); + let st = app.session.active().unwrap(); + assert_eq!((st.doc.color_mode, st.doc.color_profiles.cmyk.clone()), (ColorMode::Rgb, None), "{key}: {value}"); + assert_eq!(fill(&app, id), before); + assert_eq!(undo_steps(&app), steps); + } + } + + /// Cancel leaves the color mode, the colors and the history as they were. + #[test] + fn cancel_changes_no_color_setting() { + let (mut app, id) = red_square(); + let before = fill(&app, id); + let steps = undo_steps(&app); + color_tab(&mut app, json!({"colorMode": "cmyk", "cmykProfile": cms::active_settings().cmyk})); + click_dialog_button(&mut app, "Cancel"); + assert!(app.ui.dialog.is_none()); + let st = app.session.active().unwrap(); + assert_eq!((st.doc.color_mode, st.doc.color_profiles.cmyk.clone()), (ColorMode::Rgb, None)); + assert_eq!(fill(&app, id), before); + assert_eq!(undo_steps(&app), steps); + } } diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 24f16e1794..76e59859e7 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -249,6 +249,7 @@ font-technology Variable Променливи Asset Export Settings Настройки за експортиране на активи Assets Активи Assign Задаване + Assign Profile Задаване на профил Assign Profile… Задаване на профил… Assign: Задаване: Attach to Active Plane Прикрепване към активната равнина @@ -3781,6 +3782,7 @@ digits Malayalam Малаялам Windows Windows Working CMYK Работен CMYK Working CMYK (don't tag) Работен CMYK (без етикет) + Working RGB (don't tag) Работен RGB (без етикет) Workspace Работно пространство Workspace {i} Работно пространство {i} Wrinkle Tool Инструмент „Набръчкване“ diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 83290bdbd9..4874cddf86 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -113,6 +113,7 @@ PDF Presets… PDF predlošci… Perspective Grid Presets… Predlošci mreže perspektive… Color Settings… Postavke boja… + Assign Profile Dodijeli profil Assign Profile… Dodijeli profil… Keyboard Shortcuts… Prečice na tastaturi… Import Shortcut Set Uvezi skup prečica @@ -2035,6 +2036,7 @@ digits Malayalam Malajalam Simulate Paper Simuliraj papir Use Black Point Compensation Koristi kompenzaciju crne tačke Working CMYK (don't tag) Radni CMYK (bez oznake) + Working RGB (don't tag) Radni RGB (bez oznake) {name} (spot) {name} (spot) Add to Profiles… Dodaj u profile… Align Stroke to Center Poravnaj potez po sredini diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 717c2b59d9..778a0bf9fb 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -400,6 +400,7 @@ antiAlias Sharp Nítid Asset Export Settings Configuració d’exportació de recursos Assets Recursos Assign Assigna + Assign Profile Assigna el perfil Assign Profile… Assigna el perfil… Assign: Assigna: Attach Adjunta @@ -4909,6 +4910,7 @@ digits Malayalam Malaiàlam Words Longer Than: Paraules més llargues de: Working CMYK CMYK de treball Working CMYK (don't tag) CMYK de treball (sense etiquetar) + Working RGB (don't tag) RGB de treball (sense etiquetar) Working RGB RGB de treball Workspace Espai de treball Workspace {i} Espai de treball {i} @@ -5067,6 +5069,7 @@ digits Malayalam Malaiàlam @msg `{key}` must be a number not below 0 `{key}` ha de ser un nombre no inferior a 0 @msg `{key}` must be a number, not {v} `{key}` ha de ser un nombre, no {v} @msg `{key}` must be a string `{key}` ha de ser una cadena +@msg `{key}` must be a profile name or null `{key}` ha de ser un nom de perfil o null @msg `{key}` must be a whole number `{key}` ha de ser un nombre enter @msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` ha de ser un nombre enter entre {min} i {max} (rebut {x}) @msg `{key}` must be an array of these names: {_1} `{key}` ha de ser una matriu d’aquests noms: {_1} diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 216579443f..6bf83b1386 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -118,6 +118,7 @@ PDF Presets… Přednastavení PDF… Perspective Grid Presets… Přednastavení perspektivní mřížky… Color Settings… Nastavení barev… + Assign Profile Přiřadit profil Assign Profile… Přiřadit profil… Keyboard Shortcuts… Klávesové zkratky… # Import Shortcut Set (Keyboard Shortcuts › Import…, File › Open of a .kys file) and its commands. @@ -2061,6 +2062,7 @@ digits Malayalam Malajálamské Simulate Paper Simulovat papír Use Black Point Compensation Použít kompenzaci černého bodu Working CMYK (don't tag) Pracovní CMYK (neoznačovat) + Working RGB (don't tag) Pracovní RGB (neoznačovat) {name} (spot) {name} (přímá) Add to Profiles… Přidat do profilů… Align Stroke to Center Zarovnat tah na střed @@ -4539,6 +4541,7 @@ imageTrace Outline Obrys @msg `{key}` must be a number, not {v} `{key}` musí být číslo, ne {v} @msg {key} must be Unix seconds (an integer) or null {key} musí být unixový čas v sekundách (celé číslo) nebo null @msg `{key}` must be a string `{key}` musí být řetězec +@msg `{key}` must be a profile name or null `{key}` musí být název profilu nebo null @msg `{key}` must be a whole number `{key}` musí být celé číslo @msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` musí být celé číslo mezi {min} a {max} (zadáno {x}) @msg `{key}` must be between {min} and {max} (got {x}) `{key}` musí být mezi {min} a {max} (zadáno {x}) diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 2339ca27ed..fa6a4ded33 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -106,6 +106,7 @@ PDF Presets… PDF-forudindstillinger… Perspective Grid Presets… Forudindstillinger for perspektivgitter… Color Settings… Farveindstillinger… + Assign Profile Tildel profil Assign Profile… Tildel profil… Keyboard Shortcuts… Tastaturgenveje… Import Shortcut Set Importér genvejssæt @@ -1868,6 +1869,7 @@ digits Malayalam Malayalam Simulate Paper Simulér papir Use Black Point Compensation Brug sortpunktskompensation Working CMYK (don't tag) Arbejds-CMYK (mærk ikke) + Working RGB (don't tag) Arbejds-RGB (mærk ikke) {name} (spot) {name} (spotfarve) Add to Profiles… Tilføj til profiler… Align Stroke to Center Justér kontur til midten diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index e5ad28a0c2..15250627ab 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -237,6 +237,7 @@ font-technology Variable Variabel Asset Export Settings Einstellungen für den Asset-Export Assets Assets Assign Zuweisen + Assign Profile Profil zuweisen Assign Profile… Profil zuweisen… Assign: Zuweisen: Attach to Active Plane An aktiver Perspektivebene anbringen @@ -3780,6 +3781,7 @@ effect Points Punkte Windows Windows Working CMYK CMYK-Arbeitsfarbraum Working CMYK (don't tag) CMYK-Arbeitsfarbraum (nicht kennzeichnen) + Working RGB (don't tag) RGB-Arbeitsfarbraum (nicht kennzeichnen) Workspace Arbeitsbereich Workspace {i} Arbeitsbereich {i} Wrinkle Tool Zerknittern-Werkzeug @@ -4146,6 +4148,7 @@ theme Appearance Erscheinungsbild @msg `{key}` must be a number, not {v} `{key}` muss eine Zahl sein, nicht {v} @msg {key} must be Unix seconds (an integer) or null {key} muss eine Unix-Zeit in Sekunden (eine ganze Zahl) oder null sein @msg `{key}` must be a string `{key}` muss eine Zeichenfolge sein +@msg `{key}` must be a profile name or null `{key}` muss ein Profilname oder null sein @msg `{key}` must be a whole number `{key}` muss eine ganze Zahl sein @msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` muss eine ganze Zahl zwischen {min} und {max} sein (erhalten: {x}) @msg `{key}` must be between {min} and {max} (got {x}) `{key}` muss zwischen {min} und {max} liegen (erhalten: {x}) diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index c87ecb5713..fb9be21d29 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -240,6 +240,7 @@ font-technology Variable Μεταβλητές Asset Export Settings Ρυθμίσεις εξαγωγής στοιχείων Assets Στοιχεία Assign Αντιστοίχιση + Assign Profile Ανάθεση προφίλ Assign Profile… Ανάθεση προφίλ… Assign: Αντιστοίχιση: Attach to Active Plane Προσάρτηση στο ενεργό επίπεδο @@ -2374,6 +2375,7 @@ axis Both Και τα δύο @msg `{key}` must be a number, not {v} Το `{key}` πρέπει να είναι αριθμός, όχι {v} @msg {key} must be Unix seconds (an integer) or null {key}: πρέπει να είναι χρόνος Unix σε δευτερόλεπτα (ακέραιος) ή null @msg `{key}` must be a string Το `{key}` πρέπει να είναι συμβολοσειρά +@msg `{key}` must be a profile name or null Το `{key}` πρέπει να είναι όνομα προφίλ ή null @msg `{key}` must be a whole number Το `{key}` πρέπει να είναι ακέραιος αριθμός @msg `{key}` must be a whole number between {min} and {max} (got {x}) Το `{key}` πρέπει να είναι ακέραιος από {min} έως {max} (δόθηκε {x}) @msg `{key}` must be between {min} and {max} (got {x}) Το `{key}` πρέπει να είναι μεταξύ {min} και {max} (δόθηκε {x}) @@ -4153,6 +4155,7 @@ digits Malayalam Μαλαγιάλαμ Width Profiles Προφίλ πλάτους Width Tool Πλάτος (Width Tool) Working CMYK (don't tag) Χώρος εργασίας CMYK (χωρίς ετικέτα) + Working RGB (don't tag) Χώρος εργασίας RGB (χωρίς ετικέτα) Workspace {i} Χώρος εργασίας {i} Wrinkle Tool Πτυχώσεις (Wrinkle Tool) Write Layers Εγγραφή επιπέδων diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index db8da4cc35..567e5772a3 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -262,6 +262,7 @@ font-technology Variable Variables Asset Export Settings Ajustes de exportación de recursos Assets Recursos Assign Asignar + Assign Profile Asignar perfil Assign Profile… Asignar perfil… Assign: Asignar: Attach to Active Plane Adjuntar al plano activo @@ -3869,6 +3870,7 @@ digits Malayalam Malayálam Windows Windows Working CMYK CMYK de trabajo Working CMYK (don't tag) CMYK de trabajo (sin etiquetar) + Working RGB (don't tag) RGB de trabajo (sin etiquetar) Workspace Espacio de trabajo Workspace {i} Espacio de trabajo {i} Wrinkle Tool Herramienta Arrugar @@ -4230,6 +4232,7 @@ axis Both Ambos @msg `{key}` must be a number, not {v} `{key}` debe ser un número, no {v} @msg {key} must be Unix seconds (an integer) or null {key} debe ser un tiempo Unix en segundos (un entero) o null @msg `{key}` must be a string `{key}` debe ser un texto +@msg `{key}` must be a profile name or null `{key}` debe ser un nombre de perfil o null @msg `{key}` must be a whole number `{key}` debe ser un número entero @msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` debe ser un número entero entre {min} y {max} (se recibió {x}) @msg `{key}` must be between {min} and {max} (got {x}) `{key}` debe estar entre {min} y {max} (se recibió {x}) diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index c3d7fcfb91..28fb1401c5 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -126,6 +126,7 @@ PDF Presets… PDF-esiasetukset… Perspective Grid Presets… Perspektiiviruudukon esiasetukset… Color Settings… Väriasetukset… + Assign Profile Määritä profiili Assign Profile… Määritä profiili… Keyboard Shortcuts… Pikanäppäimet… Import Shortcut Set Tuo pikanäppäinjoukko @@ -2022,6 +2023,7 @@ digits Malayalam Malajalam Simulate Paper Simuloi paperia Use Black Point Compensation Käytä mustan pisteen kompensointia Working CMYK (don't tag) Työ-CMYK (ei tunnistetta) + Working RGB (don't tag) Työ-RGB (ei tunnistetta) {name} (spot) {name} (spottiväri) Add to Profiles… Lisää profiileihin… Align Stroke to Center Tasaa viiva keskelle @@ -4453,6 +4455,7 @@ imageTrace Outline Ääriviiva @msg `{key}` must be a number, not {v} `{key}` on oltava luku, ei {v} @msg {key} must be Unix seconds (an integer) or null {key} on oltava Unix-aika sekunteina (kokonaisluku) tai null @msg `{key}` must be a string `{key}` on oltava merkkijono +@msg `{key}` must be a profile name or null `{key}` on oltava profiilin nimi tai null @msg `{key}` must be a whole number `{key}` on oltava kokonaisluku @msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` on oltava kokonaisluku väliltä {min}–{max} (saatiin {x}) @msg `{key}` must be between {min} and {max} (got {x}) `{key}` on oltava väliltä {min}–{max} (saatiin {x}) diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index e557d273c9..24bcb70ff3 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -263,6 +263,7 @@ font-technology Variable Variables Asset Export Settings Paramètres d’exportation de ressources Assets Ressources Assign Attribuer + Assign Profile Attribuer un profil Assign Profile… Attribuer un profil… Assign: Attribuer : Attach to Active Plane Associer au plan actif @@ -3860,6 +3861,7 @@ digits Malayalam Malayalam Windows Windows Working CMYK CMJN de travail Working CMYK (don't tag) CMJN de travail (sans balise) + Working RGB (don't tag) RVB de travail (sans balise) Workspace Espace de travail Workspace {i} Espace de travail {i} Wrinkle Tool Outil Fronces @@ -4219,6 +4221,7 @@ axis Both Les deux @msg `{key}` must be a number, not {v} `{key}` doit être un nombre, pas {v} @msg {key} must be Unix seconds (an integer) or null {key} doit être un temps Unix en secondes (un entier) ou null @msg `{key}` must be a string `{key}` doit être une chaîne +@msg `{key}` must be a profile name or null `{key}` doit être un nom de profil ou null @msg `{key}` must be a whole number `{key}` doit être un nombre entier @msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` doit être un nombre entier compris entre {min} et {max} (reçu : {x}) @msg `{key}` must be between {min} and {max} (got {x}) `{key}` doit être compris entre {min} et {max} (reçu : {x}) diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 129dfeea3c..f48b915741 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -5308,6 +5308,7 @@ antiAlias Sharp शार्प Words Longer Than: से ज्यादा शब्द: Working CMYK वर्किंग CMYK Working CMYK (don't tag) कार्य CMYK (टैग नहीं) + Working RGB (don't tag) कार्य RGB (टैग नहीं) Working RGB वर्किंग RGB Workspace वर्कस्पेस Workspace {i} वर्कस्पेस {i} diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 78c096b113..48123fea3c 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -360,6 +360,7 @@ font-technology Variable Változó Asset Export Settings Elemexportálási beállítások Assets Elemek Assign Hozzárendelés + Assign Profile Profil hozzárendelése Assign Profile… Profil hozzárendelése… Assign: Hozzárendelés: Attach to Active Plane Csatolás az aktív síkhoz @@ -4603,6 +4604,7 @@ mark-style Japanese Japán Windows Windows Working CMYK Munka-CMYK Working CMYK (don't tag) Munka-CMYK (címke nélkül) + Working RGB (don't tag) Munka-RGB (címke nélkül) Working RGB Munka-RGB Workspace Munkaterület Workspace {i} Munkaterület {i} @@ -4740,6 +4742,7 @@ imageTrace Outline Körvonal @msg `{key}` must be a number, not {v} `{key}`: szám kell, ez nem az: {v} @msg {key} must be Unix seconds (an integer) or null {key}: Unix-idő kell másodpercben (egész szám) vagy null @msg `{key}` must be a string `{key}`: szöveg kell +@msg `{key}` must be a profile name or null `{key}`: profilnév vagy null kell @msg `{key}` must be a whole number `{key}`: egész szám kell @msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}`: {min} és {max} közötti egész szám kell (kapott érték: {x}) @msg `{key}` must be between {min} and {max} (got {x}) `{key}`: {min} és {max} közötti érték kell (kapott érték: {x}) diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 002740e114..4c1c522c38 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -106,6 +106,7 @@ PDF Presets… Preset PDF… Perspective Grid Presets… Preset kisi perspektif… Color Settings… Pengaturan warna… + Assign Profile Tetapkan profil Assign Profile… Tetapkan profil… Keyboard Shortcuts… Pintasan papan tik… Import Shortcut Set Impor set pintasan @@ -1868,6 +1869,7 @@ digits Malayalam Malayalam Simulate Paper Simulasikan kertas Use Black Point Compensation Gunakan kompensasi titik hitam Working CMYK (don't tag) CMYK kerja (tanpa tag) + Working RGB (don't tag) RGB kerja (tanpa tag) {name} (spot) {name} (spot) Add to Profiles… Tambahkan ke profil… Align Stroke to Center Ratakan stroke ke tengah diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 7f230b10f4..f991327e18 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -262,6 +262,7 @@ font-technology Variable Variabili Asset Export Settings Impostazioni esportazione risorse Assets Risorse Assign Assegna + Assign Profile Assegna profilo Assign Profile… Assegna profilo… Assign: Assegna: Attach to Active Plane Associa al piano attivo @@ -3907,6 +3908,7 @@ digits Malayalam Malayalam Windows Windows Working CMYK CMYK di lavoro Working CMYK (don't tag) CMYK di lavoro (senza tag) + Working RGB (don't tag) RGB di lavoro (senza tag) Workspace Area di lavoro Workspace {i} Area di lavoro {i} Wrinkle Tool Strumento Grinza @@ -4273,6 +4275,7 @@ axis Both Entrambi @msg `{key}` must be a number, not {v} `{key}` deve essere un numero, non {v} @msg {key} must be Unix seconds (an integer) or null {key} deve essere un tempo Unix in secondi (un intero) o null @msg `{key}` must be a string `{key}` deve essere una stringa +@msg `{key}` must be a profile name or null `{key}` deve essere un nome di profilo o null @msg `{key}` must be a whole number `{key}` deve essere un numero intero @msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` deve essere un numero intero tra {min} e {max} (ricevuto {x}) @msg `{key}` must be between {min} and {max} (got {x}) `{key}` deve essere compreso tra {min} e {max} (ricevuto {x}) diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 88c9013b2e..571ace2035 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -186,6 +186,7 @@ digits Malayalam マラヤーラム数字 PDF Presets… PDF プリセット… Perspective Grid Presets… 遠近グリッドプリセット… Color Settings… カラー設定… + Assign Profile プロファイルの指定 Assign Profile… プロファイルの指定… Keyboard Shortcuts… キーボードショートカット… Preferences… 環境設定… @@ -3936,6 +3937,7 @@ file-filter Templates テンプレート Weight 線幅 Windows Windows Working CMYK (don't tag) 作業用 CMYK (タグなし) + Working RGB (don't tag) 作業用 RGB (タグなし) Workspace {i} ワークスペース {i} Yellow イエロー [Normal Character Style] [標準文字スタイル] @@ -4458,6 +4460,7 @@ swatch Bright Violet ビビッドバイオレット @msg `{key}` must be a #rrggbb color `{key}` は #rrggbb 形式のカラーにしてください @msg `{key}` must be a #rrggbb colour `{key}` は #rrggbb 形式のカラーにしてください @msg `{key}` must be a string `{key}` は文字列にしてください +@msg `{key}` must be a profile name or null `{key}` はプロファイル名または null にしてください @msg `{key}` must be a whole number `{key}` は整数にしてください @msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` は {min} から {max} までの整数にしてください (指定値: {x}) @msg `{key}` must be between {min} and {max} (got {x}) `{key}` は {min} から {max} までの値にしてください (指定値: {x}) diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 4b7dfdcac7..14735f5c99 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -117,6 +117,7 @@ PDF Presets… PDF-ის პრესეტები… Perspective Grid Presets… პერსპექტივის ბადის პრესეტები… Color Settings… ფერის პარამეტრები… + Assign Profile პროფილის მინიჭება Assign Profile… პროფილის მინიჭება… Keyboard Shortcuts… კლავიატურის მალსახმობები… Preferences… პარამეტრები… @@ -1793,6 +1794,7 @@ digits Malayalam მალაიალამური Simulate Paper ქაღალდის იმიტაცია Use Black Point Compensation შავი წერტილის კომპენსაციის გამოყენება Working CMYK (don't tag) სამუშაო CMYK (ტეგის გარეშე) + Working RGB (don't tag) სამუშაო RGB (ტეგის გარეშე) {name} (spot) {name} (სპოტ) Add to Profiles… პროფილებში დამატება… Align Stroke to Center კონტურის ცენტრზე სწორება @@ -4011,6 +4013,7 @@ font-technology Variable ცვლადი @msg `{key}` must be a number, not {v} `{key}` უნდა იყოს რიცხვი და არა {v} @msg {key} must be Unix seconds (an integer) or null {key} უნდა იყოს Unix-დრო წამებში (მთელი რიცხვი) ან null @msg `{key}` must be a string `{key}` უნდა იყოს სტრიქონი +@msg `{key}` must be a profile name or null `{key}` უნდა იყოს პროფილის სახელი ან null @msg `{key}` must be a whole number `{key}` უნდა იყოს მთელი რიცხვი @msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` უნდა იყოს მთელი რიცხვი {min}-დან {max}-მდე (მიღებულია {x}) @msg `{key}` must be between {min} and {max} (got {x}) `{key}` უნდა იყოს {min}-სა და {max}-ს შორის (მიღებულია {x}) diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index ac3f3d0cf6..40cb9ac364 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -179,6 +179,7 @@ digits Malayalam 말라얄람어 PDF Presets… PDF 프리셋… Perspective Grid Presets… 원근 격자 프리셋… Color Settings… 색상 설정… + Assign Profile 프로파일 지정 Assign Profile… 프로파일 지정… Keyboard Shortcuts… 키보드 단축키… Preferences… 환경 설정… @@ -3924,6 +3925,7 @@ file-filter Templates 템플릿 Weight 두께 Windows Windows Working CMYK (don't tag) 작업용 CMYK (태그 안 함) + Working RGB (don't tag) 작업용 RGB (태그 안 함) Workspace {i} 작업 영역 {i} Yellow 노랑 [Normal Character Style] [기본 문자 스타일] @@ -4385,6 +4387,7 @@ theme Appearance 외관 @msg `{key}` must be a #rrggbb color `{key}`은(는) #rrggbb 색상이어야 합니다 @msg `{key}` must be a #rrggbb colour `{key}`은(는) #rrggbb 색상이어야 합니다 @msg `{key}` must be a string `{key}`은(는) 문자열이어야 합니다 +@msg `{key}` must be a profile name or null `{key}`은(는) 프로파일 이름 또는 null이어야 합니다 @msg `{key}` must be a whole number `{key}`은(는) 정수여야 합니다 @msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}`은(는) {min}에서 {max} 사이의 정수여야 합니다({x} 입력됨) @msg `{key}` must be between {min} and {max} (got {x}) `{key}`은(는) {min}에서 {max} 사이여야 합니다({x} 입력됨) diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 0622156953..9cbc3371d7 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -106,6 +106,7 @@ PDF Presets… PDF-voorinstellingen… Perspective Grid Presets… Voorinstellingen voor perspectiefraster… Color Settings… Kleurinstellingen… + Assign Profile Profiel toewijzen Assign Profile… Profiel toewijzen… Keyboard Shortcuts… Sneltoetsen… Import Shortcut Set Sneltoetsenset importeren @@ -1882,6 +1883,7 @@ digits Malayalam Malayalam Simulate Paper Papier simuleren Use Black Point Compensation Zwartpuntcompensatie gebruiken Working CMYK (don't tag) Werk-CMYK (niet taggen) + Working RGB (don't tag) Werk-RGB (niet taggen) {name} (spot) {name} (spotkleur) Add to Profiles… Toevoegen aan profielen… Align Stroke to Center Lijn in het midden uitlijnen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 11dd0dc99a..24029b7140 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -121,6 +121,7 @@ PDF Presets… Ustawienia PDF… Perspective Grid Presets… Ustawienia siatki perspektywy… Color Settings… Ustawienia kolorów… + Assign Profile Przypisz profil Assign Profile… Przypisz profil… Keyboard Shortcuts… Skróty klawiaturowe… Preferences… Preferencje… @@ -1979,6 +1980,7 @@ digits Malayalam Malajalam Simulate Paper Symuluj papier Use Black Point Compensation Użyj kompensacji punktu czerni Working CMYK (don't tag) Roboczy CMYK (bez znacznika) + Working RGB (don't tag) Roboczy RGB (bez znacznika) {name} (spot) {name} (dodatkowy) Add to Profiles… Dodaj do profili… Align Stroke to Center Wyrównaj obrys do środka @@ -4362,6 +4364,7 @@ imageTrace Outline Kontur @msg `{key}` must be a number, not {v} `{key}` musi być liczbą, nie {v} @msg {key} must be Unix seconds (an integer) or null {key} musi być czasem Unix w sekundach (liczbą całkowitą) lub null @msg `{key}` must be a string `{key}` musi być ciągiem znaków +@msg `{key}` must be a profile name or null `{key}` musi być nazwą profilu lub null @msg `{key}` must be a whole number `{key}` musi być liczbą całkowitą @msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` musi być liczbą całkowitą od {min} do {max} (podano {x}) @msg `{key}` must be between {min} and {max} (got {x}) `{key}` musi mieścić się w zakresie od {min} do {max} (podano {x}) diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 62b2fa772a..6481fc6e77 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -255,6 +255,7 @@ antiAlias Sharp Nítido Asset Export Settings Configurações de Exportação de Ativos Assets Ativos Assign Atribuir + Assign Profile Atribuir Perfil Assign Profile… Atribuir Perfil… Assign: Atribuir: Attach to Active Plane Anexar ao Plano Ativo @@ -3780,6 +3781,7 @@ axis Both Ambos @msg `{key}` must be a number, not {v} `{key}` deve ser um número, não {v} @msg {key} must be Unix seconds (an integer) or null {key} deve ser um tempo Unix em segundos (um inteiro) ou null @msg `{key}` must be a string `{key}` deve ser uma string +@msg `{key}` must be a profile name or null `{key}` deve ser um nome de perfil ou null @msg `{key}` must be a whole number `{key}` deve ser um número inteiro @msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` deve ser um número inteiro entre {min} e {max} (recebido {x}) @msg `{key}` must be between {min} and {max} (got {x}) `{key}` deve estar entre {min} e {max} (recebido {x}) @@ -5093,6 +5095,7 @@ axis Both Ambos Weight Espessura Windows Windows Working CMYK (don't tag) CMYK de Trabalho (não marcar) + Working RGB (don't tag) RGB de Trabalho (não marcar) Workspace {i} Espaço de Trabalho {i} Yellow Amarelo [Normal Character Style] [Estilo de Caractere Normal] diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 87c359b83b..7fbf96b913 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -260,6 +260,7 @@ font-technology Variable Вариативные Asset Export Settings Параметры экспорта ресурсов Assets Ресурсы Assign Назначить + Assign Profile Назначить профиль Assign Profile… Назначить профиль… Assign: Назначить: Attach to Active Plane Прикрепить к активной плоскости @@ -3901,6 +3902,7 @@ digits Malayalam Малаялам Windows Windows Working CMYK Рабочий CMYK Working CMYK (don't tag) Рабочий CMYK (без пометки) + Working RGB (don't tag) Рабочий RGB (без пометки) Workspace Рабочая среда Workspace {i} Рабочая среда {i} Wrinkle Tool Инструмент «Морщины» @@ -4267,6 +4269,7 @@ axis Both Оба @msg `{key}` must be a number, not {v} `{key}` должно быть числом, а не {v} @msg {key} must be Unix seconds (an integer) or null {key} должно быть временем Unix в секундах (целым числом) или null @msg `{key}` must be a string `{key}` должно быть строкой +@msg `{key}` must be a profile name or null `{key}` должно быть именем профиля или null @msg `{key}` must be a whole number `{key}` должно быть целым числом @msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` должно быть целым числом от {min} до {max} (получено {x}) @msg `{key}` must be between {min} and {max} (got {x}) `{key}` должно быть от {min} до {max} (получено {x}) diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 92de527ff1..299f8bd977 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -251,6 +251,7 @@ font-technology Variable Variabla Asset Export Settings Inställningar för resursexport Assets Resurser Assign Tilldela + Assign Profile Tilldela profil Assign Profile… Tilldela profil… Assign: Tilldela: Attach to Active Plane Koppla till aktivt plan @@ -3790,6 +3791,7 @@ digits Malayalam Malayalam Windows Windows Working CMYK Arbets-CMYK Working CMYK (don't tag) Arbets-CMYK (tagga inte) + Working RGB (don't tag) Arbets-RGB (tagga inte) Workspace Arbetslayout Workspace {i} Arbetslayout {i} Wrinkle Tool Skrynkelverktyg diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index e2ec9c6851..97c674390d 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -247,6 +247,7 @@ font-technology Variable Değişken Asset Export Settings Varlık Dışa Aktarma Ayarları Assets Varlıklar Assign Ata + Assign Profile Profil Ata Assign Profile… Profil Ata… Assign: Ata: Attach to Active Plane Etkin Düzlem'e Ekle @@ -3676,6 +3677,7 @@ digits Malayalam Malayalam Windows Windows Working CMYK CMYK'de çalışıyor Working CMYK (don't tag) CMYK çalışıyor (etiketlemeyin) + Working RGB (don't tag) Çalışan RGB (etiketlemeyin) Workspace Çalışma Alanı Workspace {i} Çalışma Alanı {i} Wrinkle Tool Kırışıklık Aracı @@ -4017,6 +4019,7 @@ axis Both Her İkisi @msg `{key}` must be a #rrggbb colour `{key}` bir #rrggbb rengi olmalıdır @msg `{key}` must be a number `{key}` bir sayı olmalıdır @msg `{key}` must be a string `{key}` bir dize olmalıdır +@msg `{key}` must be a profile name or null `{key}` bir profil adı ya da null olmalıdır @msg `{key}` must be a whole number `{key}` bir tam sayı olmalıdır @msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}`, {min} ile {max} arasında bir tam sayı olmalıdır ({x}'yi aldı) @msg `{key}` must be between {min} and {max} (got {x}) `{key}`, {min} ile {max} arasında olmalıdır ({x}'yi aldı) diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 235a4d389e..146d63b2c8 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -256,6 +256,7 @@ font-technology Variable Варіативні Asset Export Settings Параметри експорту ресурсів Assets Ресурси Assign Призначити + Assign Profile Призначити профіль Assign Profile… Призначити профіль… Assign: Призначити: Attach to Active Plane Прикріпити до активної площини @@ -3900,6 +3901,7 @@ effect Points Точки Windows Windows Working CMYK Робочий CMYK Working CMYK (don't tag) Робочий CMYK (без позначки) + Working RGB (don't tag) Робочий RGB (без позначки) Workspace Робоче середовище Workspace {i} Робоче середовище {i} Wrinkle Tool Інструмент «Зморшки» @@ -4257,6 +4259,7 @@ axis Both Обидві @msg `{key}` must be a number, not {v} `{key}` має бути числом, а не {v} @msg {key} must be Unix seconds (an integer) or null {key} має бути часом Unix у секундах (цілим числом) або null @msg `{key}` must be a string `{key}` має бути рядком +@msg `{key}` must be a profile name or null `{key}` має бути назвою профілю або null @msg `{key}` must be a whole number `{key}` має бути цілим числом @msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` має бути цілим числом від {min} до {max} (отримано {x}) @msg `{key}` must be between {min} and {max} (got {x}) `{key}` має бути від {min} до {max} (отримано {x}) diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 87d2c22625..33aab976a1 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -289,6 +289,7 @@ font-technology Variable Biến thiên Asset Export Settings Cài đặt xuất asset Assets Asset Assign Gán + Assign Profile Gán hồ sơ màu Assign Profile… Gán hồ sơ màu… Assign: Gán: Attach to Active Plane Gắn vào mặt phẳng đang hoạt động @@ -4270,6 +4271,7 @@ mark-style Japanese Kiểu Nhật Windows Windows Working CMYK CMYK làm việc Working CMYK (don't tag) CMYK làm việc (không gắn thẻ) + Working RGB (don't tag) RGB làm việc (không gắn thẻ) Workspace Không gian làm việc Workspace {i} Không gian làm việc {i} Wrinkle Tool Công cụ nhăn @@ -4393,6 +4395,7 @@ theme Appearance Giao diện @msg `{key}` must be a number, not {v} `{key}` phải là một số, không phải {v} @msg {key} must be Unix seconds (an integer) or null {key} phải là thời gian Unix tính bằng giây (số nguyên) hoặc null @msg `{key}` must be a string `{key}` phải là một chuỗi +@msg `{key}` must be a profile name or null `{key}` phải là tên hồ sơ màu hoặc null @msg `{key}` must be a whole number `{key}` phải là số nguyên @msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` phải là số nguyên từ {min} đến {max} (nhận được {x}) @msg `{key}` must be between {min} and {max} (got {x}) `{key}` phải nằm trong khoảng {min} đến {max} (nhận được {x}) diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index eea8526b99..b1c8c4c8a1 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -207,6 +207,7 @@ antiAlias Sharp 锐利 Asset Export Settings 资源导出设置 Assets 资源 Assign 指定 + Assign Profile 指定配置文件 Assign Profile… 指定配置文件… Assign: 指定: Attach to Active Plane 附加到活动平面 @@ -3751,6 +3752,7 @@ digits Malayalam 马拉雅拉姆数字 Windows Windows Working CMYK 工作 CMYK Working CMYK (don't tag) 工作 CMYK (不加标签) + Working RGB (don't tag) 工作 RGB (不加标签) Workspace 工作区 Workspace {i} 工作区 {i} Wrinkle Tool 褶皱工具 @@ -4172,6 +4174,7 @@ imageTrace Outline 轮廓 @msg `{key}` must be `auto` or a language code such as `en` or `zh-hant` `{key}` 必须是 `auto` 或语言代码,例如 `en` 或 `zh-hant` @msg `{key}` must be a #rrggbb colour `{key}` 必须是 #rrggbb 颜色 @msg `{key}` must be a string `{key}` 必须是字符串 +@msg `{key}` must be a profile name or null `{key}` 必须是配置文件名称或 null @msg `{key}` must be a whole number `{key}` 必须是整数 @msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` 必须是介于 {min} 和 {max} 之间的整数 (实际为 {x}) @msg `{key}` must be between {min} and {max} (got {x}) `{key}` 必须介于 {min} 和 {max} 之间 (实际为 {x}) diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 60cc2d1d43..83c8754f65 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -220,6 +220,7 @@ font-technology Variable 可變 Asset Export Settings 資產匯出設定 Assets 資產 Assign 指定 + Assign Profile 指定描述檔 Assign Profile… 指定描述檔… Assign: 指定: Attach to Active Plane 附加至作用中平面 @@ -1982,6 +1983,7 @@ file-filter Templates 範本 @msg `{key}` must be `auto` or a language code such as `en` or `zh-hant` `{key}` 必須是 `auto` 或語言代碼,例如 `en` 或 `zh-hant` @msg `{key}` must be a #rrggbb colour `{key}` 必須是 #rrggbb 顏色 @msg `{key}` must be a string `{key}` 必須是字串 +@msg `{key}` must be a profile name or null `{key}` 必須是描述檔名稱或 null @msg `{key}` must be a whole number `{key}` 必須是整數 @msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}` 必須是介於 {min} 和 {max} 之間的整數 (收到 {x}) @msg `{key}` must be between {min} and {max} (got {x}) `{key}` 必須介於 {min} 和 {max} 之間 (收到 {x}) @@ -4877,6 +4879,7 @@ digits Malayalam 馬來亞拉姆數字 Windows Windows Working CMYK 使用中的 CMYK Working CMYK (don't tag) 工作 CMYK (不加標記) + Working RGB (don't tag) 工作 RGB (不加標記) Workspace 工作區 Workspace {i} 工作區 {i} Wrinkle Tool 皺摺工具 diff --git a/crates/ui-egui/src/panels/separations.rs b/crates/ui-egui/src/panels/separations.rs index 04709b42c7..bf501113ab 100644 --- a/crates/ui-egui/src/panels/separations.rs +++ b/crates/ui-egui/src/panels/separations.rs @@ -76,7 +76,7 @@ fn plates_section(app: &mut VectorcraftApp, ui: &mut Ui) { /// A dropdown of the `kind` profiles after `extra` (a built-in entry, shown in the UI language); /// profile names are shown as they are. Returns the chosen name. -fn profile_dropdown(ui: &mut Ui, id: &str, current: &str, kind: ProfileKind, extra: Option<&str>) -> Option { +pub(crate) fn profile_dropdown(ui: &mut Ui, id: &str, current: &str, kind: ProfileKind, extra: Option<&str>) -> Option { let names: Vec = extra.map(str::to_string).into_iter().chain(cms::profiles().into_iter().filter(|p| p.kind == kind).map(|p| p.name)).collect(); let refs: Vec<&str> = names.iter().map(|n| super::label_or_name(n, extra == Some(n.as_str()))).collect(); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 704ec14d6f..dbaf2b48ab 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -574,8 +574,12 @@ Document Setup: File → Document Setup… (`file.documentSetup`, Cmd+Alt+P) ope are what `document.setup` reports (`units`, `bleed` [top, bottom, left, right] in pt, `outlineImages`, `highlightSubstitutedGlyphs`, `gridSize`, `gridColors`, `simulatePaper`, `flattenerPreset`, `discardWhiteOverprint`, `language`, `quotes`, `typographersQuotes`, `superscript`, `subscript`, `smallCapsSize`, -`exportText`, `backgroundContents`) plus `tab` (`General`, `Transparency` or `Type`) and `bleedLinked`. -`ui.dialog.confirm` runs `document.setup` with them as one undo step; a bad value answers with an error and the dialog +`exportText`, `backgroundContents`) plus `tab` (`General`, `Transparency`, `Type` or `Color`) and `bleedLinked`, and +the Color tab's `colorMode` (`rgb` or `cmyk`), `convertColors` (a mode change converts the colors; `false` keeps their +numbers), `rgbProfile` and `cmykProfile` (a profile name, or null for the working space). +`ui.dialog.confirm` runs `document.setup` with them as one undo step. When the Color tab's fields differ from the +document, it runs `document.setup`, then `edit.assignProfile` with the changed profiles, then `file.documentColorMode` +in one `command.batch`, and the document's changes are one undo step. A bad value answers with an error and the dialog stays open. New Document: File → New… (`file.newDialog`, Cmd+N) opens the `newDocument` dialog. Its fields are `file.new`'s params diff --git a/docs/mcp.md b/docs/mcp.md index da260b6823..d3e2fd78a3 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5146,6 +5146,9 @@ and small caps proportions, SVG text export) and the background contents (white: art). Pass any of these keys to change them in one undo step; `document.setUnits` stays as an alias for `units`. A file whose bleed (0 to 72 pt a side), superscript and subscript sizes (1 to 500%) and positions (-500 to 500%) or small caps size (1 to 200%) lies outside those ranges opens with the nearer end of each range. +`document.setup` does not take the color mode or the assigned profiles. The Document Setup dialog's Color tab sets +them with `edit.assignProfile` and `file.documentColorMode`: its OK runs `document.setup`, then `edit.assignProfile`, +then `file.documentColorMode` in one `command.batch`, and the document's changes are one undo step. ```json {"name":"run_command","arguments":{"command":"document.setup","params":{"gridColors":"Blue","gridSize":"large","language":"German","bleed":9}}} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 18d50447ff..03c5def4cb 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -675,6 +675,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Window › New Window (G7, #4116):** New Window (`window.newWindow`) opens another view of the current document ([public source](https://helpx.adobe.com/illustrator/using/viewing-artwork.html) describes showing one document in several windows at once), with its own zoom, center, rotation, artboard and Outline or Preview, as a tab of its own; an edit in one view shows in every view. Tile, Cascade, Float in Window, Float All in Windows and Consolidate All Windows arrange each view as they arrange a document, and Next and Previous Document step through the views. Closing a view leaves the document open; closing its last view closes the document, asking to save it first. The view's number after the name in its tab ("Name:2", while the document has more than one view) is VectorCraft's own. Tests: `crates/ui-egui/src/doc_windows/tests.rs`. - **Document Setup modal handoff:** Document Setup → Edit Artboards applies valid settings, closes the dialog and activates the Artboard tool. Pointer-click tests cover successful handoff, invalid settings, OK, Cancel and Undo in `dialogs/document_setup.rs`. +- **Document Setup › Color:** a Color tab shows the document color mode (RGB Color or CMYK Color), a Convert to CMYK or Convert to RGB box for a mode change (cleared: the colors keep their numbers), and the RGB and CMYK profiles assigned to the document, with a working-space entry for none. OK runs `edit.assignProfile` and then `file.documentColorMode` with the dialog's other changes in one `command.batch`, and the document's changes are one undo step. An unchanged Color tab adds no color commands, and Cancel, or OK with nothing changed, records no undo step. A Color tab field with a value of the wrong kind is an error, and the dialog stays open. The tab is VectorCraft's own layout; File › Document Color Mode and Edit › Assign Profile… stay as they are. Tests in `dialogs/document_setup.rs`. - **New Document's More Settings and Save Preset:** More Settings opens a pop-up over New Document with only Number of Artboards, the artboard layout, Change to Right-to-Left Layout, Spacing and Columns; Done, Escape or a click outside closes it and keeps the values. Save Preset turns the top of Preset Details into a Save Document Preset heading with the preset's name, Cancel and Save, and hides the document's name, Close and Create until the preset is saved or canceled; nothing in that mode creates a document. Save Preset's default name is the first "Preset N" no saved preset has. VectorCraft's own layout. Harness tests in `dialogs/new_document.rs` cover both. - **Control bar with the Artboard tool:** while the Artboard tool is in use, the Control bar shows the active artboard's controls in place of the object controls: Preset, Portrait and Landscape, New Artboard, Delete Artboard, the Name field, Artboard Options, Rearrange All Artboards, the Transform link, Move and Scale Artwork with Artboard and, while the bar has room, the reference point with X and Y, and W and H with the Constrain Width and Height Proportions link. Each runs the command its Properties or Transform panel counterpart runs (`artboard.setProps`, `artboard.new`, `artboard.delete`, `tool.options {tool: "artboard"}`), and the reference point is the one the Transform panel uses. In an 800-point window the controls up to the Transform link fit, Move and Scale Artwork with Artboard show as their boxes alone with their names in tooltips, and the link opens the Transform panel with the artboard's position and size. Pointer and key tests in `panels/artboards.rs` cover the controls, W about the reference point, the orientation and the name. Public reference: [Using multiple artboards](https://web.archive.org/web/20210925094818/https://helpx.adobe.com/illustrator/using/using-multiple-artboards.html) (archived copy). - **Names of new and renamed artboards, brushes and patterns:** `artboard.new` and `artboard.setProps` trim the white space around the name they are given, as the Artboards panel's rename does, and a blank name gives the artboard its default name, Artboard N for the Nth artboard. `brush.new`, `brush.duplicate`, `brush.options`, `object.pattern.make`, `object.pattern.saveCopy` and `pattern.options` trim the name as the Brush Options dialog and the Pattern Options panel do, and compare the trimmed name with the names there are; a blank name gives the name each command gives when there is none, and Brush Options and Pattern Options keep the name. Tests in `tests/panelcmds.rs`, `tests/brushsym.rs` and `tests/pattern.rs` cover each command. Public references: [Using multiple artboards](https://web.archive.org/web/20210925094818/https://helpx.adobe.com/illustrator/using/using-multiple-artboards.html), [Brushes](https://web.archive.org/web/20251018101754/https://helpx.adobe.com/illustrator/using/brushes.html) and [Create and edit patterns](https://web.archive.org/web/20211031180358/https://helpx.adobe.com/illustrator/using/create-edit-patterns.html) (archived copies). From dea30d6328aa39fe491f494e80a104df7bc1a4e7 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 19:14:05 -0800 Subject: [PATCH 07/15] Scripts: a finally block left early drops its saved throw site A finally block that break or continue leaves no longer keeps the saved site of the exception it ran for. A Jump now removes the sites at or above the stack's length, as a return already does, so a collection after the stack grows back over that slot does not root and count the old code. New tests in crates/js/src/host_tests.rs: an error passed on by a finally block that collects garbage under a 1 MiB heap is reported at its throw; the saved site keeps direct eval code live through a collection; a host abort inside a finally block leaves no saved site; and break, continue and return out of a finally block drop the site. --- crates/js/src/host_tests.rs | 98 +++++++++++++++++++++++++++++++++++++ crates/js/src/interp.rs | 12 +++-- docs/target-app-parity.md | 1 + 3 files changed, 107 insertions(+), 4 deletions(-) diff --git a/crates/js/src/host_tests.rs b/crates/js/src/host_tests.rs index 6fc6c4e04f..6aaa847ceb 100644 --- a/crates/js/src/host_tests.rs +++ b/crates/js/src/host_tests.rs @@ -436,6 +436,104 @@ fn a_marked_host_error_that_passes_through_a_finally_block_says_where_it_was_rai assert_eq!(r.error_mark(), Some(7)); } +/// A realm with a 1 MiB heap, the size that the `finally` blocks of these tests collect garbage in. +fn small_heap_realm() -> Realm { + let mut r = Realm::new(); + r.set_limits(Limits { fuel: 200_000_000, heap_bytes: 1 << 20, ..Limits::default() }); + r +} + +/// Begins a run of `source` and advances it one instruction at a time until a `finally` block runs +/// for an exception whose throw site is saved. +fn start_until_a_throw_site_is_saved(r: &mut Realm, source: &str) { + r.start(source).unwrap(); + while r.interp().finally_sites.is_empty() { + assert_eq!(r.advance(1), Ok(Progress::Paused), "{source}"); + } +} + +/// The collections that a `finally` block's allocations cause under a small heap limit keep the +/// site its exception was raised at, and the run's end leaves no saved site behind. +#[test] +fn an_error_that_passes_through_a_finally_block_that_collects_garbage_says_where_it_was_raised() { + let mut r = small_heap_realm(); + let source = "try {\n throw new Error('original');\n} finally {\n for (var i = 0; i < 20000; i++) { var waste = [i, i + 1, i + 2]; }\n}"; + start_until_a_throw_site_is_saved(&mut r, source); + r.collect_garbage(); + let collections = r.interp().heap.collections; + let e = loop { + match r.advance(1_000) { + Ok(Progress::Paused) => assert_eq!(r.interp().finally_sites.len(), 1, "the site stays saved while the block runs"), + Ok(Progress::Done(v)) => panic!("the run ended with {v:?}"), + Err(e) => break e, + } + }; + assert!(r.interp().heap.collections > collections, "the block's allocations collect garbage"); + assert_eq!((e.kind, e.line, e.column, e.message.as_str()), (RunErrorKind::Thrown, 2, 3, "Error: original")); + assert!(r.interp().finally_sites.is_empty()); +} + +/// The code of a saved throw site stays live through a collection: direct `eval` code that +/// threw is not part of the code of any frame still running. +#[test] +fn a_saved_throw_site_keeps_its_code_live() { + let mut r = small_heap_realm(); + let source = "try {\n eval(\"var unused = 'padding padding padding padding';\\nthrow new Error('original');\");\n} finally {\n for (var i = 0; i < 2000; i++) { var waste = [i]; }\n}"; + start_until_a_throw_site_is_saved(&mut r, source); + let with_site = r.collect_garbage(); + let sites = std::mem::take(&mut r.interp().finally_sites); + let without_site = r.collect_garbage(); + r.interp().finally_sites = sites; + assert_eq!(r.collect_garbage(), with_site, "the saved site's code is counted again"); + assert!(with_site > without_site, "{with_site} <= {without_site}"); + let e = finish(&mut r, 1_000).0.unwrap_err(); + assert_eq!((e.kind, e.line, e.message.as_str()), (RunErrorKind::Thrown, 2, "Error: original")); + assert!(r.interp().finally_sites.is_empty()); +} + +/// A run that a host function stops inside a `finally` block leaves no saved throw site behind. +#[test] +fn a_run_stopped_in_a_finally_block_leaves_no_saved_throw_site() { + let mut r = realm(1_000_000); + let f: HostFunction = Rc::new(|_| Err(HostError::Abort("stopped".into()))); + r.define_host_object("host", vec![("abort".into(), HostItem::Function(f))]).unwrap(); + let e = r.run("try { throw new Error('original'); } finally { host.abort(); }").unwrap_err(); + assert_eq!((e.kind, e.message.as_str()), (RunErrorKind::Limit(LimitKind::Host), "stopped")); + assert!(r.interp().finally_sites.is_empty()); + assert_eq!(r.run("1").unwrap(), JsValue::Number(1.0)); + assert!(r.interp().finally_sites.is_empty()); +} + +/// A `finally` block that `break`, `continue` or `return` leaves drops the saved site of the +/// exception it ran for as it leaves, before the stack grows back over that site's slot. +#[test] +fn leaving_a_finally_block_early_drops_its_saved_throw_site() { + let mut r = realm(1_000_000); + let marked = Rc::new(Cell::new(false)); + let seen = marked.clone(); + let f: HostFunction = Rc::new(move |_| { + seen.set(true); + Ok(Data::Undefined) + }); + r.define_host_object("host", vec![("mark".into(), HostItem::Function(f))]).unwrap(); + for source in [ + "do { try { throw new Error('x'); } finally { break; } } while (false);\nhost.mark(); var a = [1, 2, 3];", + "for (var i = 0; i < 1; i++) { try { throw new Error('x'); } finally { continue; } }\nhost.mark(); var a = [1, 2, 3];", + "function f() { try { throw new Error('x'); } finally { return 1; } }\nf(); host.mark(); var a = [f, f, f];", + ] { + marked.set(false); + let mut saved = false; + r.start(source).unwrap(); + while r.advance(1).unwrap() == Progress::Paused { + saved |= !r.interp().finally_sites.is_empty(); + if marked.get() { + assert!(r.interp().finally_sites.is_empty(), "{source}"); + } + } + assert!(saved && marked.get(), "{source}"); + } +} + #[test] fn advancing_without_a_run_is_an_error() { let mut r = realm(1_000); diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index afb6cde635..cda117ecac 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -235,9 +235,9 @@ pub(crate) struct Interp { /// Where the exceptions `finally` blocks are running for were raised, innermost last, each with /// the stack slot that holds the exception while the block runs. The block's end throws the /// exception on from that site. An entry whose slot is at or above the stack's length belongs to - /// a block that was left, and [`Self::drop_left_finally_sites`] removes it. [`Self::unwind`] - /// removes the entries at or above a handler's slot before it adds one, so the slots rise and - /// the list is never longer than the stack. + /// a block that was left, and [`Self::drop_left_finally_sites`] removes it at each `Jump`, each + /// return and each collection. [`Self::unwind`] removes the entries at or above a handler's slot + /// before it adds one, so the slots rise and the list is never longer than the stack. pub(crate) finally_sites: Vec<(ThrowSite, usize)>, /// The mark of the host error the last run ended with. pub(crate) error_mark: Option, @@ -885,7 +885,11 @@ impl Interp { Op::New(n) => self.op_new(n), Op::Binary(b) => self.op_binary(b), Op::Neg | Op::Plus | Op::BitNot | Op::Not | Op::Typeof | Op::ToNumber => self.op_unary(op), - Op::Jump(t) => self.jump_to(t), + Op::Jump(t) => { + // `break` and `continue` leave a `finally` block by popping its exception and jumping. + self.drop_left_finally_sites(self.stack.len()); + self.jump_to(t) + } Op::JumpIfFalse(t) | Op::JumpIfTrue(t) => self.op_branch(t, matches!(op, Op::JumpIfTrue(_)), false), Op::JumpIfFalseKeep(t) | Op::JumpIfTrueKeep(t) => self.op_branch(t, matches!(op, Op::JumpIfTrueKeep(_)), true), Op::Throw => { diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 18d50447ff..9d7c94d0ec 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -743,6 +743,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Scripts: the Script Console panel's report line reads as `script.console` gives it, `time unavailable` included on a platform without a clock; counts of host calls, dropped lines and cut lines take the singular for one, in each language's own plural forms; and a run that is no longer available still gets its end line (`crates/engine/src/tests/script_console.rs`, `crates/ui-egui/src/panels/tests_script_console.rs`). - Scripts: an error `$.evalFile` throws for a file it cannot run is reported at that file's line and column only while no `catch` has taken it. Caught and thrown again later, in the script or in another file, it is reported where it was thrown again, and a new error whose message holds its text is reported where that error was thrown. VectorCraft's own rule. `crates/engine/src/tests/script_include.rs`, `crates/js/src/host_tests.rs`. - Scripts: an uncaught error that one or more `finally` blocks pass on unchanged, in the script or in a function it called, is reported at the line and column where it was thrown, in `script.run`'s report and the Script Console. An error a `finally` block throws is reported at that throw, and one its `return`, `break` or `continue` discards is not reported. VectorCraft's own rule. `crates/engine/src/tests/script_include.rs`, `crates/js/src/host_tests.rs`. + - Scripts: an error a `finally` block passes on is still reported where it was thrown when the block's allocations collect garbage under a small heap limit, and a `finally` block that `return`, `break` or `continue` leaves drops the saved location of its error as it leaves, so a collection no longer counts that code as live. VectorCraft's own rule. `crates/js/src/host_tests.rs`. - Scripts: a run reads its preprocessor lines before any of it runs: `#include` and `//@include` put another file in place of the line, looked for beside the including file, in the `#includepath` folders and in `JSINCLUDE`; `#script` names the run; `#target` and `#strict off` are taken, `#strict on` turns on strict write checks (below), `#targetengine` names the engine (below), and `#engine` is refused. `$.evalFile` runs a file with the same globals and returns its last value, and `$.fileName` and `$.includePath` give the file being run and the include folders. A script run from a file reads these files only in its folder and below, without `..` names or links that lead out, and an error in one is reported at its own file, line and column ([preprocessor directives](https://extendscript.docsforadobe.dev/extendscript-tools-features/preprocessor-directives/), [the `$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/); `crates/engine/src/tests/script_include.rs`). - Scripts: `#targetengine name` (and `//@targetengine name`) runs the script in a named engine that lasts for the session, and a later script that names it sees the globals and functions earlier ones left ([preprocessor directives](https://extendscript.docsforadobe.dev/extendscript-tools-features/preprocessor-directives/)); `$.engineName` gives the name ([the `$` object](https://extendscript.docsforadobe.dev/extendscript-tools-features/dollar-object/)). VectorCraft's own rules: a script without the line and `#targetengine main` get a new engine each run, the line comes once in the script itself before its code, a session keeps 8 named engines with 64 MiB of heap each, an error or a stop keeps the globals the run set while the documents go back, kept host functions act for the run that calls them, `script.engines` lists the engines and `script.engine.drop` drops one, and the web build has none. `crates/engine/src/tests/script_engines.rs`. - Scripts: `File` and `Folder` ([File object](https://extendscript.docsforadobe.dev/file-system-access/file-object/), [Folder object](https://extendscript.docsforadobe.dev/file-system-access/folder-object/)) name files and folders by platform or URI path, with `name`, `fsName`, `absoluteURI`, `relativeURI`, `parent`, `Folder.current` and `encode`/`decode`, and read them: `exists`, `length`, the dates, `getFiles` with a mask or a function, and `open('r')` with `read`, `readln`, `readch`, `seek`, `tell` and `close`, decoding by byte order mark, `encoding`, UTF-8 or CP1252. VectorCraft's own rules: a script reads only in its own folder and below (and inside the automation read roots when they are set); any other path and a network or device path are refused before anything is looked up, and a link that leads out is refused as well (`false` or `null`, with `error` set); a file is read whole up to 8 MiB and a run reads up to 32 MiB; writing, the file dialogs and `execute` are not available yet. `crates/engine/src/tests/script_io.rs`. From f95c4b51c9eff0316e8d34895b370b0af82c030b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 19:17:06 -0800 Subject: [PATCH 08/15] Flatten Transparency: opaque art that touches only opaque art stays vector Grouping for flattening joined every pair of objects whose 1 pt reach boxes touched, transitively, so a row or grid of opaque objects next to one see-through object became one group and was flattened with it. Now a group holds each see-through object and every object its reach touches. An object without transparency joins only where its reach touches a member and it lies above that member but below the group's topmost object, whose place the group takes, so the stacking order stays the same. In files without transparency with Clip Complex Regions on, art under a group within one image pixel of a member joins it too, with the pixel size taken from the image's actual resolution. Objects in an overlap set that includes one inside a clip group or clipping layer keep the previous grouping. The 1 pt antialiasing margin now goes around a group's art once instead of once per nested group (a stroked path's plain art is a group, so it had 2 pt). On 3,000 stroked rectangles, 60 with a drop shadow, the Flattener Preview counts 180 affected objects where it counted 3,000, and Flatten Transparency with the Medium preset makes 240 flat regions and 2 images where it made one image of the whole grid. --- crates/engine/src/cmd/flatten.rs | 7 +- crates/engine/src/cmd/flatten/analysis.rs | 373 ++++++++++++++++++++-- crates/engine/src/cmd/flatten/apply.rs | 96 +++++- crates/engine/src/tests/flatten.rs | 48 +++ docs/mcp.md | 9 +- docs/target-app-parity.md | 1 + 6 files changed, 496 insertions(+), 38 deletions(-) diff --git a/crates/engine/src/cmd/flatten.rs b/crates/engine/src/cmd/flatten.rs index f950f8c1da..02b4097684 100644 --- a/crates/engine/src/cmd/flatten.rs +++ b/crates/engine/src/cmd/flatten.rs @@ -1,7 +1,10 @@ //! Object → Flatten Transparency: transparent art becomes opaque art that looks the same. //! -//! The targets (`ids` or the selection) split into groups of overlapping objects. Groups without -//! transparency stay as they are (their type and strokes are outlined on request). The others are +//! The targets (`ids` or the selection) split into groups: each object with transparency with the +//! objects it overlaps, and the objects without transparency whose stacking order with a member +//! would change when the group takes its topmost object's place. Objects without transparency +//! that overlap only others without it stay as they are (their type and strokes are outlined on +//! request), as do groups without transparency. The others are //! cut into atomic regions: the faces of the planar arrangement of every fill, outlined stroke and //! clipping path in them ([`po::regions`]). The same paints cover each whole region, so its colour //! is exact: those paints composited as the renderer composites them ([`Composer`], blend modes by diff --git a/crates/engine/src/cmd/flatten/analysis.rs b/crates/engine/src/cmd/flatten/analysis.rs index eacd124052..cf7bdb7baa 100644 --- a/crates/engine/src/cmd/flatten/analysis.rs +++ b/crates/engine/src/cmd/flatten/analysis.rs @@ -99,17 +99,77 @@ pub(super) fn masked(n: &Node) -> bool { n.extras().mask.as_ref().is_some_and(|m| !m.disabled) } -/// Where `n` can paint: its visual bounds grown by its effects (and a margin for antialiasing). -pub(super) fn reach(n: &Node) -> Option { +/// Where `n` paints: its visual bounds grown by its effects. +pub(super) fn painted(n: &Node) -> Option { if !n.visible { return None; } let b = match n.children() { - Some(ch) if !n.clips() && !matches!(n.kind, NodeKind::Compound { .. }) => ch.iter().filter_map(|c| reach(c)).reduce(|a, b| a.union(b))?, + Some(ch) if !n.clips() && !matches!(n.kind, NodeKind::Compound { .. }) => ch.iter().filter_map(|c| painted(c)).reduce(|a, b| a.union(b))?, _ => n.visual_bounds()?, }; let fx = n.appearance.items.iter().map(|i| effects::outset(i.effects(), b)).fold(effects::outset(&n.appearance.effects, b), f64::max); - Some(b.inflate(fx + 1.0, fx + 1.0)) + Some(b.inflate(fx, fx)) +} + +/// Where `n` can paint: where it paints ([`painted`]) and a margin of 1 pt for antialiasing, +/// once, however deep its groups nest. +pub(super) fn reach(n: &Node) -> Option { + painted(n).map(|b| b.inflate(1.0, 1.0)) +} + +type Cancelled = vectorcraft_doc::cancel::Cancelled; + +/// Whether `b` has finite coordinates and isn't inside out. +pub(super) fn finite(b: &Rect) -> bool { + [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite()) && b.x0 <= b.x1 && b.y0 <= b.y1 +} + +/// Whether boxes `a` and `b` overlap or touch. +fn touch(a: &Rect, b: &Rect) -> bool { + a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1 +} + +/// Indices joined into sets, each set knowing whether it holds an object with transparency. +struct Sets { + parent: Vec, + seeded: Vec, +} + +impl Sets { + fn find(&mut self, mut i: usize) -> usize { + while self.parent[i] != i { + self.parent[i] = self.parent[self.parent[i]]; + i = self.parent[i]; + } + i + } + + /// Joins the sets of `a` and `b`: → the set they are now, named by its lowest index. + fn join(&mut self, a: usize, b: usize) -> usize { + let (a, b) = (self.find(a), self.find(b)); + let (lo, hi) = (a.min(b), a.max(b)); + self.parent[hi] = lo; + self.seeded[lo] |= self.seeded[hi]; + lo + } + + /// The sets, each in index order, by their first index. + fn groups(&mut self) -> Vec> { + let mut groups: Vec> = vec![]; + let mut slot: Vec> = vec![None; self.parent.len()]; + for i in 0..self.parent.len() { + let r = self.find(i); + match slot[r] { + Some(g) => groups[g].push(i), + None => { + slot[r] = Some(groups.len()); + groups.push(vec![i]); + } + } + } + groups + } } /// Indices of `bounds` grouped by overlap (transitively), each group in index order. Boxes that @@ -121,16 +181,15 @@ pub(super) fn reach(n: &Node) -> Option { /// horizontal extent, would otherwise compare every pair). An open box inside a later box of /// its group, ending no further right, is closed early: whatever overlaps it overlaps that box. /// Fails only when cancelled ([`vectorcraft_doc::cancel`]). -pub(super) fn overlapping(bounds: &[Option]) -> Result>, vectorcraft_doc::cancel::Cancelled> { - fn find(p: &mut [usize], mut i: usize) -> usize { - while p[i] != i { - p[i] = p[p[i]]; - i = p[i]; - } - i - } - let mut parent: Vec = (0..bounds.len()).collect(); - let finite = |b: &Rect| [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite()) && b.x0 <= b.x1 && b.y0 <= b.y1; +pub(super) fn overlapping(bounds: &[Option]) -> Result>, Cancelled> { + Ok(sweep(bounds, None)?.groups()) +} + +/// [`overlapping`]'s sweep. With `see_through`, two boxes are joined only when one of them is +/// see-through, and an open box is closed early only inside a later see-through box. +fn sweep(bounds: &[Option], see_through: Option<&[bool]>) -> Result { + let mut sets = Sets { parent: (0..bounds.len()).collect(), seeded: (0..bounds.len()).map(|i| see_through.is_some_and(|s| s[i])).collect() }; + let joins = |a: usize, b: usize| see_through.is_none_or(|s| s[a] || s[b]); let boxes: Vec<(usize, Rect)> = bounds.iter().enumerate().filter_map(|(i, b)| b.filter(finite).map(|b| (i, b))).collect(); let n = boxes.len(); // Each box's place in the tree: by top (then index). @@ -190,29 +249,140 @@ pub(super) fn overlapping(bounds: &[Option]) -> Result>, ve stack.push((2 * node + 1, mid, hi)); stack.push((2 * node, lo, mid)); } + // Whatever overlaps a box inside this one overlaps this one, and joins it when this one is + // see-through. + let shadows = see_through.is_none_or(|s| s[boxes[k].0]); for &o in &met { - let (a, c) = (find(&mut parent, boxes[k].0), find(&mut parent, boxes[o].0)); - parent[a.max(c)] = a.min(c); + if joins(boxes[k].0, boxes[o].0) { + sets.join(boxes[k].0, boxes[o].0); + } let ob = boxes[o].1; - if ob.x1 <= b.x1 && b.y0 <= ob.y0 && ob.y1 <= b.y1 { + if shadows && ob.x1 <= b.x1 && b.y0 <= ob.y0 && ob.y1 <= b.y1 { set(&mut low, leaf[o], f64::NEG_INFINITY); } } set(&mut low, leaf[k], b.y1); } - let mut groups: Vec> = vec![]; - let mut slot: Vec> = vec![None; bounds.len()]; - for i in 0..bounds.len() { - let r = find(&mut parent, i); - match slot[r] { - Some(g) => groups[g].push(i), - None => { - slot[r] = Some(groups.len()); - groups.push(vec![i]); + Ok(sets) +} + +/// Boxes in blocks of nearby ones, each block with the box around it, to find the boxes a box +/// touches. +struct Blocks { + order: Vec, + boxes: Vec>, + around: Vec, +} + +impl Blocks { + const SIZE: usize = 32; + + /// The finite boxes of `boxes`, in vertical strips by their centers and in blocks down each + /// strip. + fn new(boxes: &[Option]) -> Self { + let boxes: Vec> = boxes.iter().map(|b| b.filter(finite)).collect(); + let mut order: Vec = (0..boxes.len()).filter(|&i| boxes[i].is_some()).collect(); + let center = |i: usize| boxes[i].map_or_else(Default::default, |b| b.center()); + order.sort_by(|&a, &b| center(a).x.total_cmp(¢er(b).x)); + let strip = ((order.len() as f64 / Self::SIZE as f64).sqrt().ceil() as usize).max(1) * Self::SIZE; + for s in order.chunks_mut(strip) { + s.sort_by(|&a, &b| center(a).y.total_cmp(¢er(b).y)); + } + let around = order.chunks(Self::SIZE).filter_map(|c| c.iter().filter_map(|&i| boxes[i]).reduce(|a, b| a.union(b))).collect(); + Self { order, boxes, around } + } + + /// Puts the indices of the boxes that touch `q` in `out`. `work` counts the boxes looked at; + /// fails only when cancelled. + fn touching(&self, q: &Rect, out: &mut Vec, work: &mut usize) -> Result<(), Cancelled> { + out.clear(); + for (b, around) in self.around.iter().enumerate() { + *work += 1; + if work.is_multiple_of(4096) { + vectorcraft_doc::cancel::check()?; + } + if !touch(around, q) { + continue; + } + for &i in &self.order[b * Self::SIZE..((b + 1) * Self::SIZE).min(self.order.len())] { + *work += 1; + if self.boxes[i].is_some_and(|r| touch(&r, q)) { + out.push(i); + } } } + Ok(()) } - Ok(groups) +} + +/// Objects (in paint order) grouped for flattening, each group in paint order and the groups by +/// their first object; a group takes the place of its topmost object. `reach` is where each +/// object can paint ([`reach`]), `painted` where it paints ([`painted`]), and `see_through` whether +/// it has transparency. An object whose box is missing or not finite is a group of its own. +/// +/// An object joins every see-through object its reach touches. An object without transparency +/// that touches only others without transparency stays a group of its own, unless a group would +/// change its stacking order with it: it joins (and groups join each other) where its reach +/// touches a member's and moving the members to their group's top would put the two the other way +/// round. With a `fringe` (how far a group's image can reach past +/// its art and cover what is under it), an object or group under a group's top whose painted box +/// that far around a member's touches joins it too. Fails only when cancelled +/// ([`vectorcraft_doc::cancel`]). +pub(super) fn flatten_groups( + reach: &[Option], + painted: &[Option], + see_through: &[bool], + fringe: f64, +) -> Result>, Cancelled> { + let n = reach.len(); + let mut sets = sweep(reach, Some(see_through))?; + let mut members: Vec> = vec![vec![]; n]; + for i in 0..n { + let r = sets.find(i); + members[r].push(i); + } + // The topmost object of each set (by its name). + let mut top: Vec = (0..n).map(|r| members[r].last().copied().unwrap_or(r)).collect(); + let index = Blocks::new(reach); + // Each member of a set with transparency is looked at. + let mut work: Vec = vec![]; + for i in 0..n { + let r = sets.find(i); + if sets.seeded[r] { + work.push(i); + } + } + let (mut near, mut looked) = (vec![], 0usize); + while let Some(i) = work.pop() { + let Some(q) = reach[i].filter(finite) else { continue }; + // Painted boxes within the fringe have reaches within it too. + index.touching(&q.inflate(fringe, fringe), &mut near, &mut looked)?; + for &k in &near { + let (ri, rk) = (sets.find(i), sets.find(k)); + if ri == rk { + continue; + } + let (ti, tk) = (top[ri], top[rk]); + let crossed = reach[k].is_some_and(|b| touch(&q, &b)) && (i < k) != (ti < tk); + let covered = fringe > 0.0 && tk < ti && painted[i].zip(painted[k]).is_some_and(|(a, b)| touch(&a.inflate(fringe, fringe), &b)); + if !crossed && !covered { + continue; + } + // The members of a set whose top moves are looked at again, from there. + let t = ti.max(tk); + if ti != t { + work.extend_from_slice(&members[ri]); + } + if tk != t { + work.extend_from_slice(&members[rk]); + } + let r = sets.join(ri, rk); + let moved = std::mem::take(&mut members[if r == ri { rk } else { ri }]); + members[r].extend(moved); + top[r] = t; + } + } + Ok(sets.groups()) } #[cfg(test)] @@ -292,4 +462,153 @@ mod tests { let odd = [Some(Rect::new(0.0, 0.0, 10.0, 10.0)), Some(Rect::new(f64::NAN, 0.0, 10.0, 10.0)), Some(Rect::new(5.0, 5.0, 20.0, 20.0))]; assert_eq!(overlapping(&odd).unwrap(), vec![vec![0, 2], vec![1]]); } + + /// A filled rectangle, unstroked. + fn filled(id: u64, r: Rect) -> Node { + let look = vectorcraft_doc::Appearance::basic( + vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0)), + vectorcraft_color::Paint::None, + 0.0, + ); + Node::path(vectorcraft_doc::NodeId(id), vectorcraft_geom::shapes::rectangle(r), look) + } + + /// The antialiasing margin goes around the outermost group once, not once per group. + #[test] + fn reach_adds_its_margin_once_however_deep_the_groups() { + let r = Rect::new(10.0, 20.0, 30.0, 50.0); + let inner = Node::group(vectorcraft_doc::NodeId(2), vec![Arc::new(filled(1, r))]); + let outer = Node::group(vectorcraft_doc::NodeId(3), vec![Arc::new(inner)]); + assert_eq!(painted(&outer), Some(r)); + assert_eq!(reach(&outer), Some(r.inflate(1.0, 1.0))); + assert_eq!(reach(&filled(1, r)), reach(&outer)); + } + + /// The group of each object, the topmost object of each group and whether it is see-through. + fn sets_of(groups: &[Vec], n: usize, see_through: &[bool]) -> (Vec, Vec, Vec) { + let mut of = vec![usize::MAX; n]; + for (g, ids) in groups.iter().enumerate() { + for &i in ids { + of[i] = g; + } + } + let top = groups.iter().map(|g| *g.last().unwrap()).collect(); + let seeded = groups.iter().map(|g| g.iter().any(|&i| see_through[i])).collect(); + (of, top, seeded) + } + + /// What [`flatten_groups`] must give: every object in one group, in paint order, the groups by + /// their first object; see-through objects with all that their reach touches; groups without + /// transparency of one object; no two objects that paint over each other the other way round + /// once each group takes its top's place; nothing within `fringe` of a member of a group under + /// that group's top. + fn check(reach: &[Option], painted: &[Option], see: &[bool], fringe: f64, groups: &[Vec], what: &str) { + let n = reach.len(); + let mut all: Vec = groups.iter().flatten().copied().collect(); + all.sort_unstable(); + assert_eq!(all, (0..n).collect::>(), "{what}: every object once"); + assert!(groups.iter().all(|g| g.is_sorted()) && groups.windows(2).all(|w| w[0][0] < w[1][0]), "{what}: in order"); + let (of, top, seeded) = sets_of(groups, n, see); + for (g, ids) in groups.iter().enumerate() { + assert!(seeded[g] || ids.len() == 1, "{what}: {ids:?} has no transparency"); + } + for i in 0..n { + for j in 0..n { + if i == j || of[i] == of[j] { + continue; + } + let (gi, gj) = (of[i], of[j]); + if let (Some(a), Some(b)) = (reach[i], reach[j]) { + assert!(!(touch(&a, &b) && (see[i] || see[j])), "{what}: {i} and {j} touch, one see-through"); + if seeded[gi] && touch(&a, &b) { + assert_eq!(i < j, top[gi] < top[gj], "{what}: {i} and {j} change places"); + } + } + let (Some(a), Some(b)) = (painted[i], painted[j]) else { continue }; + if seeded[gi] && fringe > 0.0 && touch(&a.inflate(fringe, fringe), &b) { + assert!(top[gj] > top[gi], "{what}: {j} under the image of {i}'s group"); + } + } + } + } + + /// Boxes of many sizes, some see-through, group as they must, with and without a fringe. + #[test] + fn flatten_groups_keep_stacking_order_and_leave_opaque_art_alone() { + let mut seed = 0x9e37_79b9_7f4a_7c15u64; + let mut next = |m: f64| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + (seed % 1000) as f64 / 1000.0 * m + }; + for round in 0..60 { + let n = 4 + round; + let painted: Vec> = (0..n) + .map(|i| { + if i % 13 == 5 { + return None; + } + let (x, y) = (next(120.0).round(), next(120.0).round()); + Some(Rect::new(x, y, x + next(25.0).round(), y + next(25.0).round())) + }) + .collect(); + let reach: Vec> = painted.iter().map(|b| b.map(|b| b.inflate(1.0, 1.0))).collect(); + let see: Vec = (0..n).map(|_| next(1.0) < 0.15).collect(); + for fringe in [0.0, 0.7] { + let groups = flatten_groups(&reach, &painted, &see, fringe).unwrap(); + check(&reach, &painted, &see, fringe, &groups, &format!("round {round}, fringe {fringe}")); + } + } + } + + /// A row of opaque boxes whose reaches touch, with see-through ones at its ends and far from + /// it: only the boxes next to a see-through one join it. + #[test] + fn an_opaque_row_stays_apart_from_the_see_through_boxes_at_its_ends() { + let n = 200; + // 10 pt boxes 1.5 pt apart: their 1 pt reaches overlap. + let painted: Vec> = (0..n).map(|i| Some(Rect::new(11.5 * i as f64, 0.0, 11.5 * i as f64 + 10.0, 10.0))).collect(); + let reach: Vec> = painted.iter().map(|b| b.map(|b| b.inflate(1.0, 1.0))).collect(); + let mut see = vec![false; n]; + see[0] = true; + see[n - 1] = true; + let groups = flatten_groups(&reach, &painted, &see, 0.0).unwrap(); + check(&reach, &painted, &see, 0.0, &groups, "row"); + assert_eq!(groups[0], vec![0, 1]); + assert_eq!(groups.last(), Some(&vec![n - 2, n - 1])); + assert_eq!(groups.len(), n - 2); + // Without transparency every box is a group of its own. + assert_eq!(flatten_groups(&reach, &painted, &vec![false; n], 0.0).unwrap().len(), n); + } + + /// An opaque box between a member and its group's top that paints over the member joins, and + /// so on along a chain; one under the member stays, but for one within the fringe. + #[test] + fn boxes_the_group_would_cross_join_it() { + // In paint order: D under A, A, B over A, C over B, then the see-through T over A. + let d = Rect::new(40.0, 80.0, 65.0, 120.0); + let a = Rect::new(60.0, 50.0, 105.0, 90.0); + let b = Rect::new(20.0, 20.0, 65.0, 55.0); + let c = Rect::new(0.0, 0.0, 25.0, 25.0); + let t = Rect::new(100.0, 50.0, 140.0, 90.0); + let painted = [Some(d), Some(a), Some(b), Some(c), Some(t)]; + let reach: Vec> = painted.iter().map(|b| b.map(|b| b.inflate(1.0, 1.0))).collect(); + let see = [false, false, false, false, true]; + let groups = flatten_groups(&reach, &painted, &see, 0.0).unwrap(); + assert_eq!(groups, vec![vec![0], vec![1, 2, 3, 4]]); + // C away from B: it stays. + let far = [Some(d), Some(a), Some(b), Some(Rect::new(0.0, 0.0, 10.0, 10.0)), Some(t)]; + let reach_far: Vec> = far.iter().map(|b| b.map(|b| b.inflate(1.0, 1.0))).collect(); + assert_eq!(flatten_groups(&reach_far, &far, &see, 0.0).unwrap(), vec![vec![0], vec![1, 2, 4], vec![3]]); + // B 0.5 pt above A, not overlapping it: their antialiased edges can share pixels, so B joins. + let gap = [Some(d), Some(a), Some(Rect::new(20.0, 20.0, 65.0, 49.5)), Some(Rect::new(0.0, 0.0, 10.0, 10.0)), Some(t)]; + let reach_gap: Vec> = gap.iter().map(|b| b.map(|b| b.inflate(1.0, 1.0))).collect(); + assert_eq!(flatten_groups(&reach_gap, &gap, &see, 0.0).unwrap(), vec![vec![0], vec![1, 2, 4], vec![3]]); + // D a little left of A, under it: the fringe takes it in. + let near = [Some(Rect::new(40.0, 80.0, 59.8, 120.0)), Some(a), Some(b), Some(c), Some(t)]; + let reach_near: Vec> = near.iter().map(|b| b.map(|b| b.inflate(1.0, 1.0))).collect(); + assert_eq!(flatten_groups(&reach_near, &near, &see, 0.0).unwrap()[0], vec![0]); + assert_eq!(flatten_groups(&reach_near, &near, &see, 0.24).unwrap(), vec![vec![0, 1, 2, 3, 4]]); + } } diff --git a/crates/engine/src/cmd/flatten/apply.rs b/crates/engine/src/cmd/flatten/apply.rs index c1366dba3c..ef8425b362 100644 --- a/crates/engine/src/cmd/flatten/apply.rs +++ b/crates/engine/src/cmd/flatten/apply.rs @@ -1,4 +1,4 @@ -use super::analysis::{Scratch, has_effects, masked, outlined_text, overlapping, reach}; +use super::analysis::{Scratch, finite, flatten_groups, has_effects, masked, outlined_text, overlapping, painted, reach}; use super::compose::{Builder, CLEAR, Composer, Elem, Fill, Kind, Px, WHITE}; use super::options::FlattenOptions; use super::{ @@ -115,7 +115,7 @@ pub(super) fn plan(src: &Document, roots: &[NodeId], o: &FlattenOptions, render: let mut out = Plan { flat: vec![], kept: vec![], transparent: roots.iter().zip(&see_through).filter(|(_, t)| **t).map(|(id, _)| *id).collect() }; // Baking above stopped early when cancelled; each group is a place to stop (a plan changes nothing). vectorcraft_doc::cancel::check()?; - let mut queue = overlapping(&reaches)?; + let mut queue = groups(src, roots, &plain, &reaches, &see_through, o)?; queue.reverse(); while let Some(g) = queue.pop() { vectorcraft_doc::cancel::check()?; @@ -196,6 +196,46 @@ pub(super) fn plan(src: &Document, roots: &[NodeId], o: &FlattenOptions, render: Ok(out) } +/// `roots` of `src` (`plain`: as plain art; `reaches`: where each can paint, clipped) in the +/// groups that flatten together ([`flatten_groups`]), each in paint order, by their first object. +/// Objects that overlap (as [`overlapping`] groups them) stay in one group when one of them is +/// inside a clip group or clipping layer: [`inside_and_under`] and [`place_of`] then decide where +/// they go. +fn groups( + src: &Document, + roots: &[NodeId], + plain: &[Node], + reaches: &[Option], + see_through: &[bool], + o: &FlattenOptions, +) -> Result>> { + // An image clipped to its regions, opaque over white, reaches a pixel past them; a group's + // image is no larger than the art it overlaps, so its pixels are no wider than this. + let opaque_edge = o.no_transparency.is_some() && o.clip_complex_regions && !o.preserve_alpha; + let ppi = o.line_art_ppi.min(o.gradient_ppi).max(1.0); + let extent = |ids: &mut dyn Iterator| ids.filter_map(|i| reaches[i].filter(finite)).reduce(|a, b| a.union(b)).unwrap_or_default(); + // Where the widest such pixel is wider than the margins of two reaches, objects that close + // overlap too. + let widest = if opaque_edge { 1.0 / raster_scale(extent(&mut (0..reaches.len())), ppi) } else { 0.0 }; + let grow = ((widest - 2.0) / 2.0).max(0.0); + let near: Vec> = reaches.iter().map(|b| b.map(|b| b.inflate(grow, grow))).collect(); + let mut out = vec![]; + for g in overlapping(if grow > 0.0 { &near } else { reaches })? { + let clipped = || clips_count() && g.iter().any(|&i| !clip_ancestors(src, roots[i]).is_empty()); + if g.len() < 2 || !g.iter().any(|&i| see_through[i]) || clipped() { + out.push(g); + continue; + } + let fringe = if opaque_edge { 1.0 / raster_scale(extent(&mut g.iter().copied()), ppi) } else { 0.0 }; + let local = |v: &[Option]| g.iter().map(|&i| v[i]).collect::>(); + let painted: Vec> = g.iter().map(|&i| painted(&plain[i])).collect(); + let see: Vec = g.iter().map(|&i| see_through[i]).collect(); + out.extend(flatten_groups(&local(reaches), &painted, &see, fringe)?.into_iter().map(|l| l.into_iter().map(|k| g[k]).collect::>())); + } + out.sort_by_key(|g| g.first().copied()); + Ok(out) +} + /// `plain` (object `id` of `src` as plain art) inside a clip group for each clip group and /// clipping layer around `id`, innermost first, each clipped by that group's or layer's clipping /// path. `plain` itself when nothing around it clips. With `fills` (clipping paths, and an area), @@ -482,11 +522,7 @@ fn region_fill(elems: &[Elem], sources: &[usize], o: &FlattenOptions, cmyk: bool /// amounts (a CMYK TIFF), so its CMYK colours keep their numbers (#2160); other images are PNGs. /// Fails when the image can't be encoded. fn render_raster(doc: &Document, art: &[Node], rect: Rect, ppi: f64, clip: Option, o: &FlattenOptions) -> Result { - let mut scale = ppi / 72.0; - scale = scale.min((MAX_PIXELS / (rect.width() * rect.height()).max(1.0)).sqrt()); - // No side longer than the renderer draws: the image grows by a pixel on each side and is - // rounded up to whole pixels below. - scale = scale.min((f64::from(vectorcraft_render::MAX_RASTER_SIDE) - 4.0) / rect.width().max(rect.height()).max(1.0)); + let scale = raster_scale(rect, ppi); let px = 1.0 / scale; let rect = rect.inflate(px, px); let clip = clip.map(|c| Some(po::offset_path(&c, px, po::Join::Miter, 4.0)).filter(|g| !g.is_empty()).unwrap_or(c)); @@ -559,6 +595,14 @@ fn render_raster(doc: &Document, art: &[Node], rect: Rect, ppi: f64, clip: Optio Ok(Raster { blob: ImageBlob::new("image/png", png), width: img.width, height: img.height, xf, clip }) } +/// Pixels per point of an image of `rect` at `ppi`: lower when the image would be larger than +/// [`MAX_PIXELS`], or have a side longer than the renderer draws (the image grows by a pixel on +/// each side and is rounded up to whole pixels). +fn raster_scale(rect: Rect, ppi: f64) -> f64 { + let scale = (ppi / 72.0).min((MAX_PIXELS / (rect.width() * rect.height()).max(1.0)).sqrt()); + scale.min((f64::from(vectorcraft_render::MAX_RASTER_SIDE) - 4.0) / rect.width().max(rect.height()).max(1.0)) +} + /// `n` at full opacity with Normal blending throughout (objects, fills and strokes). pub(super) fn opaque(n: &mut Node) { n.opacity = 1.0; @@ -877,4 +921,42 @@ mod tests { assert_eq!(flat.parent_of(g), Some(layer)); assert_eq!(flat.children(Some(layer)).and_then(|c| c.last()).map(|c| c.id), Some(g)); } + + /// In a file without transparency with Clip Complex Regions on, art under a group's opaque + /// image within one of its pixels joins the group, also where a long image is made at a lower + /// resolution and its pixels are wider than at the preset's. + #[test] + fn art_under_the_edge_of_a_long_opaque_image_joins_its_group() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 31000, "height": 100})).unwrap(); + let rect = |s: &mut Session, x: f64, w: f64| { + let id = s.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": w, "height": 10})).unwrap()["id"].as_u64().unwrap(); + s.execute("paint.setStroke", &json!({"none": true, "ids": [id]})).unwrap(); + NodeId(id) + }; + // 0.5 pt left of A: wider than a pixel at 300 ppi, narrower than one of a 30,000 pt image. + let d = rect(&mut s, 40.0, 19.5); + let a = rect(&mut s, 60.0, 40.0); + let t = rect(&mut s, 95.0, 30_000.0); + s.execute("transparency.set", &json!({"ids": [t.0], "opacity": 50})).unwrap(); + let o = FlattenOptions { no_transparency: Some(Default::default()), clip_complex_regions: true, ..FlattenOptions::default() }; + let doc = &s.doc().unwrap().doc; + let long = plan(doc, &[d, a, t], &o, false).unwrap(); + assert!(long.kept.is_empty(), "{:?}", long.kept); + assert_eq!(long.flat.iter().map(|f| f.roots.clone()).collect::>(), vec![vec![d, a, t]]); + // Short, the image has 300 ppi pixels: D stays. + let short = rect(&mut s, 95.0, 40.0); + s.execute("transparency.set", &json!({"ids": [short.0], "opacity": 50})).unwrap(); + let doc = &s.doc().unwrap().doc; + assert_eq!(plan(doc, &[d, a, short], &o, false).unwrap().kept, vec![d]); + // 100,000 pt long, its pixels are over 3 pt wide: art 3 pt away, whose reach doesn't touch + // A's, joins too. + let far = rect(&mut s, 40.0, 17.0); + let longer = rect(&mut s, 95.0, 100_000.0); + s.execute("transparency.set", &json!({"ids": [longer.0], "opacity": 50})).unwrap(); + let doc = &s.doc().unwrap().doc; + let p = plan(doc, &[far, a, longer], &o, false).unwrap(); + assert!(p.kept.is_empty(), "{:?}", p.kept); + assert_eq!(p.flat.iter().map(|f| f.roots.clone()).collect::>(), vec![vec![far, a, longer]]); + } } diff --git a/crates/engine/src/tests/flatten.rs b/crates/engine/src/tests/flatten.rs index 84e4724fe6..042f741e0e 100644 --- a/crates/engine/src/tests/flatten.rs +++ b/crates/engine/src/tests/flatten.rs @@ -450,6 +450,54 @@ fn objects_without_transparency_stay_unless_outlined() { } } +/// A row of opaque rectangles 1.5 pt apart (closer than twice the antialiasing margin), with a +/// see-through one touching each end: only the two next to a see-through one are flattened, and +/// the document looks the same. +#[test] +fn opaque_art_that_touches_only_opaque_art_stays() { + let _settings = settings_lock(); + let mut s = session(); + let under = rect(&mut s, 0.0, 50.0, 10.0, 10.0, "#2060c0"); + run(&mut s, "transparency.set", json!({"ids": [under], "opacity": 50})); + let row: Vec = (1..15).map(|i| rect(&mut s, 11.5 * f64::from(i), 50.0, 10.0, 10.0, "#e04010")).collect(); + let over = rect(&mut s, 11.5 * 15.0, 50.0, 10.0, 10.0, "#20a040"); + run(&mut s, "transparency.set", json!({"ids": [over], "opacity": 50, "blend": "Multiply"})); + let before = picture(&s); + run(&mut s, "select.all", json!({})); + let (ids, vector, rasterized) = flatten(&mut s, json!({})); + assert_eq!((vector, rasterized), (4, 0), "two groups of two regions each"); + let (ends, kept) = (row[0], row.last().copied().unwrap()); + assert!(!exists(&s, under) && !exists(&s, over) && !exists(&s, ends) && !exists(&s, kept)); + assert!(row[1..row.len() - 1].iter().all(|id| exists(&s, *id) && ids.contains(id)), "the rest of the row stays"); + assert_eq!(ids.len(), row.len() - 2 + 2); + let diff = changed(&before, &picture(&s)); + assert!(diff == 0.0, "{:.2}% of the pixels changed", diff * 100.0); +} + +/// Opaque art between a see-through object's group and the top of that group, painting over a +/// member, joins the group (along a chain too), so it still paints over it; opaque art under the +/// group stays. +#[test] +fn opaque_art_the_group_would_move_under_joins_it() { + let _settings = settings_lock(); + let mut s = session(); + let d = rect(&mut s, 40.0, 80.0, 25.0, 40.0, "#f0d000"); + let a = rect(&mut s, 60.0, 50.0, 45.0, 40.0, "#e01010"); + let b = rect(&mut s, 20.0, 20.0, 45.0, 35.0, "#10a010"); + let c = rect(&mut s, 0.0, 0.0, 25.0, 25.0, "#1010e0"); + let t = rect(&mut s, 100.0, 50.0, 40.0, 40.0, "#000000"); + run(&mut s, "transparency.set", json!({"ids": [t], "opacity": 50})); + let before = picture(&s); + let (ids, ..) = flatten(&mut s, json!({"ids": [d, a, b, c, t]})); + assert!(exists(&s, d) && ids.contains(&d), "the art under the group stays"); + assert!([a, b, c, t].iter().all(|id| !exists(&s, *id)), "the others are flattened together"); + // Where B paints over A, and C over B, they still do. + for p in [Point::new(62.5, 52.5), Point::new(22.5, 22.5), Point::new(62.5, 85.5), Point::new(102.5, 70.5)] { + assert_eq!(pixel(&picture(&s), p), pixel(&before, p), "at {p:?}"); + } + assert!(changed(&before, &picture(&s)) == 0.0); +} + #[test] fn outlined_type_keeps_its_underline() { let _settings = settings_lock(); diff --git a/docs/mcp.md b/docs/mcp.md index 5826c5c8c4..80df71ab2c 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3401,8 +3401,13 @@ where the layout puts it. ## Flatten Transparency `object.flattenTransparency {ids?, preset?, …options}` turns transparent art into opaque art that looks the same, in -one undo step. The targets split into groups of overlapping objects; groups without transparency stay as they are. -Each other group becomes one group of atomic regions: paths filled with the flat colour the art showed there (over +one undo step. The targets split into groups. A group holds each object with transparency and every object it +overlaps, counting 1 pt around each object for antialiasing. It also holds each object without transparency that +overlaps a member in the same way and lies above that member but below the group's topmost object, whose place the +group takes. +Objects without transparency that overlap only others without transparency stay as they are. Overlapping objects of +which one is inside a clip group or clipping layer form one group, with transparency or without; groups without +transparency stay as they are. Each other group becomes one group of atomic regions: paths filled with the flat colour the art showed there (over white, which blend modes do not read; over nothing, keeping alpha, with `preserveAlpha: true`), plus one image where gradients, patterns, images, opacity masks or raster effects reach (clipped to those regions with `clipComplexRegions`, a rectangle without). The image leaves out the part of such art that the clipping path of a clip group or clipping layer hides, so art under diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b4c949f742..8ee0eaeccb 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1155,6 +1155,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Select › Object › All on Same Layers takes the layers only of the selected objects that can be selected: an object that is hidden or locked, on a template layer, on a locked artboard while nothing is isolated, or outside the isolated group adds no layer. This is VectorCraft's own behavior. Object › Flatten Transparency takes a frame of threaded text out of the thread in the same way when Convert All Text to Outlines outlines it or when the frame becomes flat regions or an image, and the other frames keep the text they hold. Object › Flatten Transparency, the Flattener Preview, and EPS, PDF and print output that flatten transparency stop building a group's flat regions at the first region past the raster/vector balance's limit (10^(balance/20), rounded down) and rasterize the whole group without building the remaining regions. Building every region gives the same flattened art and the same files. With Clip Complex Regions on, a group builds every region and its image is clipped to them. + Object › Flatten Transparency, the Flattener Preview, and EPS, PDF and print output that flatten transparency leave art without transparency as it is when it overlaps only other art without transparency. Such art joins a see-through object's group only where it lies above a member and below the group's topmost object, whose place the group takes, so the stacking order stays the same. In files without transparency with Clip Complex Regions on, art under the group within one image pixel of a member joins it too. The 1 pt antialiasing margin goes around a group's art once, however deeply its groups nest. On a grid of 3,000 rectangles with a 0.5 pt stroke, 60 of them with a drop shadow, the Flattener Preview counts 180 affected objects where it counted 3,000, and Flatten Transparency with the Medium preset makes 240 flat regions and 2 images where it made one image of the whole grid. These rules are VectorCraft's own (`tests::flatten::opaque_art_that_touches_only_opaque_art_stays`, `tests::flatten::opaque_art_the_group_would_move_under_joins_it`). New Symbol and Redefine Symbol on a frame of threaded text take it out of the thread with the text it shows, which the symbol's art holds, and the other frames keep the text they hold. When the last frame goes into the symbol, the text it has no room for goes after the text of the last frame that stays. When every frame of a thread goes into the symbol, the copy of the last frame keeps all its text. With Optical Margin Alignment on, or Roman Hanging Punctuation on in the first paragraph, the glyphs at the ends of left-to-right type on a path hang outside its brackets, measured along the path. The type hangs at the start bracket when its line is placed there, hangs at the end bracket when the line is placed there, and hangs at both when it is centered. Glyphs that hang past the end bracket do not make the type overflow, and a hang stops at an open path's end. SVG export writes such type as outlines. Type on a Path Options › Align to Path (option names as in [Create type on a path](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-type-on-a-path.html)) moves each character by its own font, size and vertical scale: Ascender runs the top of the font's em box along the path (the ideographic em box that its `BASE` table, its typographic or `hhea` box or its ideographs give, else an em box centered on the cap height), Descender runs the bottom of the font's bounding box along it, and Center runs a line at half the height of the top of the font's H above the baseline along it. In vertical type, Ascender and Descender move the characters half an em, and Center leaves them on the path. These distances are VectorCraft's own values (`tests::on_path_align_runs_each_glyph_by_its_own_metrics`, `tests::on_path_align_finds_the_em_box_of_each_kind_of_face`, `tests::on_path_align_in_vertical_type_moves_half_an_em`). From 781357921e132b6f873f4a6604ccb99c8d596ef8 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 19:35:39 -0800 Subject: [PATCH 09/15] Scripts: E4X checks the XML declaration and takes fuel for every run the parser scans An XML declaration at the start of XML or XMLList text was skipped up to the next "?>" without being read. It is now checked against XML 1.0's grammar (https://www.w3.org/TR/xml/#NT-XMLDecl): a version of "1." and digits, then an optional encoding name and an optional standalone "yes" or "no", in that order, with optional white space before "?>". Any other declaration gives "SyntaxError: invalid XML declaration", and a character XML does not allow gives "invalid character in XML text". White space before the declaration is still accepted. The parser now takes fuel for the length of the declaration and the white space before it, element and attribute names, white space in open and closing tags, and the text it searched when an attribute value, CDATA section, comment or processing instruction is not closed, or when "]]>" or "<" stops a text run or attribute value. A long run in any of these used the same fuel as a short one. --- crates/js/src/xml.rs | 108 +++++++++++++++++++++++++++++-------- crates/js/src/xml_tests.rs | 92 +++++++++++++++++++++++++++++++ docs/target-app-parity.md | 2 +- 3 files changed, 180 insertions(+), 22 deletions(-) diff --git a/crates/js/src/xml.rs b/crates/js/src/xml.rs index 9dc6d71f85..019a9638b0 100644 --- a/crates/js/src/xml.rs +++ b/crates/js/src/xml.rs @@ -175,6 +175,52 @@ fn is_attribute_run(units: &[u16]) -> bool { true } +/// Reads one pseudo-attribute of an XML declaration at `at`: white space, `name`, `=` with +/// optional white space around it, and a quoted value. Returns the offset after the closing +/// quote and the value. +fn decl_attribute<'a>(body: &'a [u16], at: usize, name: &str) -> Option<(usize, &'a [u16])> { + let start = skip_xml_space(body, at); + let name = parser_units(name); + if start == at || !body.get(start..)?.starts_with(&name) { + return None; + } + let eq = skip_xml_space(body, start + name.len()); + if body.get(eq) != Some(&EQ) { + return None; + } + let open = skip_xml_space(body, eq + 1); + let quote = body.get(open).copied().filter(|&q| q == DQUOTE || q == SQUOTE)?; + let len = body.get(open + 1..)?.iter().position(|&c| c == quote)?; + Some((open + len + 2, body.get(open + 1..open + 1 + len)?)) +} + +/// Whether `body`, the text between ``, is an XML declaration's version, optional +/// encoding, optional standalone flag and optional trailing white space (XML 1.0 productions 23 +/// to 26, 32, 80 and 81). +fn is_xml_decl_body(body: &[u16]) -> bool { + let is_digit = |c: &u16| (u16::from(b'0')..=u16::from(b'9')).contains(c); + let is_letter = |c: &u16| (u16::from(b'A')..=u16::from(b'Z')).contains(c) || (u16::from(b'a')..=u16::from(b'z')).contains(c); + let Some((mut at, version)) = decl_attribute(body, 0, "version") else { return false }; + let digits = version.strip_prefix(parser_units("1.").as_slice()).unwrap_or_default(); + if digits.is_empty() || !digits.iter().all(is_digit) { + return false; + } + if let Some((next, encoding)) = decl_attribute(body, at, "encoding") { + let Some((first, rest)) = encoding.split_first() else { return false }; + if !is_letter(first) || !rest.iter().all(|c| is_letter(c) || is_digit(c) || matches!(*c, 0x2D | 0x2E | 0x5F)) { + return false; + } + at = next; + } + if let Some((next, standalone)) = decl_attribute(body, at, "standalone") { + if standalone != parser_units("yes").as_slice() && standalone != parser_units("no").as_slice() { + return false; + } + at = next; + } + skip_xml_space(body, at) == body.len() +} + /// An XML name without a prefix (ECMA-357 section 13.1.2.1). pub(crate) fn is_ncname(units: &[u16]) -> bool { let Some((&first, rest)) = units.split_first() else { return false }; @@ -2305,8 +2351,30 @@ impl Interp { src.get(from..)?.windows(needle.len()).position(|w| w == needle).map(|i| from + i) } + /// Takes fuel for `units` code units that the parser scanned, on top of the fuel of the step + /// that scanned them, so a short run costs nothing extra. + fn xml_scan_fuel(&mut self, units: usize) -> JsResult<()> { + self.xml_take_fuel(units / UNITS_PER_FUEL) + } + + /// The offset after the white space at `src[pos]`, taking fuel for its length. + fn skip_space(&mut self, src: &[u16], pos: usize) -> JsResult { + let end = skip_xml_space(src, pos); + self.xml_scan_fuel(end - pos)?; + Ok(end) + } + + /// The offset after the XML name at `src[pos]`, taking fuel for the units it scanned. fn read_name(&mut self, src: &[u16], pos: usize) -> JsResult { - scan_name(src, pos).ok_or_else(|| self.syntax("invalid XML name")) + if let Some(end) = scan_name(src, pos) { + self.xml_scan_fuel(end - pos)?; + return Ok(end); + } + if src.get(pos).copied().is_some_and(is_name_start) { + let scanned = src.get(pos..).unwrap_or_default().iter().skip(1).take_while(|&&c| is_name_part(c)).count(); + self.xml_scan_fuel(scanned + 1)?; + } + Err(self.syntax("invalid XML name")) } fn check_units(&mut self, src: &[u16], from: usize, to: usize) -> JsResult<()> { @@ -2390,9 +2458,7 @@ impl Interp { let mut raw_attrs: Vec<(usize, usize, Vec)> = Vec::new(); let closes = loop { let before = at; - while src.get(at).copied().is_some_and(is_xml_space) { - at += 1; - } + at = self.skip_space(src, at)?; match src.get(at).copied() { None => return Err(self.syntax("unterminated XML tag")), Some(SLASH) if src.get(at + 1) == Some(>) => { @@ -2407,26 +2473,22 @@ impl Interp { Some(_) => { self.use_fuel()?; let end = self.read_name(src, at)?; - let mut p = end; - while src.get(p).copied().is_some_and(is_xml_space) { - p += 1; - } + let p = self.skip_space(src, end)?; if src.get(p) != Some(&EQ) { return Err(self.syntax("expected '=' after an attribute name")); } - p += 1; - while src.get(p).copied().is_some_and(is_xml_space) { - p += 1; - } + let p = self.skip_space(src, p + 1)?; let quote = match src.get(p).copied() { Some(q @ (DQUOTE | SQUOTE)) => q, _ => return Err(self.syntax("expected a quoted attribute value")), }; let value_start = p + 1; let Some(value_end) = Self::find_from(src, value_start, &[quote]) else { + self.xml_text_fuel(src.len().saturating_sub(value_start))?; return Err(self.syntax("unterminated attribute value")); }; if src.get(value_start..value_end).is_some_and(|v| v.contains(<)) { + self.xml_text_fuel(value_end - value_start)?; return Err(self.syntax("'<' cannot appear in an attribute value")); } let mut value = Vec::new(); @@ -2528,10 +2590,7 @@ impl Interp { fn parse_close_tag(&mut self, src: &[u16], pos: usize, open: &mut Vec, names: &mut Vec<(usize, usize)>) -> JsResult { let name_start = pos + 2; let name_end = self.read_name(src, name_start)?; - let mut at = name_end; - while src.get(at).copied().is_some_and(is_xml_space) { - at += 1; - } + let at = self.skip_space(src, name_end)?; if src.get(at) != Some(>) { return Err(self.syntax("expected '>' at the end of a closing tag")); } @@ -2552,14 +2611,17 @@ impl Interp { held.add(self, string_bytes(source.len()).saturating_mul(2))?; let settings = self.xml_settings()?; let mut src = source; - let mut offset_start = 0; - while src.get(offset_start).copied().is_some_and(is_xml_space) { - offset_start += 1; - } + let offset_start = skip_xml_space(src, 0); if src.get(offset_start..).is_some_and(|r| r.starts_with(&parser_units("")) else { + self.xml_text_fuel(src.len())?; return Err(self.syntax("unterminated XML declaration")); }; + self.xml_text_fuel(end + 2)?; + self.check_units(src, offset_start, end)?; + if !is_xml_decl_body(src.get(offset_start + 5..end).unwrap_or_default()) { + return Err(self.syntax("invalid XML declaration")); + } src = src.get(end + 2..).unwrap_or_default(); } let mut pos = 0; @@ -2573,6 +2635,7 @@ impl Interp { let end = Self::find_from(src, pos, &[LT]).unwrap_or(src.len()); let section_end = parser_units("]]>"); if src.get(pos..end).is_some_and(|run| run.windows(3).any(|w| w == section_end)) { + self.xml_text_fuel(end - pos)?; return Err(self.syntax("']]>' cannot appear in XML text")); } self.decode_into(src, pos, end, &mut text, false)?; @@ -2582,6 +2645,7 @@ impl Interp { let rest = src.get(pos..).unwrap_or_default(); if rest.starts_with(&parser_units("")) else { + self.xml_text_fuel(src.len() - pos)?; return Err(self.syntax("unterminated CDATA section")); }; self.xml_text_fuel(end - pos)?; @@ -2593,12 +2657,13 @@ impl Interp { self.flush_text(&mut text, settings.ignore_whitespace, &open, &mut roots)?; if rest.starts_with(&parser_units("