From f0e50437c22649353e53f80ac6f572d7df686420 Mon Sep 17 00:00:00 2001 From: Ahmed Gomaa Date: Wed, 11 Nov 2026 23:16:03 +0200 Subject: [PATCH 01/11] Save on a read-only location offers Save As A plain Save that the location refuses (read-only filesystem or permission denied) now opens Save As with the name filled in, instead of ending in an error dialog. The refusal travels as a typed engine error (READ_ONLY_LOCATION), so the control protocol and MCP still return an error and never open a panel. Recovery failures, explicit destinations, a busy picker and Close/Quit keep their existing flow. Closes #6146 --- apps/vectorcraft/src/host_services.rs | 2 + crates/engine/src/cmd/fileio.rs | 11 +- crates/engine/src/cmd/fileio/access.rs | 25 ++ crates/engine/src/cmd/fileio/save.rs | 9 +- .../engine/src/cmd/fileio/tests_readonly.rs | 52 ++++ crates/engine/src/error.rs | 7 + crates/engine/src/lib.rs | 2 +- crates/format/src/atomic.rs | 48 +++- crates/format/src/lib.rs | 2 +- crates/mcp/src/tests/fileio.rs | 23 ++ crates/ui-egui/src/io.rs | 5 + crates/ui-egui/src/io/read_only_save.rs | 64 +++++ crates/ui-egui/src/io/save.rs | 96 ++++++- crates/ui-egui/src/io/tests_read_only_save.rs | 266 ++++++++++++++++++ crates/ui-egui/src/lib.rs | 3 +- crates/ui-egui/src/menus/run.rs | 2 +- crates/ui-egui/src/menus/ui_commands.rs | 2 +- crates/ui-egui/src/picks.rs | 9 + crates/ui-egui/src/services.rs | 11 + docs/control-protocol.md | 25 +- docs/file-format-parity.md | 5 + docs/gaps.md | 5 +- docs/mcp.md | 25 +- docs/roadmap.md | 5 +- docs/target-app-parity.md | 9 +- docs/ui-parity.md | 4 + 26 files changed, 684 insertions(+), 33 deletions(-) create mode 100644 crates/engine/src/cmd/fileio/tests_readonly.rs create mode 100644 crates/ui-egui/src/io/read_only_save.rs create mode 100644 crates/ui-egui/src/io/tests_read_only_save.rs diff --git a/apps/vectorcraft/src/host_services.rs b/apps/vectorcraft/src/host_services.rs index 187c42cde7..bd7c8aec80 100644 --- a/apps/vectorcraft/src/host_services.rs +++ b/apps/vectorcraft/src/host_services.rs @@ -358,6 +358,8 @@ pub(crate) fn services(parent: Parent, ui: egui::Context) -> Services { exists: Some(Box::new(|p: &str| std::path::Path::new(p).exists())), // Background Save and Export write from a worker thread. write_shared: Some(std::sync::Arc::new(write_file)), + save_write: Some(std::sync::Arc::new(fileio::write_save_file)), + save_stream: Some(std::sync::Arc::new(fileio::write_save_file_with)), // Native saves stream into the file as they are encoded. stream_shared: Some(std::sync::Arc::new(|p: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>| { fileio::write_file_with(p, fill).map_err(|e| e.to_string()) diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index 04a443ed4c..2b7969a434 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -89,9 +89,9 @@ pub use pdfimport::{LoadOptions, page_document}; pub(crate) use save::job_for; pub(crate) use save::save as save_document; pub use save::{ - SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, dialog_folder, export_folder, reads_option as save_reads_option, - remembered_save_options, save_filters, save_format, save_job, save_plan, save_with, stamp_save_dates, template_filters, templates_dialog_folder, - templates_folder, + SAVE_FORMATS, SaveJob, SaveMode, SavePlan, StreamWrite, TEMPLATE_FORMATS, dialog_folder, documents_dialog_folder, export_folder, + reads_option as save_reads_option, remembered_save_options, save_filters, save_format, save_job, save_plan, save_with, stamp_save_dates, + template_filters, templates_dialog_folder, templates_folder, }; pub(crate) use screens::{ PARAM_KEYS as SCREEN_PARAM_KEYS, ROW_KEYS as SCREEN_ROW_KEYS, UNWRITTEN_FORMATS as SCREEN_UNWRITTEN_FORMATS, @@ -120,7 +120,10 @@ mod formats; #[cfg(not(target_arch = "wasm32"))] pub(crate) use access::list_dir; -pub use access::write_file_with; +pub use access::{write_file_with, write_save_file, write_save_file_with}; + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests_readonly; #[cfg(not(target_arch = "wasm32"))] pub(crate) use access::{Placed, copy_new, file_metadata}; pub(crate) use access::{absolute_path, create_dir, document_path, file_created, file_stamp, read_file, read_file_head, write_file, write_new_file}; diff --git a/crates/engine/src/cmd/fileio/access.rs b/crates/engine/src/cmd/fileio/access.rs index 4dce2023bb..0e69147a32 100644 --- a/crates/engine/src/cmd/fileio/access.rs +++ b/crates/engine/src/cmd/fileio/access.rs @@ -89,6 +89,31 @@ pub fn write_file_with(path: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write) vectorcraft_format::write_atomic_with(std::path::Path::new(path), |f| fill(f)).map_err(|e| EngineError::Other(format!("{path}: {e}"))) } +/// The save commands' writer: preserves a filesystem location refusal as a typed error. Other +/// writers keep their existing errors; an automation-root refusal is never reclassified. +pub fn write_save_file(path: &str, bytes: &[u8]) -> Result<()> { + write_save_file_with(path, &mut |w| w.write_all(bytes)) +} + +/// [`write_save_file`] for a streamed save. +#[cfg(not(target_arch = "wasm32"))] +pub fn write_save_file_with(path: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>) -> Result<()> { + vectorcraft_doc::cancel::check()?; + may_write(path)?; + vectorcraft_format::write_atomic_with(std::path::Path::new(path), |f| fill(f)).map_err(|e| save_write_error(path, e)) +} + +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn save_write_error(path: &str, error: std::io::Error) -> EngineError { + let reason = format!("{path}: {error}"); + if vectorcraft_format::read_only_location(&error) { EngineError::ReadOnlyLocation(reason) } else { EngineError::Other(reason) } +} + +#[cfg(target_arch = "wasm32")] +pub fn write_save_file_with(path: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>) -> Result<()> { + write_file_with(path, fill) +} + #[cfg(not(target_arch = "wasm32"))] pub(crate) fn create_dir(path: &str) -> Result<()> { may_write(path)?; diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index 9f4e5b0572..791c25a9a2 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -157,6 +157,11 @@ fn documents_folder() -> Option { Some(home.join("Documents")) } +/// A Save As after a refused Save starts in Documents, else home, never the refused folder. +pub fn documents_dialog_folder() -> Option { + documents_folder().filter(|p| p.is_dir()).map(|p| p.to_string_lossy().into_owned()).or_else(home_folder) +} + /// `XDG_DOCUMENTS_DIR` in a `user-dirs.dirs` file's `text`, `$HOME` being `home`. None when it isn't /// set, is relative, or is the home folder itself (the spec's way of turning it off). fn xdg_documents(text: &str, home: &std::path::Path) -> Option { @@ -1032,7 +1037,7 @@ pub(crate) fn save(s: &mut Session, mode: SaveMode, p: &Value) -> Result let plan = save_plan(s, mode, p)?; let job = save_job(s, plan)?; let uid = s.doc()?.uid; - let out = job.write_streamed(&mut super::write_file_with)?; + let out = job.write_streamed(&mut super::write_save_file_with)?; job.complete(s, uid); Ok(out) } @@ -1104,7 +1109,7 @@ pub(super) fn specs() -> Vec { "Save Document", [], None, - "{path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version, preview or precision given flat, or a key given in options), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), precision?: 0..9 (native: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact; default: exact, and a later save without it writes exactly again), separateArtboards?: false (native and .ai: also save each artboard of `range` to -. beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: true (native: also carry a PDF of every artboard; false omits it) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly, on the active artboard (the app's, else the one the document opened on; with editingData: false, the first); separateArtboards writes more files, one per artboard. EPS saves the chosen artboard (first by default), retaining its page margins and the whole editable document; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified. A key that no save format takes, beside the path or in options, and an unknown key inside a PDF option's object or svg, are refused", + "{path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version, preview or precision given flat, or a key given in options), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), precision?: 0..9 (native: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact; default: exact, and a later save without it writes exactly again), separateArtboards?: false (native and .ai: also save each artboard of `range` to -. beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: true (native: also carry a PDF of every artboard; false omits it) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly, on the active artboard (the app's, else the one the document opened on; with editingData: false, the first); separateArtboards writes more files, one per artboard. EPS saves the chosen artboard (first by default), retaining its page margins and the whole editable document; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified. A key that no save format takes, beside the path or in options, and an unknown key inside a PDF option's object or svg, are refused. A terminal filesystem permission/read-only refusal returns READ_ONLY_LOCATION: followed by the reason; recovery-copy failures keep their original error. Non-UI callers never open a save panel", has_doc, |s, p| save(s, SaveMode::Save, p) ), diff --git a/crates/engine/src/cmd/fileio/tests_readonly.rs b/crates/engine/src/cmd/fileio/tests_readonly.rs new file mode 100644 index 0000000000..badf82a8cf --- /dev/null +++ b/crates/engine/src/cmd/fileio/tests_readonly.rs @@ -0,0 +1,52 @@ +//! Save errors retain their type without changing the other filesystem writers (#6146). + +use std::io::{self, ErrorKind}; + +use serde_json::json; + +use super::{SaveMode, access, save_plan, save_with, write_file_with, write_save_file_with}; +use crate::{EngineError, READ_ONLY_LOCATION, Session}; + +#[test] +fn readonly_error_is_typed_and_other_writers_keep_their_error() { + let dir = vectorcraft_testkit::temp_dir("save-refusal"); + let path = dir.join("Poster.vectorcraft").to_string_lossy().into_owned(); + for kind in [ErrorKind::PermissionDenied, ErrorKind::ReadOnlyFilesystem, ErrorKind::StorageFull, ErrorKind::NotFound, ErrorKind::Other] { + let typed = write_save_file_with(&path, &mut |_| Err(io::Error::new(kind, "injected"))).unwrap_err(); + let ordinary = write_file_with(&path, &mut |_| Err(io::Error::new(kind, "injected"))).unwrap_err(); + let reason = format!("{path}: injected"); + assert!(matches!(&ordinary, EngineError::Other(_))); + assert_eq!(ordinary.to_string(), reason); + if matches!(kind, ErrorKind::PermissionDenied | ErrorKind::ReadOnlyFilesystem) { + assert!(matches!(&typed, EngineError::ReadOnlyLocation(_))); + assert_eq!(typed.to_string(), format!("{READ_ONLY_LOCATION}: {reason}")); + } else { + assert!(matches!(&typed, EngineError::Other(_))); + assert_eq!(typed.to_string(), reason); + } + assert!(!std::path::Path::new(&path).exists()); + } + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn readonly_failed_save_keeps_document_and_options() { + let mut session = Session::new(); + session.execute("file.new", &json!({"width": 40, "height": 40})).unwrap(); + session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap(); + let st = session.doc_mut().unwrap(); + st.path = Some("Poster.svg".into()); + st.format = "svg"; + st.save_options = json!({"decimals": 3}).as_object().unwrap().clone(); + let before = serde_json::to_value(&*st.doc).unwrap(); + let options = st.save_options.clone(); + let plan = save_plan(&session, SaveMode::Save, &json!({"modified": null})).unwrap(); + let e = save_with(&mut session, plan, |path, _| Err(access::save_write_error(path, io::Error::from(ErrorKind::PermissionDenied)))).unwrap_err(); + assert!(matches!(e, EngineError::ReadOnlyLocation(_))); + let st = session.doc().unwrap(); + assert_eq!(st.path.as_deref(), Some("Poster.svg")); + assert_eq!(st.format, "svg"); + assert_eq!(st.save_options, options); + assert!(st.is_dirty()); + assert_eq!(serde_json::to_value(&*st.doc).unwrap(), before); +} diff --git a/crates/engine/src/error.rs b/crates/engine/src/error.rs index d32fda613d..6ab1608bd7 100644 --- a/crates/engine/src/error.rs +++ b/crates/engine/src/error.rs @@ -3,6 +3,9 @@ use super::{NodeId, cancel}; /// The text of [`EngineError::Busy`], which ends every error a frontend reports for it. pub const SCRIPT_BUSY: &str = "a script is running: stop it or wait for it to finish"; +/// Stable prefix for a save refused by the filesystem: choose another destination. +pub const READ_ONLY_LOCATION: &str = "READ_ONLY_LOCATION"; + #[derive(Debug, thiserror::Error)] pub enum EngineError { #[error("unknown command `{0}`")] @@ -20,6 +23,10 @@ pub enum EngineError { NoNode(NodeId), #[error("{0}")] Other(String), + /// The save's terminal filesystem error refuses writes here. The reason keeps the path and + /// system message. This never means an automation-root refusal or a recovery-copy failure. + #[error("{code}: {0}", code = READ_ONLY_LOCATION)] + ReadOnlyLocation(String), /// The caller asked for the command to stop ([`cancel`]). It changed nothing. #[error("cancelled; nothing was changed")] Cancelled, diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 091348a6bd..6bc570623d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -370,7 +370,7 @@ pub(crate) use command_sets::SELECTS_GIVEN_IDS; use command_sets::given_ids; pub use command_sets::{FIT_ACTIVE_ARTBOARD, ON_ACTIVE_ARTBOARD, waits_for_tool_edit}; pub use doc_state::{DocState, OpenRows, RecalledSelection}; -pub use error::{EngineError, Result, SCRIPT_BUSY}; +pub use error::{EngineError, READ_ONLY_LOCATION, Result, SCRIPT_BUSY}; #[cfg(test)] use history::history_budget_for; pub use history::{History, HistoryEntry, NamedStep, default_history_budget}; diff --git a/crates/format/src/atomic.rs b/crates/format/src/atomic.rs index 863c6fdd34..8e7149cad2 100644 --- a/crates/format/src/atomic.rs +++ b/crates/format/src/atomic.rs @@ -18,6 +18,25 @@ use std::io::{self, Write as _}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; +/// Whether the returned write error refuses this location. Recovery errors must still be shown: +/// their message names copies the user may need, even when their kind is PermissionDenied. +pub fn read_only_location(error: &io::Error) -> bool { + matches!(error.kind(), io::ErrorKind::ReadOnlyFilesystem | io::ErrorKind::PermissionDenied) + && !error.get_ref().is_some_and(|e| e.is::()) +} + +/// Marks the existing recovery message without changing its text or the returned error kind. +#[derive(Debug)] +struct RecoveryError(String); + +impl std::fmt::Display for RecoveryError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl std::error::Error for RecoveryError {} + /// Write `bytes` to `path` atomically (see the module docs). pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> { write_atomic_with(path, |f| f.write_all(bytes)) @@ -102,7 +121,7 @@ fn write_over(tmp: &Path, target: &Path, refused: io::Error) -> io::Result<()> { let _ = std::fs::remove_file(old); let why = format!("{refused}, and the file can't be copied aside before it is overwritten ({e}); the new content is kept in {}", tmp.display()); - return Err(io::Error::new(refused.kind(), why)); + return Err(io::Error::new(refused.kind(), RecoveryError(why))); } let copied = to.set_len(0).and_then(|()| io::copy(&mut from, &mut to)).and_then(|_| sync_file(&to)); drop((from, to)); @@ -115,10 +134,11 @@ fn write_over(tmp: &Path, target: &Path, refused: io::Error) -> io::Result<()> { } Ok(()) } - (Err(e), Some(old)) => { - Err(io::Error::new(e.kind(), format!("{e} (the new content is kept in {}, the file as it was in {})", tmp.display(), old.display()))) - } - (Err(e), None) => Err(io::Error::new(e.kind(), format!("{e} (the new content is kept in {})", tmp.display()))), + (Err(e), Some(old)) => Err(io::Error::new( + e.kind(), + RecoveryError(format!("{e} (the new content is kept in {}, the file as it was in {})", tmp.display(), old.display())), + )), + (Err(e), None) => Err(io::Error::new(e.kind(), RecoveryError(format!("{e} (the new content is kept in {})", tmp.display())))), } } @@ -318,6 +338,24 @@ fn keep_permissions(_: &Path, _: &Path) -> io::Result<()> { mod tests { use super::*; + #[test] + fn readonly_classification_uses_the_terminal_kind_and_excludes_recovery() { + for (kind, expected) in [ + (io::ErrorKind::PermissionDenied, true), + (io::ErrorKind::ReadOnlyFilesystem, true), + (io::ErrorKind::StorageFull, false), + (io::ErrorKind::QuotaExceeded, false), + (io::ErrorKind::NotFound, false), + (io::ErrorKind::Other, false), + ] { + assert_eq!(read_only_location(&io::Error::new(kind, "injected")), expected, "{kind:?}"); + let recovery = io::Error::new(kind, RecoveryError("the new content is kept in a.tmp".into())); + assert!(!read_only_location(&recovery), "recovery: {kind:?}"); + assert_eq!(recovery.kind(), kind); + assert_eq!(recovery.to_string(), "the new content is kept in a.tmp"); + } + } + fn dir(name: &str) -> PathBuf { let d = std::env::temp_dir().join(format!("vc-atomic-{name}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&d); diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 38545b8292..ed1777b6de 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -55,7 +55,7 @@ use serde::{Deserializer, Serialize}; use vectorcraft_doc::{Document, ImageBlob}; #[cfg(not(target_arch = "wasm32"))] -pub use atomic::{sync_file, write_atomic, write_atomic_with, write_new_with}; +pub use atomic::{read_only_location, sync_file, write_atomic, write_atomic_with, write_new_with}; pub use craft_vectorcraft_format::base64::{decode as base64_decode, decoded_len as base64_decoded_len, encode as base64_encode}; pub use craft_vectorcraft_format::{ BLOB_MAGIC, COMPRESSED_SINCE, EXTENSION, ICC_MIME, INLINE_MAX, LEGACY_EXTENSION, MIN_VERSION, PREVIEW_MAX, VERSION, is_compressed, json_part, diff --git a/crates/mcp/src/tests/fileio.rs b/crates/mcp/src/tests/fileio.rs index 00ff3a2d77..d29161a68b 100644 --- a/crates/mcp/src/tests/fileio.rs +++ b/crates/mcp/src/tests/fileio.rs @@ -357,6 +357,29 @@ fn save_file_waits_for_the_file_in_the_app() { } } +#[test] +fn readonly_save_file_keeps_the_stable_error_prefix() { + struct Refused(bool); + impl Backend for Refused { + fn call(&mut self, method: &str, params: Value) -> Result { + assert_eq!(method, "app.save"); + assert_eq!(params.get("wait").and_then(Value::as_bool), self.0.then_some(true)); + Err(vectorcraft_engine::EngineError::ReadOnlyLocation("Poster.svg: injected refusal".into()).to_string()) + } + fn has_ui(&self) -> bool { + self.0 + } + fn describe(&self) -> String { + "injected save refusal".into() + } + } + for desktop in [false, true] { + let r = call_tool(&mut Refused(desktop), "save_file", &json!({})); + assert!(r.is_error); + assert_eq!(r.content[0]["text"], "save_file: READ_ONLY_LOCATION: Poster.svg: injected refusal"); + } +} + fn pdf_pages(pdf: &[u8]) -> usize { let mut h = Headless::new(); h.call("engine.execute", json!({"command": "document.open", "params": {"name": "p.pdf", "dataBase64": vectorcraft_format::base64_encode(pdf)}})) diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index cc6f5d2262..a3a41c922a 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -15,8 +15,12 @@ use crate::{FilePick, Services, VectorcraftApp, dialogs}; mod export; mod open; +mod read_only_save; mod save; +#[cfg(test)] +mod tests_read_only_save; + pub use export::{EXPORT_CANCELLED, cancel_exports, export, export_for_screens, export_pdf, hide_exports, reveal, save_command_output}; pub(crate) use export::{ export_files, exported_with_notes, file_url, open_in_app, open_original, reveal_path, run_to_file, save_command_output_named, @@ -29,6 +33,7 @@ pub use open::{ }; #[cfg(test)] pub(crate) use open::{open_file, opened_with_notes}; +pub(crate) use save::save_current; pub use save::{ask_format_options, ask_pdf_options, remember_active_artboard, remember_view, save, save_pdf}; pub(crate) use save::{export_path, is_web, report_saved, target_path, write_named, write_reporting, write_to}; #[cfg(test)] diff --git a/crates/ui-egui/src/io/read_only_save.rs b/crates/ui-egui/src/io/read_only_save.rs new file mode 100644 index 0000000000..f49603eba2 --- /dev/null +++ b/crates/ui-egui/src/io/read_only_save.rs @@ -0,0 +1,64 @@ +//! The continuation of ordinary File > Save when its own location refuses writes (#6146). +//! It reuses Save As, including extension and overwrite checks, under its own picker entry. + +use std::rc::Rc; + +use serde_json::{Value, json}; +use vectorcraft_engine::cmd::fileio::{self, SaveMode, SavePlan}; + +use crate::picks::{self, Entry}; +use crate::{FilePick, VectorcraftApp}; + +pub(super) struct Retry { + path: Option, + pick: FilePick, + params: Value, +} + +impl Retry { + pub(super) fn new(plan: &SavePlan, folder: Option) -> Self { + let name = plan.path.as_deref().map(fileio::file_name).unwrap_or_else(|| plan.name.clone()); + let mut params = json!({ "format": plan.format.id, "options": plan.options }); + if let Some(modified) = plan.modified { + params["modified"] = json!(modified); + } + Self { path: plan.path.clone(), pick: FilePick { name, folder, filters: vec![] }, params } + } + + /// None when this continuation is stale or another picker prevents it. The existing failed-save + /// dialog then stays in charge; there is no queue of deferred Save As requests. + pub(super) fn instead(self, app: &mut VectorcraftApp, uid: u64) -> Option> { + if app.automation_is_acting() || app.services.pick_save.is_none() || app.picks.is_waiting() || super::save::is_web(app) { + return None; + } + let index = app.session.documents().iter().position(|st| st.uid == uid && st.path == self.path)?; + if app.session.active_index() != Some(index) && app.run("document.activate", json!({ "index": index })).is_err() { + return None; + } + let name = &self.pick.name; + let status = format!("Cannot write to the location of “{name}”; choose where to save it"); + app.status(status.clone()); + let save = Rc::new(move |app: &mut VectorcraftApp| { + // An agent can retarget this document while the sheet is open. Its answer must not + // replace that later choice. The picker itself already checks the document's uid. + if !app.session.active().is_some_and(|st| st.uid == uid && st.path == self.path) { + return Err("cancelled".into()); + } + super::save::save_suggesting(app, SaveMode::SaveAs, &self.params, false, Some(&self.pick)) + }); + let again = save.clone(); + let entry = Entry::Call(Rc::new(move |app| { + if let Err(e) = again(app) { + app.status(e); + } + })); + let r = picks::as_own_entry(app, entry, |app| save(app)); + Some(match r { + Err(e) if e == "cancelled" && app.picks.is_waiting() => { + app.status(status); + Ok(json!({ "pending": "fileDialog" })) + } + r => r, + }) + } +} diff --git a/crates/ui-egui/src/io/save.rs b/crates/ui-egui/src/io/save.rs index cfbdaaadba..5efe776d64 100644 --- a/crates/ui-egui/src/io/save.rs +++ b/crates/ui-egui/src/io/save.rs @@ -241,6 +241,31 @@ fn plan(app: &VectorcraftApp, mode: SaveMode, p: &Value) -> Result Save alone offers another destination when its own location refuses writes. +/// Save Changes during Close/Quit continues to use [`save`]. +pub(crate) fn save_current(app: &mut VectorcraftApp, p: &Value) -> Result { + save_current_in(app, p, fileio::documents_dialog_folder()) +} + +/// The same entry with a resolved Documents folder (also permits deterministic host-free tests). +pub(super) fn save_current_in(app: &mut VectorcraftApp, p: &Value, folder: Option) -> Result { + remember_view(app); + let first = plan(app, SaveMode::Save, &acknowledged(p))?; + let own = !app.automation_is_acting() + && !is_web(app) + && p.get("path").is_none_or(Value::is_null) + && first.path.is_some() + && app.session.active().is_some_and(|st| !st.converted && !st.recovered && st.path == first.path && st.format == first.format.id); + if !own { + return save(app, SaveMode::Save, p, true); + } + if let Some(r) = first.path.as_deref().and_then(|path| ask_before_losing(app, path, "file.save", p)) { + return Ok(r); + } + let retry = super::read_only_save::Retry::new(&first, folder); + write_plan(app, first, Some(retry)) +} + /// File → Save, Save As…, Save a Copy…, Save as Template… and the options dialogs' OK. `p` is the /// engine command's `{path?, format?, options?, svg?}`. /// @@ -253,6 +278,17 @@ fn plan(app: &VectorcraftApp, mode: SaveMode, p: &Value) -> Result, /// path?}` while a dialog is open. pub fn save(app: &mut VectorcraftApp, mode: SaveMode, p: &Value, ask_options: bool) -> Result { + save_suggesting(app, mode, p, ask_options, None) +} + +/// Reuse Save As with a captured name and folder, without changing the document before writing. +pub(super) fn save_suggesting( + app: &mut VectorcraftApp, + mode: SaveMode, + p: &Value, + ask_options: bool, + suggestion: Option<&FilePick>, +) -> Result { remember_view(app); // Writing over the file the document was read from is asked about here, not refused. let first = plan(app, mode, &acknowledged(p))?; @@ -264,7 +300,7 @@ pub fn save(app: &mut VectorcraftApp, mode: SaveMode, p: &Value, ask_options: bo if ask && matches!(mode, SaveMode::SaveAs | SaveMode::Copy) && matches!(first.format.id, "svg" | "svgz") { return ask_format_options(app, mode, first.format, &path); } - return write_plan(app, first); + return write_plan(app, first, None); } if is_web(app) && ask_options { return Ok(open_options(app, mode.command(), first.format, &first.name, true)); @@ -273,8 +309,12 @@ pub fn save(app: &mut VectorcraftApp, mode: SaveMode, p: &Value, ask_options: bo SaveMode::Template => fileio::template_filters(first.format.id), _ => fileio::save_filters(first.format.id), }); - let pick = FilePick { name: first.name.clone(), folder: first.folder.clone(), filters }; - let picked = pick_finished(app, &pick, |picked, file_type| with_picked_type(picked, &first.name, file_type, first.format, mode))?; + let pick = FilePick { + name: suggestion.map_or_else(|| first.name.clone(), |s| s.name.clone()), + folder: suggestion.map_or_else(|| first.folder.clone(), |s| s.folder.clone()), + filters, + }; + let picked = pick_finished(app, &pick, |picked, file_type| with_picked_type(picked, &pick.name, file_type, first.format, mode))?; // The picked file type wins over a `format` param. let mut q = if p.is_object() { p.clone() } else { json!({}) }; if let Some(o) = q.as_object_mut() { @@ -291,7 +331,7 @@ pub fn save(app: &mut VectorcraftApp, mode: SaveMode, p: &Value, ask_options: bo crate::dialogs::opened_by_panel(app); return r; } - write_plan(app, chosen) + write_plan(app, chosen, None) } /// The UI command that runs a save in `mode`. @@ -418,7 +458,7 @@ pub(crate) fn saved_status(path: &str, r: &Value) -> String { /// ([`background`]): this returns `{path, background: true, status, format, warnings}` at once /// (`warnings`: what the format loses, then the save's notes on its params, such as an option the format /// doesn't read), and the document takes the file (and counts as saved, as of the snapshot) once it is written. -fn write_plan(app: &mut VectorcraftApp, plan: SavePlan) -> Result { +fn write_plan(app: &mut VectorcraftApp, plan: SavePlan, retry: Option) -> Result { let uid = app.session.active().ok_or("no document")?.uid; // Saves of one document finish in order. if app.background.busy_with(uid) { @@ -444,19 +484,53 @@ fn write_plan(app: &mut VectorcraftApp, plan: SavePlan) -> Result let enabled = app.session.prefs.background_save && target.is_some(); // The work writes the file; the copy makes it the document's own afterwards. let done = job.clone(); + // Capture the caller before background work leaves the request: it must never open a panel + // or error dialog later, when answering_control has been reset. + let by_control = app.automation_is_acting(); + let location_refused = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let refused_by_work = location_refused.clone(); + let own_path = target.clone(); + let save_write = app.services.save_write.clone().filter(|_| !is_web(app)); + let save_stream = app.services.save_stream.clone().filter(|_| job.streams() && !is_web(app)); // A native file streams into the file as it is encoded (desktop; not to a download). let stream = app.services.stream_shared.clone().filter(|_| job.streams() && !is_web(app)); let work = move |write: Writer| { - match stream { - Some(stream) => job.write_streamed(&mut |p, fill| stream(p, fill).map_err(EngineError::Other)), - None => job.write(|p, b| write(p, b).map_err(EngineError::Other)), - } - .map_err(|e| e.to_string()) + let mut own_refused = false; + let mut observed = |p: &str, r: vectorcraft_engine::Result<()>| { + own_refused = own_path.as_deref() == Some(p) && matches!(&r, Err(EngineError::ReadOnlyLocation(_))); + r + }; + let r = match (save_stream, save_write, stream) { + (Some(stream), _, _) => job.write_streamed(&mut |p, fill| observed(p, stream(p, fill))), + (_, Some(write), _) => job.write(|p, b| observed(p, write(p, b))), + (_, _, Some(stream)) => job.write_streamed(&mut |p, fill| stream(p, fill).map_err(EngineError::Other)), + _ => job.write(|p, b| write(p, b).map_err(EngineError::Other)), + }; + r.map_err(|e| { + if let EngineError::ReadOnlyLocation(reason) = &e { + refused_by_work.store(own_refused, std::sync::atomic::Ordering::Relaxed); + if !by_control { + return reason.clone(); + } + } + e.to_string() + }) }; let failed = target.clone().unwrap_or_default(); let then = move |app: &mut VectorcraftApp, r: Result| { + if r.is_err() + && location_refused.load(std::sync::atomic::Ordering::Relaxed) + && let Some(retry) = retry + && let Some(r) = retry.instead(app, uid) + { + return r; + } // Not written: the document keeps its file, format and modified state, and the user is told. - let r = r.inspect_err(|e| report_write_error(app, false, &failed, e))?; + let r = r.inspect_err(|e| { + if !by_control { + report_write_error(app, false, &failed, e); + } + })?; // The document may have closed meanwhile: the file is written all the same. done.complete(&mut app.session, uid); let path = r["path"].as_str().unwrap_or_default().to_string(); diff --git a/crates/ui-egui/src/io/tests_read_only_save.rs b/crates/ui-egui/src/io/tests_read_only_save.rs new file mode 100644 index 0000000000..8a01003a90 --- /dev/null +++ b/crates/ui-egui/src/io/tests_read_only_save.rs @@ -0,0 +1,266 @@ +//! Injected writers and picker answers: no native panel or filesystem permissions are needed. + +use std::io::ErrorKind; +use std::sync::{Arc, Mutex, mpsc}; + +use serde_json::{Value, json}; +use vectorcraft_engine::{EngineError, READ_ONLY_LOCATION, Session}; + +use super::{fileio, save}; +use crate::control::ControlRequest; +use crate::tests::picks::off_the_ui_thread; +use crate::{Services, VectorcraftApp, background, dialogs, picks}; + +const DOCUMENTS: &str = "/home/artist/Documents"; + +#[derive(Default)] +struct Files { + original: String, + attempts: Vec, + kind: Option, + fail_second: bool, +} + +fn write(files: &Mutex, path: &str) -> vectorcraft_engine::Result<()> { + let mut files = files.lock().unwrap(); + files.attempts.push(path.into()); + if path == files.original || files.fail_second { + let reason = format!("{path}: injected refusal"); + return Err(match files.kind { + Some(ErrorKind::PermissionDenied | ErrorKind::ReadOnlyFilesystem) => EngineError::ReadOnlyLocation(reason), + _ => EngineError::Other(reason), + }); + } + Ok(()) +} + +fn app(format: &str, kind: ErrorKind) -> (VectorcraftApp, Arc>) { + let files = Arc::new(Mutex::new(Files { original: format!("/locked/Poster.{format}"), kind: Some(kind), ..Default::default() })); + let typed = files.clone(); + let ordinary = files.clone(); + let services = Services { + save_write: Some(Arc::new(move |path, _| write(&typed, path))), + write: Some(Box::new(move |path, _| { + write(&ordinary, path).map_err(|e| match e { + EngineError::ReadOnlyLocation(reason) => reason, + e => e.to_string(), + }) + })), + ..Default::default() + }; + let mut app = VectorcraftApp::new(Session::new(), services); + app.run("file.new", json!({"width": 40, "height": 40})).unwrap(); + app.run("shape.rectangle", json!({"x": 1, "y": 1, "width": 10, "height": 10})).unwrap(); + let st = app.session.doc_mut().unwrap(); + st.path = Some(files.lock().unwrap().original.clone()); + st.format = fileio::format(format).unwrap().id; + st.save_options = if format == "svg" { + json!({"decimals": 2, "preserveEditing": false}) + } else { + json!({"pdfCompatible": false, "compress": true, "preview": false}) + } + .as_object() + .unwrap() + .clone(); + (app, files) +} + +fn state(app: &VectorcraftApp) -> (Option, bool, &'static str, Value) { + let st = app.session.doc().unwrap(); + (st.path.clone(), st.is_dirty(), st.format, json!(st.save_options)) +} + +fn poll(app: &mut VectorcraftApp) { + picks::poll(app, &egui::Context::default()); +} + +fn normal_error(app: &VectorcraftApp) { + let d = app.ui.dialog.as_ref().expect("a persistent error dialog"); + assert_eq!(d.kind, dialogs::confirm::MESSAGE); + assert!(d.str("detail").contains("injected refusal")); + assert!(!d.str("detail").contains(READ_ONLY_LOCATION)); + assert!(app.ui.told.is_empty(), "no duplicate error dialog"); +} + +#[test] +fn readonly_fallback_prefills_save_as_and_cancel_keeps_the_failed_save_state() { + for kind in [ErrorKind::PermissionDenied, ErrorKind::ReadOnlyFilesystem] { + let (mut app, files) = app("svg", kind); + let before = state(&app); + let (shown, _) = off_the_ui_thread(&mut app.services); + let r = save::save_current_in(&mut app, &json!({"modified": null}), Some(DOCUMENTS.into())).unwrap(); + assert_eq!(r["pending"], "fileDialog"); + let requests = shown.requests(); + let [picks::PickRequest::Save(pick)] = requests.as_slice() else { panic!("expected one save panel: {requests:?}") }; + assert_eq!(pick.name, "Poster.svg"); + assert_eq!(pick.folder.as_deref(), Some(DOCUMENTS)); + assert_eq!(pick.filters.first(), crate::FilePick::filters(fileio::save_filters("svg")).first()); + assert!(app.ui.status.contains("choose where to save it")); + assert_eq!(state(&app), before); + assert!(app.ui.dialog.is_none()); + shown.answer(&[]); + poll(&mut app); + assert_eq!(state(&app), before); + assert_eq!(files.lock().unwrap().attempts, ["/locked/Poster.svg"]); + assert!(!app.picks.is_waiting()); + } +} + +#[test] +fn readonly_save_as_retargets_only_after_write_and_keeps_options() { + let (mut app, files) = app("svg", ErrorKind::PermissionDenied); + let before = state(&app); + let uid = app.session.doc().unwrap().uid; + let (shown, _) = off_the_ui_thread(&mut app.services); + assert_eq!(app.run("file.save", json!({})).unwrap()["pending"], "fileDialog"); + // Hold the second write after the picker answers: the chosen path is not yet the document's. + let (entered, writing) = mpsc::channel(); + let (release, released) = mpsc::channel(); + let released = Mutex::new(released); + let f = files.clone(); + app.services.save_write = Some(Arc::new(move |path, _| { + entered.send(()).unwrap(); + released.lock().unwrap().recv().unwrap(); + write(&f, path) + })); + app.services.write_shared = Some(Arc::new(|_, _| panic!("typed saves must use their own writer"))); + app.session.prefs.background_save = true; + shown.answer(&["/home/artist/Documents/Poster.svg"]); + poll(&mut app); + writing.recv().unwrap(); + assert_eq!(state(&app), before); + release.send(()).unwrap(); + background::wait_all(&mut app); + let st = app.session.document_mut(uid).unwrap(); + assert_eq!(st.path.as_deref(), Some("/home/artist/Documents/Poster.svg")); + assert!(!st.is_dirty()); + assert_eq!(st.format, before.2); + assert_eq!(json!(st.save_options), before.3); + assert_eq!(files.lock().unwrap().attempts, ["/locked/Poster.svg", "/home/artist/Documents/Poster.svg"]); + assert!(shown.requests().is_empty() && app.ui.dialog.is_none()); +} + +#[test] +fn readonly_second_failure_shows_one_dialog_without_reopening_the_picker() { + let (mut app, files) = app("svg", ErrorKind::PermissionDenied); + files.lock().unwrap().fail_second = true; + let before = state(&app); + let (shown, _) = off_the_ui_thread(&mut app.services); + app.run("file.save", json!({})).unwrap(); + shown.answer(&["/elsewhere/Poster.svg"]); + poll(&mut app); + normal_error(&app); + assert_eq!(state(&app), before); + assert!(shown.requests().is_empty() && !app.picks.is_waiting()); + assert_eq!(files.lock().unwrap().attempts, ["/locked/Poster.svg", "/elsewhere/Poster.svg"]); +} + +#[test] +fn readonly_other_errors_and_explicit_destinations_keep_the_dialog() { + for (command, params, kind) in [ + ("file.save", json!({}), ErrorKind::StorageFull), + ("file.save", json!({}), ErrorKind::NotFound), + ("file.save", json!({}), ErrorKind::Other), + ("file.save", json!({"path": "/locked/Poster.svg"}), ErrorKind::PermissionDenied), + ("file.saveAs", json!({"path": "/locked/Poster.svg", "options": {"decimals": 2}}), ErrorKind::PermissionDenied), + ("file.saveCopy", json!({"path": "/locked/Poster.svg", "options": {"decimals": 2}}), ErrorKind::PermissionDenied), + ("file.exportAs", json!({"path": "/locked/Poster.svg", "format": "svg"}), ErrorKind::PermissionDenied), + ] { + let (mut app, _) = app("svg", kind); + let before = state(&app); + // A fallback would return pending, so no inline/native dialog is necessary here. + app.services.pick_save = Some(Box::new(move |_| panic!("{command}: no fallback"))); + assert!(app.run(command, params).is_err(), "{command}"); + normal_error(&app); + assert_eq!(state(&app), before); + } +} + +#[test] +fn readonly_control_returns_the_prefix_and_never_opens_a_panel_even_after_completion() { + for wait in [true, false] { + let (mut app, _) = app("svg", ErrorKind::PermissionDenied); + app.services.pick_save = Some(Box::new(|_| panic!("automation never picks"))); + app.services.write_shared = Some(Arc::new(|_, _| panic!("typed writer required"))); + app.session.prefs.background_save = true; + let before = state(&app); + let (tx, rx) = mpsc::channel(); + app = app.with_control(rx); + let (req, reply) = ControlRequest::new("app.save", json!({"wait": wait})); + tx.send(req).unwrap(); + app.drain_control(&egui::Context::default()); + let reply = reply.try_recv().unwrap(); + if wait { + assert_eq!(reply["ok"], false); + assert!(reply["error"].as_str().unwrap().starts_with("READ_ONLY_LOCATION: "), "{reply}"); + } else { + assert_eq!(reply["result"]["background"], true); + assert!(!app.ui.answering_control); + background::wait_all(&mut app); + assert!(app.ui.status.contains("READ_ONLY_LOCATION: ")); + } + assert_eq!(state(&app), before); + assert!(app.ui.dialog.is_none() && app.ui.told.is_empty() && !app.picks.is_waiting()); + } +} + +#[test] +fn readonly_background_completion_resumes_the_original_uid_without_repeating_save() { + for streamed in [false, true] { + let format = if streamed { "vectorcraft" } else { "svg" }; + let (mut app, files) = app(format, ErrorKind::ReadOnlyFilesystem); + let original = files.lock().unwrap().original.clone(); + let uid = app.session.doc().unwrap().uid; + let (shown, _) = off_the_ui_thread(&mut app.services); + if streamed { + let f = files.clone(); + app.services.save_stream = Some(Arc::new(move |path, fill| { + let mut bytes = Vec::new(); + fill(&mut bytes).unwrap(); + write(&f, path) + })); + app.services.save_write = Some(Arc::new(|_, _| panic!("native save must stream"))); + } + app.services.write_shared = Some(Arc::new(|_, _| panic!("typed writer required"))); + app.session.prefs.background_save = true; + assert_eq!(app.run("file.save", json!({})).unwrap()["background"], true); + app.run("file.new", json!({"width": 20, "height": 20})).unwrap(); + let other = app.session.doc().unwrap().uid; + background::wait_all(&mut app); + assert_eq!(app.session.doc().unwrap().uid, uid); + assert_eq!(shown.requests().len(), 1); + // Switch again while the native panel is open: its answer belongs to the original uid. + app.run("document.activate", json!({"index": 1})).unwrap(); + let chosen = format!("/elsewhere/Poster.{format}"); + shown.answer(&[&chosen]); + poll(&mut app); + background::wait_all(&mut app); + assert_eq!(app.session.doc().unwrap().uid, uid); + assert_eq!(app.session.doc().unwrap().path.as_deref(), Some(chosen.as_str())); + assert!(!app.session.doc().unwrap().is_dirty()); + assert!(app.session.document_mut(other).unwrap().path.is_none()); + assert_eq!(files.lock().unwrap().attempts, [original, chosen]); + assert!(shown.requests().is_empty()); + } +} + +#[test] +fn readonly_busy_picker_and_save_changes_keep_the_existing_error_flow() { + let (mut app, _) = app("svg", ErrorKind::PermissionDenied); + let (shown, _) = off_the_ui_thread(&mut app.services); + app.run("file.saveAs", json!({})).unwrap_err(); + assert_eq!(shown.requests().len(), 1); + assert!(app.run("file.save", json!({})).is_err()); + normal_error(&app); + assert_eq!(shown.requests().len(), 1); + shown.answer(&[]); + poll(&mut app); + app.ui.dialog = None; + let st = app.session.doc().unwrap(); + app.ui.dialog = Some(crate::state::Dialog::new(crate::unsaved::KIND, json!({"index": 0, "uid": st.uid, "then": "close"}))); + assert!(crate::unsaved::confirm(&mut app).is_err()); + normal_error(&app); + assert!(shown.requests().is_empty()); + assert_eq!(app.session.documents().len(), 1); + assert!(app.session.doc().unwrap().is_dirty()); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 644125b545..c4d7c21c1a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -120,7 +120,8 @@ pub use craft_ui::docking::Monitor as DockMonitor; #[cfg(test)] pub(crate) use services::browsable_url; pub use services::{ - DownloadFn, ExistsFn, FileFilter, FilePick, Inbox, NativeInputHook, OpenUrlFn, PickOpen, PickSave, ReadFn, RevealFn, Services, WriteFn, + DownloadFn, ExistsFn, FileFilter, FilePick, Inbox, NativeInputHook, OpenUrlFn, PickOpen, PickSave, ReadFn, RevealFn, SaveStreamFn, SaveWriteFn, + Services, WriteFn, }; use state::{GeometryStamp, InputGeneration, PendingScreenshot, QueuedScreenshot}; pub(crate) use state::{InputFrame, take_frame_once}; diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index 1223e1e426..0e29e99e12 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -122,7 +122,7 @@ fn run_ui_part1(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option io::open_with(app, p), // Saves write through the app (save panel, download); with no path known the panel and the // format's options dialog ask first. - "file.save" => io::save(app, SaveMode::Save, p, true), + "file.save" => io::save_current(app, p), "file.saveAs" => io::save(app, SaveMode::SaveAs, p, true), "file.saveCopy" => io::save(app, SaveMode::Copy, p, true), "file.saveAsTemplate" => io::save(app, SaveMode::Template, p, true), diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index a96f13e67c..e21c1f1787 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -40,7 +40,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "file.save", "Save", "Cmd+S", - "{path?, format?, options?, svg?: {…SVG options}} = document.save, written through the app (a document saved as SVG, PDF or .ai, or opened from one Save can write back, saves as that again, with the same options); with no path known (never saved, converted) the Save As panel asks first", + "{path?, format?, options?, svg?: {…SVG options}} = document.save, written through the app (a document saved as SVG, PDF or .ai, or opened from one Save can write back, saves as that again, with the same options); with no path known (never saved, converted) the Save As panel asks first; ordinary Save to the document's existing file refused by filesystem permissions or a read-only volume opens Save As in Documents with the same name, format and options; cancelling keeps the original path and modified state. Explicit destinations and Close/Quit keep their existing error dialog. Automation gets READ_ONLY_LOCATION: followed by the reason and no panel", ), ( "file.place", diff --git a/crates/ui-egui/src/picks.rs b/crates/ui-egui/src/picks.rs index 4122c19410..080c335d4c 100644 --- a/crates/ui-egui/src/picks.rs +++ b/crates/ui-egui/src/picks.rs @@ -188,6 +188,15 @@ pub(crate) fn as_entry(app: &mut VectorcraftApp, entry: impl FnOnce() -> Entr r } +/// Run a continuation under its own entry: a refused Save's panel must resume Save As, not retry +/// the original Save. Restore the enclosing entry for any work it does afterwards. +pub(crate) fn as_own_entry(app: &mut VectorcraftApp, entry: Entry, f: impl FnOnce(&mut VectorcraftApp) -> R) -> R { + let outer = app.picks.entry.replace(entry); + let r = f(app); + app.picks.entry = outer; + r +} + /// [`as_entry`] for a panel's or a dialog's button that `f` handles. pub(crate) fn button(app: &mut VectorcraftApp, f: impl Fn(&mut VectorcraftApp) + 'static) { let f = Rc::new(f); diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs index 901c0a3409..3b0bc7caa9 100644 --- a/crates/ui-egui/src/services.rs +++ b/crates/ui-egui/src/services.rs @@ -43,6 +43,13 @@ pub type ReadHeadFn = Box Result, String>>; pub type WriteFn = Box Result<(), String>>; +/// A save writer that keeps typed filesystem failures, on either the UI or background thread. +pub type SaveWriteFn = Arc vectorcraft_engine::Result<()> + Send + Sync>; + +/// A typed save writer for native files encoded directly into the output. +pub type SaveStreamFn = + Arc std::io::Result<()>) -> vectorcraft_engine::Result<()> + Send + Sync>; + pub type ExistsFn = Box bool>; pub type Inbox = Arc)>>>; @@ -129,6 +136,10 @@ pub struct Services { /// stream into the file as they are encoded instead of being held whole in memory first. /// Without it they are encoded, then written with `write` / `write_shared`. pub stream_shared: Option, + /// Typed writers for document saves only. Without them the existing string writers are used + /// and failures keep their normal dialog. Downloads never use these callbacks. + pub save_write: Option, + pub save_stream: Option, /// Open a file (or a folder) in the system's default app for it (desktop: Edit Original, Show /// Package). Without it (the web) those answer with an error. pub open_file: Option, diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 04715474d0..abf5a0e4cb 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -1,5 +1,7 @@ # Control protocol +> **Last updated:** 2026-11-11 · **Change:** Save location-refusal error (#6146); existing response shapes retained. + > **Last updated:** 2026-10-31 · **Change:** Confining file access refuses a `:` in a name after the drive on Windows, an NTFS alternate data stream such as `x.svg:evil` (#3604); previously: Object → Expand… dimmed while the selection has an appearance Expand Appearance expands (`object.expand.info`'s `appearance`, #3089); previously: brush library panels (`window.brushLibrary`, `window.brushLibrary.other`, `ui.saveBrushLibrary`); previously: Transform Each opens on the values it was last confirmed with; its Copy button confirms with `copy: true` (#1732); previously: panel docking on craft-ui's layout: `ui.docking`, the parameters of `window.panel.float` and `window.panel.dock`; previously: `ui.screenshot`'s 64 MiB inline-PNG cap and its path-plus-data downgrade; symbol library panels (`window.symbolLibrary`, `window.symbolLibrary.other`, `ui.saveSymbolLibrary`); synthetic input budgets (#1709); bounded transport waits (#1726). `vectorcraft --control ` listens on `127.0.0.1:` (loopback only). The port must be a number @@ -111,7 +113,7 @@ the two can still win that race. | `app.export` with `useArtboards` | `{path, useArtboards: true, range? \| artboards?, …}` | Export As: PNG, JPEG and WebP write one file per chosen artboard (default all; SVG does so for a `range`), `-.`, and return `{path, files: [path…]}`; a PDF keeps them as pages of `path`. `useArtboards: false` covers the bounds of the visible art. The menu's Export As… is `file.exportAs`: the `exportAs` dialog (`format`, `useArtboards`, `all`, `range`), then a save dialog and the format's options: `pngOptions` / `jpgOptions` / `webpOptions`, `svgOptions`, or `savePdf` for a PDF with Use Artboards | | `ui.dialog.*` on `gradientStop` | `{field: "color" \| "opacity" \| "location", value}` | double-clicking a stop on the Gradient tool's annotator opens its popover (fields `index`, `x`, `y`, `tab`); set `color` (hex), `opacity` or `location` (percentages) and `ui.dialog.confirm` to apply them to the selected stop. `gradient.selectStop {index}` picks the stop the annotator, the panels and Delete/←/→ (`ui.key`) act on | | `engine.execute` `ui.savePdfDialog` | `{path?, preset?, range?, …document.exportPdf options}` | opens the Save PDF dialog (`file.export.pdf` with no params opens it too). Its fields are the `document.exportPdf` options: set `compatibility` with `ui.dialog.set {field: "compatibility", value: "1.5"}`, a whole section with `{field: "compression", value: {…}}`, the artboards with `__allArtboards: false` and `range`, the visible section with `__section` (General, Compression, Marks and Bleeds, Output, Advanced, Security, Summary). `ui.dialog.confirm` writes `path` (else asks for one), returns `{path, bytes, warnings}` and opens the file when `viewAfterSaving` is set | -| `file.save` / `file.saveAs` / `file.saveCopy` / `file.saveAsTemplate` (via `engine.execute`) | `{path?, format?, options?, svg?}` | Save writes the document's own file in its own format (a document opened from or saved as SVG/PDF saves as that again; the result's `warnings` say what the format loses). With `path` the file is written at once (Save As or a Copy to an SVG path without SVG options first opens SVG Options, below). Without one (never saved, a converted older file, Save As, a Copy, a Template) the app shows a save panel with one file type per save format (`vectorcraft`, `template`, `pdf`, `svg`, `svgz`, `ai`; for a Template, `template` and `ait`), then the format's options: SVG opens the `svgOptions` dialog (below; returns `{"pending": "svgOptions", "path"}`), PDF the `savePdf` dialog (its `__save` field names the save command); `ui.dialog.confirm` finishes the save. A name typed in the panel without the file type's extension gets it, and a name the panel returns with the extension of a format the panel does not list has it replaced (in a native save, `card.png` and `card.ait` are saved as `card.vectorcraft`, and `notes.v2`, whose extension names no format, is saved as `notes.v2.vectorcraft`); when a file of that name exists, which the panel did not ask about, the save (and every export or other write through a save panel) answers `cancelled` and opens a `replaceFile` dialog (fields `message`, `detail`, `path`): `ui.dialog.confirm` writes that file, `ui.dialog.cancel` writes nothing. On the web a `saveOptions` dialog (fields `path`, `format`) first names the file and picks the format. `file.formatOptions {format?}` reads the options a save would use. `file.revert` opens a `confirm` dialog (`{"pending": "confirm"}`); `ui.dialog.confirm` reloads the saved file in the same tab | +| `file.save` / `file.saveAs` / `file.saveCopy` / `file.saveAsTemplate` (via `engine.execute`) | `{path?, format?, options?, svg?}` | Save writes the document's own file in its own format (a document opened from or saved as SVG/PDF saves as that again; the result's `warnings` say what the format loses). With `path` the file is written at once (Save As or a Copy to an SVG path without SVG options first opens SVG Options, below). Without one (never saved, a converted older file, Save As, a Copy, a Template) the app shows a save panel with one file type per save format (`vectorcraft`, `template`, `pdf`, `svg`, `svgz`, `ai`; for a Template, `template` and `ait`), then the format's options: SVG opens the `svgOptions` dialog (below; returns `{"pending": "svgOptions", "path"}`), PDF the `savePdf` dialog (its `__save` field names the save command); `ui.dialog.confirm` finishes the save. A name typed in the panel without the file type's extension gets it, and a name the panel returns with the extension of a format the panel does not list has it replaced (in a native save, `card.png` and `card.ait` are saved as `card.vectorcraft`, and `notes.v2`, whose extension names no format, is saved as `notes.v2.vectorcraft`); when a file of that name exists, which the panel did not ask about, the save (and every export or other write through a save panel) answers `cancelled` and opens a `replaceFile` dialog (fields `message`, `detail`, `path`): `ui.dialog.confirm` writes that file, `ui.dialog.cancel` writes nothing. On the web a `saveOptions` dialog (fields `path`, `format`) first names the file and picks the format. `file.formatOptions {format?}` reads the options a save would use. `file.revert` opens a `confirm` dialog (`{"pending": "confirm"}`); `ui.dialog.confirm` reloads the saved file in the same tab Location refusals return `READ_ONLY_LOCATION: ` without a fallback panel for automation; see [Save location errors](#save-location-errors). | | SVG Options (`engine.execute` of `file.export.svg`, `file.saveAs`, `file.saveCopy`) | `{}` / `{path?, svg?: {…}}` | `file.export.svg` without params, and Save As or Save a Copy to an `.svg`/`.svgz` path without SVG options, open the `svgOptions` dialog (`ui.dialog.set` its fields: `styling`, `outlineText`, `images`, `objectIds`, `decimals`, `minify`, `responsive`, `fewerTspans`, `metadata`, `preserveEditing`, `useArtboards`, `allArtboards`, `range`, `showCode`, `profile` (`svg11`/`tiny12`), `encoding` (`utf8`/`utf16`/`latin1`), `embedFonts`; `ui.dialog.confirm` exports or saves and remembers the choices). With `svg: {…}` (the `document.formats` SVG options) they run directly. `app.save {path?, svg?}` takes the same options; a document saved as SVG saves as SVG again with the same options | | `engine.execute` `file.place` | `{paths?}` / `{path \| name+dataBase64, …file.place params}` | with no file, picks files and opens the `place` dialog; with `paths`, opens it for them (fields `files`, `link`, `template`, `replace`; `__replace` says whether Replace applies, `__info` describes each file). `ui.dialog.confirm` places one file centered in the view (or in place of the selected object) and loads the place cursor with several (`file.place.queue`). In the desktop app the dialog reads its files off the UI thread: `__info` is empty until they are read, `ui.dialog.confirm` returns `{background: true, status}`, `ui.inspect` lists the files still being read in `placing`, and the file is placed (or the cursor loaded) once it is read. With a file, it runs `file.place` centered in the view, at once. Files dropped on the window with no document open open. On the web, files dropped on the canvas are placed at the drop point (linked; Shift embeds) and files dropped off it (the tab bar) open, as in Illustrator; desktop drops carry no pointer position, so documents (native, SVG, PDF, `.ai`, EPS, DXF, EMF/WMF) open as tabs of their own and pictures and text are placed in the middle of the view. Swatch libraries and color books (`.vcswatches`, `.gpl`, `.ase`, `.acb`), graphic style libraries (`.vcstyles`), Libraries panel files (`.vclibrary`), presets (`.vcflattener`, `.vcpdfpresets`, `.vcprintpresets`, `.vcperspective`, `.vcexportpresets`), plug-ins (`.wasm`) and shortcut sets (`.kys`, in Import Shortcut Set) open wherever they are dropped, as File › Open opens them. With the cursor loaded (`ui.inspect` → `tool: "place"`, `toolOptions: {count, current, name, names}`), `ui.pointer` clicks and drags place files (in the desktop app each file is read off the UI thread and placed once it is read, and `ui.inspect` lists it in `placing` until then) and `ui.key` ←/→/↑/↓ and Escape cycle and discard them | | `app.export` raster options | `{path?, format: png \| jpg \| webp, ppi?, width?, height?, background?, antiAlias?, interlaced?, quality?}` | `ppi` sets the pixel size (72 = one pixel per point) and is stored in PNG and JPEG files (WebP files store no resolution); `width` or `height` sets the size of each file in pixels instead (`width` wins), and PNG and JPEG files store the resolution that results; `background` is `transparent`, `white`, `black` or `"#rrggbb"`; `antiAlias` is `none`, `art` or `type` (text snapped to pixels); `interlaced` writes an Adam7 PNG. The PNG Options dialog (`pngOptions`; `jpgOptions` / `webpOptions` for the other raster formats) has the same fields: fill them with `ui.dialog.set` and press OK with `ui.dialog.confirm` | @@ -1072,3 +1074,24 @@ hidden tab (whose frames the browser pauses) answers when it is shown again. | 2026-10-13 | minor | Documented count, pending-event and per-frame request budgets (#1709). | | 2026-10-13 | minor | Documented transport deadlines and connection-slot recovery (#1726). | | 2026-10-18 | minor | Documented `window.vectorcraft`, the web build's control channel. | + +## Save location errors + +A save refused by the filesystem with PermissionDenied or ReadOnlyFilesystem returns +`READ_ONLY_LOCATION: `. The reason retains the path and system message. This is a +stable prefix within the existing error string, not a new response field or JSON-RPC code. +An automation-root denial, an encoding failure, and an error retaining recovery copies do +not use this code. Choose a writable destination and explicitly save there; automation never +opens the fallback Save As panel. Failed saves keep the original document path and modified +state; multi-file saves may already have written other output files. + +`app.save` with `wait: true` returns the write failure in its reply (`save_file` already asks +for this on the desktop). Without waiting, an accepted background save still replies before +completion; any later failure opens no panel. MCP retains its usual tool-name prefix around +the error text. The interactive fallback applies only to ordinary File > Save, not Close/Quit. + +## Revision history + +| Date | Change | +|---|---| +| 2026-11-11 | Document READ_ONLY_LOCATION for saves (#6146); no response-shape change. | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d42e770df0..c83419fe64 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,5 +1,7 @@ # File format parity +> **Last updated:** 2026-11-11 · **Change:** Ordinary Save to a location refusing writes offers Save As (#6146); cancellation preserves the original path and modified state; automation receives READ_ONLY_LOCATION. Scores and estimates unchanged. Validation pending. + > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (`.ai` and EPS import: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as the file's page aligns them, #6610; `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art, #6570; `.ai` save and open: layer colour presets Yellow, Magenta and Cyan keep their colour, as the `Lb` colour index now follows the file's numbering, #6502; `.ai` save: stroked type keeps its stroke's cap, join and miter limit in the text document, #6472; `.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`, @@ -15,6 +17,8 @@ from the public specifications, and users' own `.ai` files are used locally only ## Summary +Ordinary File > Save offers Save As when its own location refuses writes with a terminal permission/read-only error (#6146). The picker keeps the filename, format and options and starts in Documents (home or host default if unavailable). Cancel leaves the document modified at its original path. Explicit Save As/Copy/export, recovery failures and Close/Quit keep their existing error dialog. Non-UI saves return `READ_ONLY_LOCATION: ` without opening a panel. Regression execution and native panel acceptance pending; format scores unchanged. + | | Illustrator | VectorCraft | Notes | |---|---:|---:|---| | Formats opened or placed | ~30 | 30 | missing: FreeHand, CorelDRAW, DWG, PICT, PS | @@ -339,6 +343,7 @@ Stop opacity on translated artboards remains outside this regression coverage. | Date | Change | Summary | |---|---|---| +| 2026-11-11 | minor | Ordinary Save to a location refusing writes offers Save As (#6146); cancellation preserves the original path and modified state; automation receives READ_ONLY_LOCATION. Scores and estimates unchanged. Validation pending. | | 2026-11-11 | minor | `.ai` and EPS import, and what SVG export, PDF export and `.ai` save draw: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as a `.ai`'s own page aligns them (see type-parity.md, same date). In 224 users' files (used locally, never committed), 63 render differently: 54 closer to the page, 7 further, 1 alike and 1 that couldn't be compared; among them the median share of pixels that differ from the page went from 4.4% to 1.0%. Of the 7: one now opens from its layers instead of its PDF part (they differ from the page by less than the import's 5% limit now; type note 3.8%); one has a centred line whose spacing inside it differs from the page's, which now centres on the page's line while half of it no longer lies on the page's glyphs (type note 1.4% to 2.4%); five moved by less than 0.05 of a percentage point, in type the page draws in other glyphs. The type note shows a smaller share in 6 files; 2 more envelopes of type open live (129 to 131, none fewer in any file). SVG import: `text-anchor` aligns a line's advance, which ends after its last character's letter spacing, so a centred or right-aligned letter-spaced line moves back by half or all of it and stays where a reader draws it; SVG export, which anchors lines so, reads back unchanged (test `import_text::anchored_letter_spaced_lines_stay_where_a_reader_draws_them`). Affinity import: centred or right-aligned tracked artistic text moves by half or all of its last character's tracking, as layout now aligns it (unverified against Affinity, whose files we don't have), and differently aligned paragraphs line up by the widths layout aligns them by (`affinity_features::artistic_text_aligns_its_lines_within_the_longest`). Scores and estimates unchanged. (#6610) | | 2026-11-11 | minor | `.ai` and EPS import: while an envelope's contents are edited, the editing data keeps its mesh (a mesh's, a top object's or a warp's grid) in its dictionary, and in some files that mesh lies away from the drawn art by a move, the contents with the drawn art, as if the mesh hadn't followed the envelope when it moved: the envelope drew its contents elsewhere and kept its drawn art. A mesh or top object saved in Edit Contents (or a warp of type tried on its saved grid) whose mesh draws the contents as large as the drawn art, within half a point over width and height together, but further from it than that, is now tried again, after every other way and with one more of the import's checks, with its mesh moved by as far as the drawn art's box lies from the box of what it draws; it opens live there where it then draws as the drawn art. In users' files, used locally and never committed, only envelopes saved in Edit Contents draw elsewhere than their drawn art, and nothing in their dictionaries says by how much they moved. Tests `ai::tests::an_envelope_in_edit_contents_whose_mesh_stayed_behind_opens_live_on_its_drawn_art`, `tests_native::a_warp_of_type_in_edit_contents_whose_grid_stayed_behind_opens_on_its_drawn_art` (#6570) | | 2026-11-11 | minor | `.ai` save (PDF-compatible editing copy and legacy) and `.ai`/EPS open: a layer's `Lb` colour index follows the file's numbering, where Magenta, Cyan and Yellow are 4, 5 and 6 and `LAYER_COLORS` has Yellow, Magenta and Cyan; Yellow, Magenta and Cyan layers no longer come out as Magenta, Cyan and Yellow, and the other 24 presets are unchanged (#6502, from the index and RGB pairs in users' files). `LAYER_COLORS` keeps its order, so `.vectorcraft` files and the Layers panel don't change. Scores and estimates unchanged. | diff --git a/docs/gaps.md b/docs/gaps.md index b722ace84c..fc003737cc 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,5 +1,7 @@ # Gaps +> **Last updated:** 2026-11-11 · **Change:** Ordinary Save to a location refusing writes offers Save As (#6146); cancellation preserves the original path and modified state; automation receives READ_ONLY_LOCATION. Scores and estimates unchanged. Validation pending. + > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (G5: built-in Energy Beams brush library; estimate 5–15 → 4–14 h; previously minor: G5: built-in Skill Icons symbol library; estimate 6–16 → 5–15 h; previously minor: ruler double-click adds a guide at the pointer, #5239; G5: built-in Status Effects graphic style library; estimate 7–17 → 6–16 h; MCP docs catch-up for UI Frames and Map Markers; previously minor: G5: built-in Inventory Icons symbol library; estimate 8–18 → 7–17 h; previously minor: G9: `.ai` save names a variable font instance's design-space setting, #6388; previously minor: G5: built-in Game Trails brush library; estimate 9–19 → 8–18 h; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: G5: built-in Game HUD graphic style library; estimate 10–20 → 9–19 h; previously minor: G5: built-in Map Markers symbol library; estimate 11–21 → 10–20 h; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview; estimate 1–2 h → 0; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -337,7 +339,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi selected with Direct Selection (#1839, #1824); Snap to Grid wins over Snap to Pixel (#1840, #1816 unconfirmed); Redo during a drag keeps the drag (#1856, #1719); a template layer dims its images by Dim Images instead of fading the whole layer (#1864, #1683); a failed save or export is reported in a dialog that names the file and stays until - closed, the document left modified (#1865, #1685). + closed, the document left modified (#1865, #1685). Ordinary File > Save now offers Save As for terminal permission/read-only refusals (#6146), unless another picker is open; recovery errors retain the dialog. Regression execution pending; G11 estimate unchanged. - **Done:** the Shape Builder offers the empty areas closed shapes enclose as regions, such as the middle of four circles that touch, and merges them with the regions around them (#1691). - **Done (#2523):** Shape Builder Gap Detection shares Live Paint's spatially indexed gap finder, @@ -854,6 +856,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | Date | Change | Summary | |---|---|---| +| 2026-11-11 | minor | Ordinary Save to a location refusing writes offers Save As (#6146); cancellation preserves the original path and modified state; automation receives READ_ONLY_LOCATION. Scores and estimates unchanged. Validation pending. | | 2026-11-11 | minor | G5: built-in Energy Beams brush library (Laser Bolt, Plasma Arc, Charge Wave, Spark Trail); Missing trimmed; estimate 5–15 → 4–14 h; MCP Brush libraries list Energy Beams. | | 2026-11-11 | minor | G5: built-in Skill Icons symbol library (Attack, Defend, Heal, Buff, Ult); Missing trimmed; estimate 6–16 → 5–15 h; MCP Symbol libraries list Skill Icons. | | 2026-11-10 | minor | G6: MCP catalogue (#6460): headless lists `UI_COMMANDS` (`ui: true`), eight prompts, effect vs command ids, 33 tools, commands/reference; estimate unchanged. | diff --git a/docs/mcp.md b/docs/mcp.md index 5826c5c8c4..d8e00fbdb1 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1,5 +1,7 @@ # VectorCraft MCP server +> **Last updated:** 2026-11-11 · **Change:** Save location-refusal error (#6146); existing response shapes retained. + `vectorcraft-cli mcp` runs a [Model Context Protocol](https://modelcontextprotocol.io) server on stdio (newline-delimited JSON-RPC 2.0; see [Protocol](#protocol) for version negotiation). Agents use it to draw, inspect and look at VectorCraft documents. @@ -491,7 +493,7 @@ opens the picker through `run_command` with the remote backend. | `open_panel` | `{panel}` | Remote only. `panel` is a panel id (`layers`, `swatches`, `colorGuide`, …, as `window.panel` takes) or its display label (`"Color Guide"`), in any case. | | `screenshot` | `{path?, scale?, artboard?, window?, max_side?, traceViews?}` | Returns MCP image content (`image/png`, base64) plus a text block. Renders the artboard with the pixels of a PNG `export` with `background: "white"` at the same scale; `traceViews:true` draws Image Trace objects in the view set with `imageTrace.setView`, as in the app's window; `window:true` captures the app window (remote only), as `ui_screenshot` does, and `max_side` (1–4096, window captures only) scales the returned image down as described for `ui_screenshot`. With `path` the backend (the headless session or the app) writes the PNG there too; a window capture written there keeps its full size with `max_side`. | | `open_file` | `{path, importer?, params?}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.vctemplate`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, `.eps`/`.epsf`, `.ps`, `.dxf`, `.emf`, `.wmf`, PNG (`.png`/`.pns`), JPEG, GIF, WebP, TIFF, BMP (`.bmp`/`.dib`/`.rle`), Targa (`.tga`/`.vda`/`.icb`/`.vst`), PCX, Pixar (`.pxr`), JPEG 2000 (`.jpf`/`.jpx`/`.jp2`/`.j2k`/`.j2c`/`.jpc`), HEIC/HEIF (`.heic`/`.heif`), JPEG XL (`.jxl`), PSD (`.psd`/`.pdd`)/PSB (an image opens as a document of its pixel size; a Photoshop file as its merged image; a JPEG XL file as its first frame). Templates (`.vctemplate`, `.ait`) open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. The reply is `document.open`'s: its `warnings` say what didn't come in as it was (an EPS whose PostScript can't be read opens as its preview, and the warning names the PostScript error, the operator and the procedure). A file of an extension only an installed importer plug-in lists opens with that importer ([Plug-ins](#plug-ins)); `importer: "plugin."` opens the file with the importer it names, which must list the file's extension, also one a built-in format reads (in the app, as File › Open With does), and `params` gives that importer's parameters (their defaults when omitted). `run_command document.formats` lists the formats. | -| `save_file` | `{path?, compress?, version?, preview?, precision?, options?}` | Runs `document.save`: the document's own file in its own format (native `.vectorcraft` unless it was opened from or saved as SVG, PDF or a restorable `.ai`; then `warnings` say what that format loses). A path's extension picks the format (`.vectorcraft`, `.vctemplate`, `.pdf`, `.svg`, `.svgz`, `.ai`: a PDF carrying the native document, which reopens editable). For a native file, `compress: true` gzips it (default: the `useCompression` preference, Preferences › File Handling › Use Compression), `version` (1 to 4) writes it for an older VectorCraft (1 and 2 are never compressed, and `version: 2` with `compress: true` is an error), `preview: true` embeds a PNG of the first artboard, and `precision` (0 to 9) rounds path anchors and handles to that many decimal places for that save only (colors, transforms and type stay exact). `options` passes the format's save options as `run_command document.formats` lists them. When `compress`, `version`, `preview` or `precision` is given both at the top level and in `options` with different values, the call is an error (`` `compress: true` and `options.compress: false` disagree: give the option once ``) and nothing is written. A key in `options` that no save format takes is an error, and nothing is written (see Parameter keys). The save drops each option the written format doesn't read. The reply's `warnings` then name each such option given (for example `` `compress` was not used: SVG files don't read it `` for an `.svg` path, and `` `options.preview` was not used: … `` for a key in `options`; a `.ai` file reads `compress` but not `version`, `preview` or `precision`). For a `.ai` file, these warnings name the kind of `.ai` written: a legacy .ai (version 7) for `aiVersion: "7"`, a non-PDF-compatible .ai for `pdfCompatible: false`, and a PDF-compatible .ai otherwise, including a save that reuses the options the document was last saved with. `run_command document.save` and the other save commands refuse any option given beside the path and in `options` with different values, and give the same warnings for a top-level `compress`, `version`, `preview` or `precision` and for each key in `options` that the format doesn't read. The headless session and the app receive the same `app.save` params, and the app is also sent `wait: true`: the call returns once the file is written, or with the error (such as a path outside the write root), even with Background Save on. When the save asks first (the question before replacing a file that opened with losses, or on macOS and Linux the file dialog for an untitled document without `path`), the reply is `{pending}` or `"cancelled"` as before, and the save that runs after the answer uses the Background Save preference. | +| `save_file` | `{path?, compress?, version?, preview?, precision?, options?}` | Runs `document.save`: the document's own file in its own format (native `.vectorcraft` unless it was opened from or saved as SVG, PDF or a restorable `.ai`; then `warnings` say what that format loses). A path's extension picks the format (`.vectorcraft`, `.vctemplate`, `.pdf`, `.svg`, `.svgz`, `.ai`: a PDF carrying the native document, which reopens editable). For a native file, `compress: true` gzips it (default: the `useCompression` preference, Preferences › File Handling › Use Compression), `version` (1 to 4) writes it for an older VectorCraft (1 and 2 are never compressed, and `version: 2` with `compress: true` is an error), `preview: true` embeds a PNG of the first artboard, and `precision` (0 to 9) rounds path anchors and handles to that many decimal places for that save only (colors, transforms and type stay exact). `options` passes the format's save options as `run_command document.formats` lists them. When `compress`, `version`, `preview` or `precision` is given both at the top level and in `options` with different values, the call is an error (`` `compress: true` and `options.compress: false` disagree: give the option once ``) and nothing is written. A key in `options` that no save format takes is an error, and nothing is written (see Parameter keys). The save drops each option the written format doesn't read. The reply's `warnings` then name each such option given (for example `` `compress` was not used: SVG files don't read it `` for an `.svg` path, and `` `options.preview` was not used: … `` for a key in `options`; a `.ai` file reads `compress` but not `version`, `preview` or `precision`). For a `.ai` file, these warnings name the kind of `.ai` written: a legacy .ai (version 7) for `aiVersion: "7"`, a non-PDF-compatible .ai for `pdfCompatible: false`, and a PDF-compatible .ai otherwise, including a save that reuses the options the document was last saved with. `run_command document.save` and the other save commands refuse any option given beside the path and in `options` with different values, and give the same warnings for a top-level `compress`, `version`, `preview` or `precision` and for each key in `options` that the format doesn't read. The headless session and the app receive the same `app.save` params, and the app is also sent `wait: true`: the call returns once the file is written, or with the error (such as a path outside the write root), even with Background Save on. When the save asks first (the question before replacing a file that opened with losses, or on macOS and Linux the file dialog for an untitled document without `path`), the reply is `{pending}` or `"cancelled"` as before, and the save that runs after the answer uses the Background Save preference. Location refusals return `READ_ONLY_LOCATION: ` without a fallback panel for automation; see [Save location errors](#save-location-errors). | | `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `svgz`, `pdf`, `ai` (the PDF-compatible `.ai` that Save writes, which carries the document when every artboard is written in order), `eps`, `dxf`, `emf`, `wmf`, `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `tiff`, `bmp`, `tga`, `psd` (layered), `txt` (the document's text), `vectorcraft` or `template` (a native template). The tool's `format` enum and `document.formats` list them, generated from the engine's format table. When `format` is omitted, it comes from the path's extension. PDF and `.ai` write one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based). SVG and SVGZ write one file per chosen artboard. EPS writes one file of the visible art; with `artboard`, `range`, `options.artboards` or `options: {"useArtboards": true}` it writes one file per chosen artboard, after a central file of the visible art when there are two or more, and `useArtboards: false` keeps the one file. Rasters, DXF, EMF and WMF write one artboard, or one file per chosen artboard with `options: {"useArtboards": true}`. Rasters also take `options: {"rect": [x, y, width, height]}` (page points, y down): that area of the page at the given `ppi`/`scale`, without an artboard and without changing the document; it is refused with `artboard`, `artboards`, `range` or `useArtboards`, with a non-positive or non-finite size, and for non-raster formats. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds (the reply adds their `bounds`, and reports `format` and the encoder's `warnings` as a whole-document export does); `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. | | `add_text` | `{text, x?, y?, width?, height?, fit?, fitMinPercent?, path?, mode?, pathEffect?, size?, font?, color?, justify?, vertical?}` | Point type at (x, y) (`justify`: `left`, `center` or `right` starts, centres or ends each line on x); area type with `width`/`height` (`fit`: `none`, `autoHeight`, `shrinkText`, as `text.areaOptions`); or `path` + `mode` (`area`/`onPath`) to flow text in or along a path, with `pathEffect` (`rainbow`, `skew`, `3dRibbon`, `stairStep`, `gravity`; SVG export writes type on a path with any effect other than `rainbow` as outlines, with a warning); `justify` (also `auto`, `justifyAll`) aligns the paragraphs of every kind, as `text.setStyle` does later. `vertical: true` uses the existing vertical writing mode for every kind (default `false`, horizontal). Returns `{id}`, with `overflow: true` when the text doesn't fit its frame or path. | | `apply_effect` | `{effect?, params?, ids?}` | Appends a live effect. Without `effect`, returns the effect catalogue with parameters and defaults. | @@ -9031,3 +9033,24 @@ Path Options' Preview does (#5360). {"name":"run_command","arguments":{"command":"addon.type.releasePath","params":{}}} {"name":"run_command","arguments":{"command":"addon.type.releasePath","params":{"mode":"path","keepOriginal":true}}} ``` + +## Save location errors + +A save refused by the filesystem with PermissionDenied or ReadOnlyFilesystem returns +`READ_ONLY_LOCATION: `. The reason retains the path and system message. This is a +stable prefix within the existing error string, not a new response field or JSON-RPC code. +An automation-root denial, an encoding failure, and an error retaining recovery copies do +not use this code. Choose a writable destination and explicitly save there; automation never +opens the fallback Save As panel. Failed saves keep the original document path and modified +state; multi-file saves may already have written other output files. + +`app.save` with `wait: true` returns the write failure in its reply (`save_file` already asks +for this on the desktop). Without waiting, an accepted background save still replies before +completion; any later failure opens no panel. MCP retains its usual tool-name prefix around +the error text. The interactive fallback applies only to ordinary File > Save, not Close/Quit. + +## Revision history + +| Date | Change | +|---|---| +| 2026-11-11 | Document READ_ONLY_LOCATION for saves (#6146); no response-shape change. | diff --git a/docs/roadmap.md b/docs/roadmap.md index 1b55740614..4f75ce5008 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,7 @@ # Roadmap: milestones and what's next +> **Last updated:** 2026-11-11 · **Change:** Ordinary Save to a location refusing writes offers Save As (#6146); cancellation preserves the original path and modified state; automation receives READ_ONLY_LOCATION. Scores and estimates unchanged. Validation pending. + > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (M10/G5: Energy Beams brush library; MCP docs list Energy Beams; previously minor: M10/G5: Skill Icons symbol library; MCP docs list Skill Icons; previously minor: M10/G5: Status Effects graphic style library; MCP docs catch-up for symbol libraries; previously minor: M10/G5: Inventory Icons built-in symbol library; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: M10/G5: Game HUD graphic style library; previously minor: resizable icon-strip flyouts and bottom-aligned panel footers, #5713; previously minor: M10/G5: Map Markers built-in symbol library; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview are in; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) > > Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369). @@ -143,7 +145,7 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l | M1 | Selection, transform, layers, MCP (common command tools, strict keys, session resources and version-aware result hints) | ✅ mostly done (document-tab artwork drag with lossless resource transfer and one target Undo; malformed MCP app replies discard the connection without replay, #1761; bounded control socket waits and connection-slot recovery, #1726; bounded synthetic control input, #1709; no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending; the Transform fields take one Tab each, the W/H link after them) | 4–6 | | M2 | Drawing tools + smart guides | ✅ mostly done (#3380 deletion creates independent selected path pieces and keeps bounds-fitted gradients in place; endpoint highlighting remains open; Polygon/Star exact-size count persistence, with repeated-drag, tool-switch and Cancel regression coverage; Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending); a shape just drawn moves by its centre point and turns from the zone outside a corner with its tool still active (#5477); Tangent and straight-segment perpendicular snapping use saved Smart Guides Geometric Guides preferences and Control bar quick access (#2537), including the existing Snap to Glyph and Alignment Guides toggles (#6164); no View submenu; Copy and Cut with the Pen or Curvature tool take the anchors a Cmd/Ctrl-lent Direct Selection picked (#6123) | 12–18 | | M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit; finite-arithmetic follow-up regressions and full CI passed ([Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area))), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform gradients in SVG/PDF, mixed spot/process gradients in SVG and PDF gradients mixing a spot color with RGB or Lab stops export as stops or process colors; PDF export writes dithered gradient fills with spot stops, and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, as images in process colors; confirm the Scale Strokes & Effects default; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls | 4–8 | -| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | PDF authored ActualText preserved once in editable import (#6165; first-glyph line/style, bounded input; estimates unchanged); PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. `.ai`/EPS type from the text document keeps its paragraphs' tab stops (#6147); `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them (#6137); ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (EPS legacy mesh corners, vector opacity masks and raster page comparison verified; closed SPLINE flags round-trip and circular paths export as native CIRCLE/ARC, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation); EPS Save and Save As keep an opened EPS as EPS with its page frame and complete editable VectorCraft document (2026-10-26; G9 import fidelity unchanged). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts | 3–6 (existing scope; Affinity export unestimated) | +| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | Save refusal continuation (#6146): ordinary Save offers Save As; cancellation/automation isolation; validation pending; PDF authored ActualText preserved once in editable import (#6165; first-glyph line/style, bounded input; estimates unchanged); PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. `.ai`/EPS type from the text document keeps its paragraphs' tab stops (#6147); `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them (#6137); ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (EPS legacy mesh corners, vector opacity masks and raster page comparison verified; closed SPLINE flags round-trip and circular paths export as native CIRCLE/ARC, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation); EPS Save and Save As keep an opened EPS as EPS with its page frame and complete editable VectorCraft document (2026-10-26; G9 import fidelity unchanged). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts | 3–6 (existing scope; Affinity export unestimated) | | M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); opaque solid-fill knockout paths draw once in hard-edge/supersampled raster export, with analytical-AA compositing unchanged; file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); Craquelure/Mosaic Tiles/Stained Glass distance searches optimized with pixel-invariance checks and the `texture_drag_perf` probe (#1257); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | | M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (live compound shapes: Make, Alt-click a Shape Mode, member modes, Release, Expand (#4522); single-result Pathfinder names and SVG IDs retained (#1293); Divide open-cutting-path geometry, appearance and history verified (#1307; existing segment cap remains); Simplify preview/counts and rewritten-path component selection verified (#1272; the slider opens at the Auto-Simplify level; a Direct Selection part of a path simplifies on its own); Compound appearance and member fill-rule edits verified (#1197); Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open); Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog | 3–6 | | M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type, and the Edit Custom Dictionary dialog (#4936; the Check Spelling dialog remains); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Hanging punctuation keeps its published amounts under Glyph Scaling, and Standard burasagari counts a hung line start as room; Fullwidth `!` / `?` keep an upright fallback in vertical type; Hyphenation… and Justification… per paragraph (#2310, #2755), Auto Leading also driving the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel (including font-defined Stylistic Sets), Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | @@ -190,6 +192,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-11-11 | minor | Ordinary Save to a location refusing writes offers Save As (#6146); cancellation preserves the original path and modified state; automation receives READ_ONLY_LOCATION. Scores and estimates unchanged. Validation pending. | | 2026-11-11 | minor | M10/G5: Energy Beams built-in brush library; MCP docs catch-up; estimates unchanged | | 2026-11-11 | minor | M10/G5: Skill Icons built-in symbol library; MCP docs catch-up; estimates unchanged | | 2026-11-10 | minor | M10/G5: Status Effects built-in graphic style library; MCP docs catch-up for UI Frames and Map Markers; estimates unchanged | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b4c949f742..46059e55f9 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,5 +1,7 @@ # Parity with Adobe Illustrator +> **Last updated:** 2026-11-11 · **Change:** Ordinary Save to a location refusing writes offers Save As (#6146); cancellation preserves the original path and modified state; automation receives READ_ONLY_LOCATION. Scores and estimates unchanged. Validation pending. + > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > > Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369). @@ -238,7 +240,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged); document-tab artwork hover/copy and lossless transfer tested (2026-10-22; score/hours unchanged); cached canvas reprojection preserves scale during resize, rotation and DPI changes (regression and contributor original-file evidence below; scores/hours unchanged); cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission (regression evidence below) | | File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), no writing of Illustrator's editing data (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified PDF marked-text batching covered by M4 evidence (#6309); cross-line/style ActualText layout (#6165) and styled-cluster limits remain.| 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`; the distance from the path under Align to Path is measured from the top of each character's em box, half the height of the top of its H or the bottom of its font's bounding box (`tests_native::aligned_type_lies_as_far_from_its_path_as_the_file_lays_it_out`), and the fit under Gravity does not reproduce the file's layout), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. PDF ActualText preserved in editable import (#6165; bounded synthetic coverage below); score/hours unchanged. | | File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects on objects other than paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports, and with live objects as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, textures, map art and shading in 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified PDF marked-text batching covered by M4 evidence (#6309); cross-line/style ActualText layout (#6165) and styled-cluster limits remain.| 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. PDF ActualText preserved in editable import (#6165; bounded synthetic coverage below); score/hours unchanged. | -| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects on objects other than paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports, and with live objects as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified PDF marked-text batching covered by M4 evidence (#6309); cross-line/style ActualText layout (#6165) and styled-cluster limits remain.| 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. PDF ActualText preserved in editable import (#6165; bounded synthetic coverage below); score/hours unchanged. | +| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects on objects other than paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports, and with live objects as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified PDF marked-text batching covered by M4 evidence (#6309); cross-line/style ActualText layout (#6165) and styled-cluster limits remain.| 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. PDF ActualText preserved in editable import (#6165; bounded synthetic coverage below); score/hours unchanged. Save refusal continuation (#6146): `READ_ONLY_LOCATION`, Save As on ordinary UI Save only; regression execution pending; score/hours unchanged. | | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged); document-tab artwork hover/copy and lossless transfer tested (2026-10-22; score/hours unchanged); cached canvas reprojection preserves scale during resize, rotation and DPI changes (regression and contributor original-file evidence below; scores/hours unchanged) | | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged) | | Guides, grids, smart guides, snapping, rulers | 3 | 80% | "intersect" smart guides for a moved selection's anchors (drawn points and dragged anchors snap to intersections, #1658), distance readouts while drawing | 4–7 | estimated (lowered from 85%: target moved); Control bar snapping quick access includes Snap to Glyph and Alignment Guides (#6164); new ruler-guide Alt orientation implemented; global/artboard ruler modes (`view.globalRulers`) and video rulers (View › Rulers › Show Video Rulers, #2849) done; status-bar X/Y uses the active ruler origin and General units; score unchanged | @@ -551,6 +553,8 @@ since 2026-10-01. ## Evidence: what ships today, by area +- **Save refusal (#6146):** ordinary File > Save to its own location offers Save As after a terminal permission/read-only refusal, preserving name, format and options. Cancel keeps the failed-save state. Busy pickers, recovery-copy errors, other failures, explicit destinations and Close/Quit keep the existing dialog. Engine, UI and MCP regression cases are in `cmd/fileio/tests_readonly.rs`, `io/tests_read_only_save.rs` and MCP `tests/fileio.rs`; execution and native panel acceptance pending. Scores and hours unchanged. + - **PDF authored replacement text (#6165):** `crates/pdf/src/tests/actual_text.rs` covers replacement once with suffix placement, PDF text-string decoding, empty and nested owners, sibling spans, page reset, multiple text-show operations, style/line changes, fill-and-stroke, reader windows and bounded input. Authored logical-order stretches are protected while adjacent ordinary text still undergoes reconstruction. The replacement takes its first placed glyph's style and line; its characters are not redistributed over later lines/styles. Exact mixed-direction paragraph layout is not claimed. No export changes; scores and hours stay at 75% / 10–20 h. @@ -1981,6 +1985,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| +| 2026-11-11 | minor | Ordinary Save to a location refusing writes offers Save As (#6146); cancellation preserves the original path and modified state; automation receives READ_ONLY_LOCATION. Scores and estimates unchanged. Validation pending. | | 2026-11-11 | minor | Bundled content: built-in Energy Beams brush library (Laser Bolt, Plasma Arc, Charge Wave, Spark Trail); brush libraries evidence updated; ~44% → ~46%; more built-in brush libraries remain. | | 2026-11-11 | minor | Bundled content: built-in Skill Icons symbol library (Attack, Defend, Heal, Buff, Ult); symbol libraries evidence updated; ~42% → ~44%; more built-in symbol libraries remain. | | 2026-11-10 | minor | Automation: MCP tool count 33; headless `command_list` lists `UI_COMMANDS` (`ui: true`); eight prompts; effect-id vs command-id clarified (#6460); scores and hours unchanged. | @@ -2367,4 +2372,4 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s 2026-10-28 Flare interaction: the center drag turns only the rays by one degree per document point of increasing pointer y and back at the same rate for decreasing y. This is an application rule independent of event frequency. Shift keeps the current turn; Space during either drag moves the flare without changing its size or turn, including the first drag sample. Releasing a modifier resumes from the current pointer position without catching up skipped rotation. Scores and remaining estimates unchanged. ## EPS Save evidence (2026-10-26) -Ordinary Save and Save As keep EPS as EPS, with the chosen page frame, transparent margins and the complete editable VectorCraft document. Existing import-loss confirmation and transparency warnings remain; the Save TIFF preview is capped at 1024 pixels, while Export retains 2048. See [file-format-parity.md](file-format-parity.md#eps-save-and-source-protection) for limits and regression evidence. This narrows G9 save-back workflow coverage; native Illustrator editing-data fidelity, parity scores and hour estimates are unchanged. \ No newline at end of file +Ordinary Save and Save As keep EPS as EPS, with the chosen page frame, transparent margins and the complete editable VectorCraft document. Existing import-loss confirmation and transparency warnings remain; the Save TIFF preview is capped at 1024 pixels, while Export retains 2048. See [file-format-parity.md](file-format-parity.md#eps-save-and-source-protection) for limits and regression evidence. This narrows G9 save-back workflow coverage; native Illustrator editing-data fidelity, parity scores and hour estimates are unchanged. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 296826671f..9b654cc363 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,5 +1,7 @@ # UI and interaction parity +> **Last updated:** 2026-11-11 · **Change:** Ordinary Save to a location refusing writes offers Save As (#6146); cancellation preserves the original path and modified state; automation receives READ_ONLY_LOCATION. Scores and estimates unchanged. Validation pending. + > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (Outline view: images and gradient meshes are picked on what Outline draws, so a click inside them reaches the path behind, #6437; previously minor: a middle-click on a document tab closes it, #6506; previously minor: Blob Brush: a round tip's preview is the outline of the area it adds, round at both ends, as the stroke it commits (#4343); scores and estimates unchanged; previously:; ruler double-click adds a guide at the pointer, #5239; G5: Inventory Icons built-in symbol library (four built-ins); previously minor: files that record no units open in Preferences › Units › General, #5820; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > > Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369). @@ -363,6 +365,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po | Interaction | Status | Evidence | |---|---|---| +| Ordinary Save refused by filesystem permissions/read-only storage offers Save As | implemented; validation pending | #6146; `io::tests_read_only_save`; same name/format/options, Documents folder, UID-bound continuation; cancellation and automation isolation | | Selection artwork drag hovers a visible document tab to activate it, then drops editable artwork on its canvas | done | `tests_document_drag`, `edit.dropSelection`, lossless resource and one-step Undo regressions; overflow and cross-window drag remain separate | | A document tab dragged along the tab strip moves its document; a line marks where it goes, the current document and every document's view stay as they were, and a drag off the strip still floats it | done | #5663, [public source](https://helpx.adobe.com/illustrator/using/customizing-workspace.html); `document.move`; `tests::tab_reorder`, `chrome::tests` (drop index with floating and view tabs), `engine` `tests::document_move` | | A middle-click on a document tab or its × closes it as the × does; closing a modified document's last view asks to save first (VectorCraft addition, as in PhotoCraft) | done | #6506; `chrome::tests::a_middle_click_on_a_tab_closes_it_as_the_close_box_does`; not scored | @@ -511,6 +514,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-11-11 | minor | Ordinary Save to a location refusing writes offers Save As (#6146); cancellation preserves the original path and modified state; automation receives READ_ONLY_LOCATION. Scores and estimates unchanged. Validation pending. | | 2026-11-11 | minor | Outline view: a click on a path's outline selects it through an image or a gradient mesh in front, which Outline draws as a frame or mesh lines (#6437). | | 2026-11-11 | minor | A middle-click on a document tab (or its ×) closes it as the × does; closing a document's last view with unsaved changes asks to save first (#6506). VectorCraft addition; scores and estimates unchanged. | | 2026-11-03 | minor | A double-click on a ruler adds a guide at the pointer (#5239): a vertical one on the top ruler, a horizontal one on the left ruler; scores and estimates unchanged. | From 9a77c2ee11750528ce55d3fc5e4129423a30fc09 Mon Sep 17 00:00:00 2001 From: Ahmed Gomaa Date: Thu, 12 Nov 2026 01:27:25 +0200 Subject: [PATCH 02/11] Save As fallback asks for a changed format's options When the read-only fallback's Save As panel returns a different format, drop the options captured for the original format and open the picked format's normal Save options; an unchanged format keeps its options. Regenerate the file.save entry in the MCP command catalogue. --- crates/mcp/src/ui_catalog.rs | 2 +- crates/ui-egui/src/io/save.rs | 8 +++++++- crates/ui-egui/src/io/tests_read_only_save.rs | 15 +++++++++++++++ docs/file-format-parity.md | 5 +++-- docs/roadmap.md | 5 +++-- docs/target-app-parity.md | 5 +++-- docs/ui-parity.md | 5 +++-- 7 files changed, 35 insertions(+), 10 deletions(-) diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index e15244580d..2fc406440f 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -39,7 +39,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "file.save", "Save", "Cmd+S", - "{path?, format?, options?, svg?: {…SVG options}} = document.save, written through the app (a document saved as SVG, PDF or .ai, or opened from one Save can write back, saves as that again, with the same options); with no path known (never saved, converted) the Save As panel asks first", + "{path?, format?, options?, svg?: {…SVG options}} = document.save, written through the app (a document saved as SVG, PDF or .ai, or opened from one Save can write back, saves as that again, with the same options); with no path known (never saved, converted) the Save As panel asks first; ordinary Save to the document's existing file refused by filesystem permissions or a read-only volume opens Save As in Documents with the same name, format and options; cancelling keeps the original path and modified state. Explicit destinations and Close/Quit keep their existing error dialog. Automation gets READ_ONLY_LOCATION: followed by the reason and no panel", ), ( "file.place", diff --git a/crates/ui-egui/src/io/save.rs b/crates/ui-egui/src/io/save.rs index 5efe776d64..03a03b4c3a 100644 --- a/crates/ui-egui/src/io/save.rs +++ b/crates/ui-egui/src/io/save.rs @@ -315,17 +315,23 @@ pub(super) fn save_suggesting( filters, }; let picked = pick_finished(app, &pick, |picked, file_type| with_picked_type(picked, &pick.name, file_type, first.format, mode))?; + // Only the read-only retry carries options for the original format through Save As. + // A different format must use its own normal options flow instead. + let changed_retry_format = suggestion.is_some() && fileio::format_for_name(&picked).is_some_and(|f| f.id != first.format.id); // The picked file type wins over a `format` param. let mut q = if p.is_object() { p.clone() } else { json!({}) }; if let Some(o) = q.as_object_mut() { o.remove("format"); + if changed_retry_format { + o.remove("options"); + } o.insert("path".into(), json!(picked)); } let chosen = plan(app, mode, &acknowledged(&q))?; if let Some(r) = chosen.path.as_deref().and_then(|path| ask_before_losing(app, path, save_command(mode), &q)) { return Ok(r); } - if ask && asks_options(mode, chosen.format) { + if (ask || changed_retry_format) && asks_options(mode, chosen.format) { let path = chosen.path.clone().unwrap_or_default(); let r = ask_format_options(app, mode, chosen.format, &path); crate::dialogs::opened_by_panel(app); diff --git a/crates/ui-egui/src/io/tests_read_only_save.rs b/crates/ui-egui/src/io/tests_read_only_save.rs index 8a01003a90..6568a7e281 100644 --- a/crates/ui-egui/src/io/tests_read_only_save.rs +++ b/crates/ui-egui/src/io/tests_read_only_save.rs @@ -264,3 +264,18 @@ fn readonly_busy_picker_and_save_changes_keep_the_existing_error_flow() { assert_eq!(app.session.documents().len(), 1); assert!(app.session.doc().unwrap().is_dirty()); } + +#[test] +fn readonly_changed_format_asks_for_its_options_before_writing() { + let (mut app, files) = app("svg", ErrorKind::PermissionDenied); + let before = state(&app); + let (shown, _) = off_the_ui_thread(&mut app.services); + assert_eq!(app.run("file.save", json!({})).unwrap()["pending"], "fileDialog"); + shown.answer(&["/elsewhere/Poster.pdf"]); + poll(&mut app); + let dialog = app.ui.dialog.as_ref().expect("the picked format's Save dialog"); + assert_eq!(dialog.kind, "savePdf"); + assert_eq!(dialog.str("path"), "/elsewhere/Poster.pdf"); + assert_eq!(state(&app), before); + assert_eq!(files.lock().unwrap().attempts, ["/locked/Poster.svg"]); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index c83419fe64..f8261eef13 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last updated:** 2026-11-11 · **Change:** Ordinary Save to a location refusing writes offers Save As (#6146); cancellation preserves the original path and modified state; automation receives READ_ONLY_LOCATION. Scores and estimates unchanged. Validation pending. +> **Last updated:** 2026-11-12 · **Change:** Save refusal follow-up (#6146): changing format in fallback Save As opens that format’s options; unchanged format keeps the original options. Focused UI regressions passed; native panel acceptance pending; scores and estimates unchanged. > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (`.ai` and EPS import: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as the file's page aligns them, #6610; `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art, #6570; `.ai` save and open: layer colour presets Yellow, Magenta and Cyan keep their colour, as the `Lb` colour index now follows the file's numbering, #6502; `.ai` save: stroked type keeps its stroke's cap, join and miter limit in the text document, #6472; `.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) @@ -17,7 +17,7 @@ from the public specifications, and users' own `.ai` files are used locally only ## Summary -Ordinary File > Save offers Save As when its own location refuses writes with a terminal permission/read-only error (#6146). The picker keeps the filename, format and options and starts in Documents (home or host default if unavailable). Cancel leaves the document modified at its original path. Explicit Save As/Copy/export, recovery failures and Close/Quit keep their existing error dialog. Non-UI saves return `READ_ONLY_LOCATION: ` without opening a panel. Regression execution and native panel acceptance pending; format scores unchanged. +Ordinary File > Save offers Save As when its own location refuses writes with a terminal permission/read-only error (#6146). The picker suggests the original filename and format, keeps the original options when the format is unchanged, and opens the chosen format’s Save options when it changes. It starts in Documents (home or host default if unavailable). Cancel leaves the document modified at its original path. Explicit Save As/Copy/export, recovery failures and Close/Quit keep their existing error dialog. Non-UI saves return `READ_ONLY_LOCATION: ` without opening a panel. Focused UI regressions passed; engine regression execution and native panel acceptance pending; format scores unchanged. | | Illustrator | VectorCraft | Notes | |---|---:|---:|---| @@ -343,6 +343,7 @@ Stop opacity on translated artboards remains outside this regression coverage. | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | Save refusal follow-up (#6146): changed format opens its Save options; unchanged format retains original options. Focused UI regressions passed, including the format-change case that failed before the fix; native panel acceptance pending. Scores and estimates unchanged. | | 2026-11-11 | minor | Ordinary Save to a location refusing writes offers Save As (#6146); cancellation preserves the original path and modified state; automation receives READ_ONLY_LOCATION. Scores and estimates unchanged. Validation pending. | | 2026-11-11 | minor | `.ai` and EPS import, and what SVG export, PDF export and `.ai` save draw: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as a `.ai`'s own page aligns them (see type-parity.md, same date). In 224 users' files (used locally, never committed), 63 render differently: 54 closer to the page, 7 further, 1 alike and 1 that couldn't be compared; among them the median share of pixels that differ from the page went from 4.4% to 1.0%. Of the 7: one now opens from its layers instead of its PDF part (they differ from the page by less than the import's 5% limit now; type note 3.8%); one has a centred line whose spacing inside it differs from the page's, which now centres on the page's line while half of it no longer lies on the page's glyphs (type note 1.4% to 2.4%); five moved by less than 0.05 of a percentage point, in type the page draws in other glyphs. The type note shows a smaller share in 6 files; 2 more envelopes of type open live (129 to 131, none fewer in any file). SVG import: `text-anchor` aligns a line's advance, which ends after its last character's letter spacing, so a centred or right-aligned letter-spaced line moves back by half or all of it and stays where a reader draws it; SVG export, which anchors lines so, reads back unchanged (test `import_text::anchored_letter_spaced_lines_stay_where_a_reader_draws_them`). Affinity import: centred or right-aligned tracked artistic text moves by half or all of its last character's tracking, as layout now aligns it (unverified against Affinity, whose files we don't have), and differently aligned paragraphs line up by the widths layout aligns them by (`affinity_features::artistic_text_aligns_its_lines_within_the_longest`). Scores and estimates unchanged. (#6610) | | 2026-11-11 | minor | `.ai` and EPS import: while an envelope's contents are edited, the editing data keeps its mesh (a mesh's, a top object's or a warp's grid) in its dictionary, and in some files that mesh lies away from the drawn art by a move, the contents with the drawn art, as if the mesh hadn't followed the envelope when it moved: the envelope drew its contents elsewhere and kept its drawn art. A mesh or top object saved in Edit Contents (or a warp of type tried on its saved grid) whose mesh draws the contents as large as the drawn art, within half a point over width and height together, but further from it than that, is now tried again, after every other way and with one more of the import's checks, with its mesh moved by as far as the drawn art's box lies from the box of what it draws; it opens live there where it then draws as the drawn art. In users' files, used locally and never committed, only envelopes saved in Edit Contents draw elsewhere than their drawn art, and nothing in their dictionaries says by how much they moved. Tests `ai::tests::an_envelope_in_edit_contents_whose_mesh_stayed_behind_opens_live_on_its_drawn_art`, `tests_native::a_warp_of_type_in_edit_contents_whose_grid_stayed_behind_opens_on_its_drawn_art` (#6570) | diff --git a/docs/roadmap.md b/docs/roadmap.md index 4f75ce5008..67a5a17a1a 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap: milestones and what's next -> **Last updated:** 2026-11-11 · **Change:** Ordinary Save to a location refusing writes offers Save As (#6146); cancellation preserves the original path and modified state; automation receives READ_ONLY_LOCATION. Scores and estimates unchanged. Validation pending. +> **Last updated:** 2026-11-12 · **Change:** Save refusal follow-up (#6146): changing format in fallback Save As opens that format’s options; unchanged format keeps the original options. Focused UI regressions passed; native panel acceptance pending; scores and estimates unchanged. > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (M10/G5: Energy Beams brush library; MCP docs list Energy Beams; previously minor: M10/G5: Skill Icons symbol library; MCP docs list Skill Icons; previously minor: M10/G5: Status Effects graphic style library; MCP docs catch-up for symbol libraries; previously minor: M10/G5: Inventory Icons built-in symbol library; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: M10/G5: Game HUD graphic style library; previously minor: resizable icon-strip flyouts and bottom-aligned panel footers, #5713; previously minor: M10/G5: Map Markers built-in symbol library; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview are in; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) > @@ -145,7 +145,7 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l | M1 | Selection, transform, layers, MCP (common command tools, strict keys, session resources and version-aware result hints) | ✅ mostly done (document-tab artwork drag with lossless resource transfer and one target Undo; malformed MCP app replies discard the connection without replay, #1761; bounded control socket waits and connection-slot recovery, #1726; bounded synthetic control input, #1709; no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending; the Transform fields take one Tab each, the W/H link after them) | 4–6 | | M2 | Drawing tools + smart guides | ✅ mostly done (#3380 deletion creates independent selected path pieces and keeps bounds-fitted gradients in place; endpoint highlighting remains open; Polygon/Star exact-size count persistence, with repeated-drag, tool-switch and Cancel regression coverage; Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending); a shape just drawn moves by its centre point and turns from the zone outside a corner with its tool still active (#5477); Tangent and straight-segment perpendicular snapping use saved Smart Guides Geometric Guides preferences and Control bar quick access (#2537), including the existing Snap to Glyph and Alignment Guides toggles (#6164); no View submenu; Copy and Cut with the Pen or Curvature tool take the anchors a Cmd/Ctrl-lent Direct Selection picked (#6123) | 12–18 | | M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit; finite-arithmetic follow-up regressions and full CI passed ([Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area))), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform gradients in SVG/PDF, mixed spot/process gradients in SVG and PDF gradients mixing a spot color with RGB or Lab stops export as stops or process colors; PDF export writes dithered gradient fills with spot stops, and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, as images in process colors; confirm the Scale Strokes & Effects default; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls | 4–8 | -| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | Save refusal continuation (#6146): ordinary Save offers Save As; cancellation/automation isolation; validation pending; PDF authored ActualText preserved once in editable import (#6165; first-glyph line/style, bounded input; estimates unchanged); PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. `.ai`/EPS type from the text document keeps its paragraphs' tab stops (#6147); `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them (#6137); ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (EPS legacy mesh corners, vector opacity masks and raster page comparison verified; closed SPLINE flags round-trip and circular paths export as native CIRCLE/ARC, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation); EPS Save and Save As keep an opened EPS as EPS with its page frame and complete editable VectorCraft document (2026-10-26; G9 import fidelity unchanged). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts | 3–6 (existing scope; Affinity export unestimated) | +| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | Save refusal continuation (#6146): ordinary Save offers Save As; unchanged format keeps options, changed format opens its Save options; cancellation/automation isolation; focused UI regressions passed, native acceptance pending; PDF authored ActualText preserved once in editable import (#6165; first-glyph line/style, bounded input; estimates unchanged); PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. `.ai`/EPS type from the text document keeps its paragraphs' tab stops (#6147); `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them (#6137); ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (EPS legacy mesh corners, vector opacity masks and raster page comparison verified; closed SPLINE flags round-trip and circular paths export as native CIRCLE/ARC, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation); EPS Save and Save As keep an opened EPS as EPS with its page frame and complete editable VectorCraft document (2026-10-26; G9 import fidelity unchanged). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts | 3–6 (existing scope; Affinity export unestimated) | | M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); opaque solid-fill knockout paths draw once in hard-edge/supersampled raster export, with analytical-AA compositing unchanged; file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); Craquelure/Mosaic Tiles/Stained Glass distance searches optimized with pixel-invariance checks and the `texture_drag_perf` probe (#1257); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | | M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (live compound shapes: Make, Alt-click a Shape Mode, member modes, Release, Expand (#4522); single-result Pathfinder names and SVG IDs retained (#1293); Divide open-cutting-path geometry, appearance and history verified (#1307; existing segment cap remains); Simplify preview/counts and rewritten-path component selection verified (#1272; the slider opens at the Auto-Simplify level; a Direct Selection part of a path simplifies on its own); Compound appearance and member fill-rule edits verified (#1197); Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open); Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog | 3–6 | | M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type, and the Edit Custom Dictionary dialog (#4936; the Check Spelling dialog remains); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Hanging punctuation keeps its published amounts under Glyph Scaling, and Standard burasagari counts a hung line start as room; Fullwidth `!` / `?` keep an upright fallback in vertical type; Hyphenation… and Justification… per paragraph (#2310, #2755), Auto Leading also driving the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel (including font-defined Stylistic Sets), Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | @@ -192,6 +192,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | Save refusal follow-up (#6146): changed format opens its Save options; unchanged format retains original options. Focused UI regressions passed, including the format-change case that failed before the fix; native panel acceptance pending. Scores and estimates unchanged. | | 2026-11-11 | minor | Ordinary Save to a location refusing writes offers Save As (#6146); cancellation preserves the original path and modified state; automation receives READ_ONLY_LOCATION. Scores and estimates unchanged. Validation pending. | | 2026-11-11 | minor | M10/G5: Energy Beams built-in brush library; MCP docs catch-up; estimates unchanged | | 2026-11-11 | minor | M10/G5: Skill Icons built-in symbol library; MCP docs catch-up; estimates unchanged | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 46059e55f9..fb42bb6a8c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last updated:** 2026-11-11 · **Change:** Ordinary Save to a location refusing writes offers Save As (#6146); cancellation preserves the original path and modified state; automation receives READ_ONLY_LOCATION. Scores and estimates unchanged. Validation pending. +> **Last updated:** 2026-11-12 · **Change:** Save refusal follow-up (#6146): changing format in fallback Save As opens that format’s options; unchanged format keeps the original options. Focused UI regressions passed; native panel acceptance pending; scores and estimates unchanged. > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > @@ -553,7 +553,7 @@ since 2026-10-01. ## Evidence: what ships today, by area -- **Save refusal (#6146):** ordinary File > Save to its own location offers Save As after a terminal permission/read-only refusal, preserving name, format and options. Cancel keeps the failed-save state. Busy pickers, recovery-copy errors, other failures, explicit destinations and Close/Quit keep the existing dialog. Engine, UI and MCP regression cases are in `cmd/fileio/tests_readonly.rs`, `io/tests_read_only_save.rs` and MCP `tests/fileio.rs`; execution and native panel acceptance pending. Scores and hours unchanged. +- **Save refusal (#6146):** ordinary File > Save to its own location offers Save As after a terminal permission/read-only refusal, suggesting the original name and format, preserving options for the unchanged format and opening the chosen format’s Save options on a change. Cancel keeps the failed-save state. Busy pickers, recovery-copy errors, other failures, explicit destinations and Close/Quit keep the existing dialog. Engine, UI and MCP regression cases are in `cmd/fileio/tests_readonly.rs`, `io/tests_read_only_save.rs` and MCP `tests/fileio.rs`; focused UI regressions passed; engine regression execution and native panel acceptance pending. Scores and hours unchanged. - **PDF authored replacement text (#6165):** `crates/pdf/src/tests/actual_text.rs` covers replacement once with suffix placement, PDF text-string decoding, empty and nested owners, sibling spans, page reset, multiple text-show operations, style/line changes, fill-and-stroke, reader windows and bounded input. Authored logical-order stretches are protected while adjacent ordinary text still undergoes reconstruction. The replacement takes its first placed glyph's style and line; its characters are not redistributed over later lines/styles. Exact mixed-direction paragraph layout is not claimed. No export changes; scores and hours stay at 75% / 10–20 h. @@ -1985,6 +1985,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | Save refusal follow-up (#6146): changed format opens its Save options; unchanged format retains original options. Focused UI regressions passed, including the format-change case that failed before the fix; native panel acceptance pending. Scores and estimates unchanged. | | 2026-11-11 | minor | Ordinary Save to a location refusing writes offers Save As (#6146); cancellation preserves the original path and modified state; automation receives READ_ONLY_LOCATION. Scores and estimates unchanged. Validation pending. | | 2026-11-11 | minor | Bundled content: built-in Energy Beams brush library (Laser Bolt, Plasma Arc, Charge Wave, Spark Trail); brush libraries evidence updated; ~44% → ~46%; more built-in brush libraries remain. | | 2026-11-11 | minor | Bundled content: built-in Skill Icons symbol library (Attack, Defend, Heal, Buff, Ult); symbol libraries evidence updated; ~42% → ~44%; more built-in symbol libraries remain. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 9b654cc363..dc580549cb 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last updated:** 2026-11-11 · **Change:** Ordinary Save to a location refusing writes offers Save As (#6146); cancellation preserves the original path and modified state; automation receives READ_ONLY_LOCATION. Scores and estimates unchanged. Validation pending. +> **Last updated:** 2026-11-12 · **Change:** Save refusal follow-up (#6146): changing format in fallback Save As opens that format’s options; unchanged format keeps the original options. Focused UI regressions passed; native panel acceptance pending; scores and estimates unchanged. > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (Outline view: images and gradient meshes are picked on what Outline draws, so a click inside them reaches the path behind, #6437; previously minor: a middle-click on a document tab closes it, #6506; previously minor: Blob Brush: a round tip's preview is the outline of the area it adds, round at both ends, as the stroke it commits (#4343); scores and estimates unchanged; previously:; ruler double-click adds a guide at the pointer, #5239; G5: Inventory Icons built-in symbol library (four built-ins); previously minor: files that record no units open in Preferences › Units › General, #5820; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > @@ -365,7 +365,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po | Interaction | Status | Evidence | |---|---|---| -| Ordinary Save refused by filesystem permissions/read-only storage offers Save As | implemented; validation pending | #6146; `io::tests_read_only_save`; same name/format/options, Documents folder, UID-bound continuation; cancellation and automation isolation | +| Ordinary Save refused by filesystem permissions/read-only storage offers Save As | focused UI regressions passed; native acceptance pending | #6146; `io::tests_read_only_save`; same name/format suggested; unchanged format keeps options, changed format opens its Save options; Documents folder, UID-bound continuation; cancellation and automation isolation | | Selection artwork drag hovers a visible document tab to activate it, then drops editable artwork on its canvas | done | `tests_document_drag`, `edit.dropSelection`, lossless resource and one-step Undo regressions; overflow and cross-window drag remain separate | | A document tab dragged along the tab strip moves its document; a line marks where it goes, the current document and every document's view stay as they were, and a drag off the strip still floats it | done | #5663, [public source](https://helpx.adobe.com/illustrator/using/customizing-workspace.html); `document.move`; `tests::tab_reorder`, `chrome::tests` (drop index with floating and view tabs), `engine` `tests::document_move` | | A middle-click on a document tab or its × closes it as the × does; closing a modified document's last view asks to save first (VectorCraft addition, as in PhotoCraft) | done | #6506; `chrome::tests::a_middle_click_on_a_tab_closes_it_as_the_close_box_does`; not scored | @@ -514,6 +514,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | Save refusal follow-up (#6146): changed format opens its Save options; unchanged format retains original options. Focused UI regressions passed, including the format-change case that failed before the fix; native panel acceptance pending. Scores and estimates unchanged. | | 2026-11-11 | minor | Ordinary Save to a location refusing writes offers Save As (#6146); cancellation preserves the original path and modified state; automation receives READ_ONLY_LOCATION. Scores and estimates unchanged. Validation pending. | | 2026-11-11 | minor | Outline view: a click on a path's outline selects it through an image or a gradient mesh in front, which Outline draws as a frame or mesh lines (#6437). | | 2026-11-11 | minor | A middle-click on a document tab (or its ×) closes it as the × does; closing a document's last view with unsaved changes asks to save first (#6506). VectorCraft addition; scores and estimates unchanged. | From 9c5a2fa2b87309916d4331afdd3f1456cb67cbcf Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 00:37:18 -0800 Subject: [PATCH 03/11] 3D: a solid in a transparent gradient keeps the shared face order With Extrude & Bevel, Extrude & Bevel (Classic) or Inflate on a group or an object with fills and strokes, a solid whose gradient has transparent stops carries an opacity mask, and it was drawn as one whole surface in the order of the members. It could cover a solid in front of it or hide behind one it is in front of. Its faces now take their places in the one back-to-front order of every solid's faces, in runs that each carry the solid's opacity mask. A run splits only where a face drawn after it overlaps the next face. --- crates/effects/src/revolve.rs | 12 ++-- crates/effects/src/solids.rs | 88 ++++++++++++++++++++++++++---- crates/effects/src/solids/tests.rs | 80 ++++++++++++++++++++++++--- 3 files changed, 155 insertions(+), 25 deletions(-) diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index 4795e8848a..9b85185e2f 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -20,7 +20,7 @@ const MAX_PAINT_COPIES: usize = 256 << 20; /// The most face and box tests a group's transparent members' faces take together to rejoin their /// members' last runs; past them each starts a run of its own. -const MAX_OVERLAP_TESTS: usize = 1 << 22; +pub(crate) const MAX_OVERLAP_TESTS: usize = 1 << 22; /// The width of the stroke around each face that covers the seams between faces. const SEAM_WIDTH: f64 = 0.75; @@ -576,14 +576,14 @@ pub(crate) fn scoped<'a>(id: NodeId, surfaces: Vec<(&'a [Scope], Arc)>, le /// What a face can paint, to tell whether two faces overlap: the convex hull of the points and /// control points of its outline, which holds the outline, and the hull's box grown by half the /// seam stroke. -struct Coverage { - bounds: Rect, +pub(crate) struct Coverage { + pub(crate) bounds: Rect, hull: Vec, } impl Coverage { /// `None` for a face with no outline and no box. - fn of(face: &Node) -> Option { + pub(crate) fn of(face: &Node) -> Option { let points: Vec = match face.path_data() { Some(path) => path .to_bezpath() @@ -611,7 +611,7 @@ impl Coverage { /// Do the two faces' hulls, each grown by half the seam stroke, overlap? A test of the /// separating axes of their edges and of the page's axes: `true` whenever they overlap, and /// sometimes near a corner when they don't. - fn meets(&self, other: &Coverage) -> bool { + pub(crate) fn meets(&self, other: &Coverage) -> bool { fn normals(hull: &[Point]) -> impl Iterator + '_ { (0..hull.len()).filter_map(|i| { let (a, b) = (hull.get(i)?, hull.get((i + 1) % hull.len())?); @@ -658,7 +658,7 @@ fn convex_hull(mut points: Vec) -> Vec { } /// Do boxes `a` and `b` overlap with some area? -fn overlaps(a: Rect, b: Rect) -> bool { +pub(crate) fn overlaps(a: Rect, b: Rect) -> bool { a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1 } diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index ff5b1c9711..4744202dd0 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -686,10 +686,11 @@ type ShadowAndSurfaces = (Vec>, Vec>); /// Rotate turns every fill and stroke, each in its own paint. Extrude & Bevel and Inflate build a /// solid from the path for the top visible fill and one from the outline of each visible stroke, /// each in its own paint. When every one is opaque with normal blending and no colored light makes -/// a surface one composite or masks it, their faces are drawn in one back-to-front order (where -/// depths tie, as for the caps turned toward the viewer, in the order of the appearance, and with -/// caps, a shape's fill walls before the faces of its covering strokes that overlap them: -/// [`draw_order`]); +/// a surface one composite, their faces are drawn in one back-to-front order (where depths tie, as +/// for the caps turned toward the viewer, in the order of the appearance, and with caps, a shape's +/// fill walls before the faces of its covering strokes that overlap them: [`draw_order`]), the +/// faces of a solid in a gradient with transparent stops in runs that carry its opacity mask +/// ([`in_runs`]); /// otherwise each solid is a surface of its own, in that order. A member's opacity mask and /// blending mode, and those of a group it is in, apply to the surfaces of the members it holds /// ([`masked`]). The art comes in two parts: the effect's shadow ([`cast_shadow`], none with it @@ -841,19 +842,20 @@ fn surfaces_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option>> = surfaces.iter().map(|(_, s, _)| s.extras().mask.clone()).collect(); let (mut placed, mut faces) = (vec![], vec![]); - for (depths, s, _) in surfaces { + for (surface, (depths, s, _)) in surfaces.into_iter().enumerate() { if let NodeKind::Group { children, .. } = s.kind { placed.extend(depths); - faces.extend(children.into_iter().map(Some)); + faces.extend(children.into_iter().map(|face| Some((surface, face)))); } } let sealed = match fx.id.as_str() { @@ -862,7 +864,69 @@ fn surfaces_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option extrude_options(&p).cap, }; let order = draw_order(&placed, sealed); - Some(vec![Arc::new(Node::group(n.id, order.into_iter().filter_map(|k| faces.get_mut(k).and_then(Option::take)).collect()))]) + let faces: Vec<(usize, Arc)> = order.into_iter().filter_map(|k| faces.get_mut(k).and_then(Option::take)).collect(); + let children = if masks.iter().all(Option::is_none) { faces.into_iter().map(|(_, face)| face).collect() } else { in_runs(n.id, faces, &masks) }; + Some(vec![Arc::new(Node::group(n.id, children))]) +} + +/// `faces` in their back-to-front order, each with the index of its surface, where some surfaces +/// carry an opacity mask (`masks`, by surface). A face of a surface without one goes in the order +/// on its own. The faces of a masked surface share a run, one group with the surface's mask. Such a +/// face joins its surface's last run unless a face drawn after that run overlaps it: only then does +/// the surface split, where one group can't be both behind and in front of another face. +fn in_runs(id: NodeId, faces: Vec<(usize, Arc)>, masks: &[Option>]) -> Vec> { + use crate::revolve::{Coverage, overlaps}; + struct Run { + surface: Option, + bounds: Rect, + faces: Vec>, + cover: Vec, + } + let mut runs: Vec = vec![]; + let mut last_run = vec![None; masks.len()]; + // Past this many tests a face starts a run of its own, which never draws out of order. + let mut budget = crate::revolve::MAX_OVERLAP_TESTS; + for (surface, face) in faces { + let Some(cover) = Coverage::of(&face) else { + // A face with no outline and no box overlaps nothing. + runs.push(Run { surface: None, bounds: Rect::ZERO, faces: vec![face], cover: vec![] }); + continue; + }; + if masks.get(surface).is_none_or(Option::is_none) { + runs.push(Run { surface: None, bounds: cover.bounds, faces: vec![face], cover: vec![cover] }); + continue; + } + let mut clear = |r: &Run| { + budget = budget.saturating_sub(1); + if !overlaps(r.bounds, cover.bounds) { + return budget > 0; + } + budget = budget.saturating_sub(r.cover.len()); + budget > 0 && r.cover.iter().all(|c| !overlaps(c.bounds, cover.bounds) || !c.meets(&cover)) + }; + let joins = last_run.get(surface).copied().flatten().filter(|&i: &usize| runs.get(i + 1..).is_some_and(|later| later.iter().all(&mut clear))); + match joins.and_then(|i| runs.get_mut(i)) { + Some(run) => { + run.bounds = run.bounds.union(cover.bounds); + run.faces.push(face); + run.cover.push(cover); + } + None => { + if let Some(last) = last_run.get_mut(surface) { + *last = Some(runs.len()); + } + runs.push(Run { surface: Some(surface), bounds: cover.bounds, faces: vec![face], cover: vec![cover] }); + } + } + } + runs.into_iter() + .filter_map(|mut r| { + let Some(mask) = r.surface.and_then(|s| masks.get(s)).cloned().flatten() else { return r.faces.pop() }; + let mut group = Node::group(id, r.faces); + group.extras_mut().mask = Some(mask); + Some(Arc::new(group)) + }) + .collect() } fn geometry(path: &PathData, fx: &[Effect], ctx: &GeomContext) -> PathData { diff --git a/crates/effects/src/solids/tests.rs b/crates/effects/src/solids/tests.rs index 248473f428..a79f209381 100644 --- a/crates/effects/src/solids/tests.rs +++ b/crates/effects/src/solids/tests.rs @@ -415,15 +415,16 @@ fn a_translucent_stroke_is_a_surface_of_its_own() { } /// With a light that is not white, a fill in a gradient with transparent stops is drawn with an -/// opacity mask over its whole surface: the fill's solid and the stroke's stay surfaces of their -/// own, and the mask stays on the fill's. Under a white light the same object is one surface. +/// opacity mask: its faces still take their places in the one order of the fill's and the +/// stroke's faces, in runs that each carry the mask. Under a white light the faces carry the +/// gradient itself and no mask. #[test] -fn a_masked_surface_under_a_colored_light_stays_whole() { +fn a_masked_surface_under_a_colored_light_keeps_the_shared_face_order() { use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop}; let mut half = GradientStop::new(1.0, RED); half.opacity = 0.5; let gradient = GradientPaint::new(Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, RED), half])); - for (lights, surfaces) in [(json!({"lights": [{"color": "#ff8080"}]}), 2), (json!({}), 1)] { + for (lights, masked) in [(json!({"lights": [{"color": "#ff8080"}]}), true), (json!({}), false)] { let mut n = stroked_rect(100.0); if let Some(AppearanceItem::Fill(f)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Fill(_))) { f.paint = Paint::Gradient(Box::new(gradient.clone())); @@ -431,8 +432,10 @@ fn a_masked_surface_under_a_colored_light_stays_whole() { n.appearance.effects.push(fx(EXTRUDE, lights.clone())); let art = solid_art(&n).unwrap(); let drawn = art.children().unwrap(); - assert_eq!(drawn.len(), surfaces, "{lights}"); - assert_eq!(drawn[0].extras().mask.is_some(), surfaces == 2, "{lights}"); + assert_eq!(drawn.len(), 1, "{lights}: one order of faces"); + let runs: Vec<&Arc> = drawn[0].children().unwrap().iter().filter(|c| c.extras().mask.is_some()).collect(); + assert_eq!(!runs.is_empty(), masked, "{lights}"); + assert!(runs.iter().all(|r| r.children().unwrap().iter().all(|f| !blue(f))), "{lights}: the mask stays on the fill's faces"); } } @@ -847,7 +850,9 @@ fn classic_shading_keeps_a_gradients_transparency() { } let art = solid_art(&n).unwrap(); let surface = &art.children().unwrap()[0]; - assert_eq!(surface.extras().mask.is_some(), masked, "{end}"); + let mut has_mask = false; + surface.walk(&mut |c| has_mask |= c.extras().mask.is_some()); + assert_eq!(has_mask, masked, "{end}"); let mut opaque = true; surface.walk(&mut |c| { for item in &c.appearance.items { @@ -862,6 +867,67 @@ fn classic_shading_keeps_a_gradients_transparency() { } } +/// A group member in a gradient with transparent stops keeps the shared back-to-front order of +/// every member's faces: its faces go in runs that carry its opacity mask, and the other member's +/// faces are drawn between them where they are in front of some and behind others. Here a square +/// frame in the gradient holds a square in its hole: the frame's inner walls are behind the square +/// and its front cap is in front of the square's walls. +#[test] +fn a_member_in_a_transparent_gradient_keeps_the_shared_face_order() { + use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop}; + let square = |b: &mut BezPath, x0: f64, x1: f64, clockwise: bool| { + let corners = [(x0, x0), (x1, x0), (x1, x1), (x0, x1)]; + let mut points = corners.to_vec(); + if !clockwise { + points.reverse(); + } + b.move_to(points[0]); + for p in &points[1..] { + b.line_to(*p); + } + b.close_path(); + }; + let art = |id: &str, params: &Value, end_opacity: f32| { + let mut end = GradientStop::new(1.0, Color::BLACK); + end.opacity = end_opacity; + let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, RED), end])))); + let mut b = BezPath::new(); + square(&mut b, 100.0, 300.0, true); + square(&mut b, 150.0, 250.0, false); + let frame = Node::path(NodeId(1), PathData::from_bezpath(&b), Appearance::basic(gradient, Paint::None, 0.0)); + let mut b = BezPath::new(); + square(&mut b, 185.0, 215.0, true); + let inner = Node::path(NodeId(2), PathData::from_bezpath(&b), Appearance::basic(Paint::solid(BLUE), Paint::None, 0.0)); + let mut g = Node::group(NodeId(3), vec![Arc::new(frame), Arc::new(inner)]); + g.appearance.effects.push(fx(id, params.clone())); + solid_art(&g).unwrap_or_else(|| panic!("{id}")) + }; + // The drawing order: `M` for a group with an opacity mask, `F` for a face outside one. + fn visit(n: &Node, order: &mut String) { + if n.extras().mask.is_some() { + order.push('M'); + return; + } + if n.path_data().is_some() { + order.push('F'); + } + for c in n.children().into_iter().flatten() { + visit(c, order); + } + } + let order = |art: &Node| { + let mut order = String::new(); + visit(art, &mut order); + order + }; + for (id, params) in [(CLASSIC_EXTRUDE, json!({})), (EXTRUDE, json!({"lights": [{"color": "#ff8080"}]}))] { + assert!(!order(&art(id, ¶ms, 1.0)).contains('M'), "{id}: an opaque gradient needs no mask"); + let masked = order(&art(id, ¶ms, 0.5)); + let (first, last) = (masked.find('M').unwrap(), masked.rfind('M').unwrap()); + assert!(masked.get(first..last).is_some_and(|between| between.contains('F')), "{id}: faces between the member's runs: {masked}"); + } +} + #[test] fn classic_parameters_are_checked() { let n = classic(json!({})); From 2bb8cd0b92785fd287dc2bdada0cfd921b247f43 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 00:04:53 -0800 Subject: [PATCH 04/11] =?UTF-8?q?Swatch=20libraries:=20Patterns=20?= =?UTF-8?q?=E2=80=BA=20Optical=20Effects=20adds=20wave,=20moir=C3=A9=20and?= =?UTF-8?q?=20op-art=20patterns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Window › Swatch Libraries › Patterns and the Swatches panel's library menu gain Optical Effects (`optical-effects`), a built-in pattern library of eight black tiles drawn in code: Sine Bands, Ripple Lines, Interference Rings, Offset Rings, Warped Checks, Pinched Stripes, Chevron Pulse and Radiating Lenses. The names and designs are VectorCraft's own. Each tile is 32 to 48 pt, one period of its design, with fixed sample counts (at most 600 paths and 20,000 segments), so a band that leaves one edge comes back at the same place on the opposite edge, and rings and lenses that cross an edge are kept whole for the tiling to draw on the next tile. Clicking a swatch adds its pattern and swatch once and applies it, as with Geometric Patterns (`swatch.library.add`). The menu label has a row in every catalog; docs/mcp.md, the parity doc and ROADMAP name the library. --- ROADMAP.md | 1 + crates/engine/src/cmd/patternlib.rs | 254 ++++++++++++++++++ crates/engine/src/tests/swatchlib.rs | 45 +++- crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/bs.tsv | 1 + crates/ui-egui/src/i18n/ca.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hi.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ka.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + .../ui-egui/src/panels/library_panel/tests.rs | 3 +- docs/mcp.md | 7 +- docs/target-app-parity.md | 2 +- 33 files changed, 333 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index b1f75b2735..af46cb5a4e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -127,6 +127,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap - 2026-11-02: Mouse wheel over a closed font-family field in Character, Properties or Control changes selected text through the family list using saved font filters (excluding search text), one undo step per notch, stopping at the ends (#5253). Scores and estimates unchanged. - 2026-10-31: Pattern libraries: Window › Swatch Libraries › Patterns and the Swatches panel's library menu open Geometric Patterns, a built-in library of eleven pattern swatches (dots, stripes, checks, a grid and shapes); clicking one adds its pattern and swatch to the document, once, and applies it to the active fill or stroke (`swatch.library.add`). - 2026-11-11: Pattern libraries: Patterns › Grit Textures, a second built-in pattern library, holds eight seamless black textures (Fine Grain, Coarse Grit, Sand, Speckle, Stipple, Scratches, Sparse Flecks and Dense Flecks) on tiles of 18 to 36 pt, each drawn the same way every time; marks that reach past a tile edge continue on the next tile, so the tiles join without a seam (`swatch.library.add`). Scores and estimates unchanged. +- 2026-11-11: Pattern libraries: Patterns › Optical Effects, a third built-in pattern library, of VectorCraft's own designs, holds eight black wave, moiré and op-art tiles (Sine Bands, Ripple Lines, Interference Rings, Offset Rings, Warped Checks, Pinched Stripes, Chevron Pulse and Radiating Lenses) of 32 to 48 pt, each drawn the same way every time; each tile is one period of its design, and art that reaches past a tile edge continues on the next tile, so the tiles join without a seam (`swatch.library.add`). Scores and estimates unchanged. - 2026-11-01: Find Nearest Swatch joins the Color Effects add-on (Object › Plug-ins › Color Effects): for each colour of the selection, the closest swatches of the document, a built-in library or a library or colour book the user loaded, with their CIEDE2000 difference; Apply gives each colour its nearest swatch as one undo step, linking global and spot swatches and adding a library's to the document first, so a process colour becomes the closest spot colour (`addon.color.nearestSwatch`, `addon.color.applyNearestSwatch`, #4923). - 2026-11-01: Expand Dashes into Paths joins the Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): each selected path's dashed stroke becomes separate open paths, one per dash, laid out exactly as the stroke draws them (pattern, offset, Align Dashes to Corners and Path Ends), each keeping the stroke with the dashes off, so the gaps are real gaps for cutters and plotters, as one undo step (`addon.pathtools.expandDashes`, #4922). - 2026-11-01: Randomize Opacity joins the Arrange Tools add-on (Object › Plug-ins › Arrange Tools): each selected object gets its own opacity at random between a minimum and a maximum percent, with a seed that repeats the result, as one undo step (`addon.arrange.randomizeOpacity`, #4925). diff --git a/crates/engine/src/cmd/patternlib.rs b/crates/engine/src/cmd/patternlib.rs index 8fd2a0ea5e..f69e4b889e 100644 --- a/crates/engine/src/cmd/patternlib.rs +++ b/crates/engine/src/cmd/patternlib.rs @@ -26,6 +26,7 @@ pub struct BuiltinPatternLibrary { pub const PATTERN_LIBRARIES: &[BuiltinPatternLibrary] = &[ BuiltinPatternLibrary { id: "geometric-patterns", name: "Geometric Patterns", make: geometric_patterns }, BuiltinPatternLibrary { id: "grit-textures", name: "Grit Textures", make: grit_textures }, + BuiltinPatternLibrary { id: "optical-effects", name: "Optical Effects", make: optical_effects }, ]; /// The library category of the built-in pattern libraries. @@ -256,6 +257,162 @@ fn grit_textures(d: &mut Document) { }); } +/// `n` + 1 points of the curve `(t, f(t))` for `t` from `t0` to `t1`, evenly spaced. +fn curve(t0: f64, t1: f64, n: u32, f: impl Fn(f64) -> f64) -> Vec<(f64, f64)> { + (0..=n) + .map(|i| { + let t = t0 + (t1 - t0) * f64::from(i) / f64::from(n); + (t, f(t)) + }) + .collect() +} + +/// The closed band between two curves drawn from the same side to the same side: `upper` forward, +/// then `lower` back. +fn band(upper: &[(f64, f64)], lower: &[(f64, f64)]) -> PathData { + let points: Vec<(f64, f64)> = upper.iter().chain(lower.iter().rev()).copied().collect(); + polygon(&points) +} + +/// 72 corners of the circle of radius `r` about (`cx`, `cy`), turning the other way with `reverse`. +fn circle(cx: f64, cy: f64, r: f64, reverse: bool) -> SubPath { + const CORNERS: u32 = 72; + let turn = if reverse { -std::f64::consts::TAU } else { std::f64::consts::TAU }; + let points: Vec = (0..CORNERS) + .map(|i| { + let a = turn * f64::from(i) / f64::from(CORNERS); + Point::new(cx + r * a.cos(), cy + r * a.sin()) + }) + .collect(); + SubPath::polyline(&points, true) +} + +/// The ring from radius `inner` to `outer` about (`cx`, `cy`): the outer circle and the inner one +/// turning the other way, so the middle stays clear under either fill rule. An `inner` of 0 gives +/// a disk. +fn ring(cx: f64, cy: f64, inner: f64, outer: f64) -> PathData { + let mut subpaths = vec![circle(cx, cy, outer, false)]; + if inner > 0.0 { + subpaths.push(circle(cx, cy, inner, true)); + } + PathData::new(subpaths) +} + +/// A lens `2 × l` long and `2 × h` high about (`cx`, `cy`), lying along x: two half sine waves that +/// meet at its tips, turning the other way with `reverse`. +fn lens(cx: f64, cy: f64, l: f64, h: f64, reverse: bool) -> SubPath { + const STEPS: u32 = 32; + let at = |i: u32, side: f64| { + let t = f64::from(i) / f64::from(STEPS); + Point::new(cx - l + 2.0 * l * t, cy + side * h * (std::f64::consts::PI * t).sin()) + }; + let mut points: Vec = (0..=STEPS).map(|i| at(i, -1.0)).chain((1..STEPS).rev().map(|i| at(i, 1.0))).collect(); + if reverse { + points.reverse(); + } + SubPath::polyline(&points, true) +} + +/// Optical Effects: black waves, rings, warped checks, pinched stripes, chevrons and lenses on a +/// clear background. Each tile is one period of its design, so a band that leaves one edge comes +/// back at the same place on the opposite edge. Art that reaches past an edge is kept whole: the +/// tiling draws the part outside the tile on the next tile. +fn optical_effects(d: &mut Document) { + use std::f64::consts::TAU; + // Three bands 5 pt wide, 12 pt apart, each one sine wave across the tile. + let bands = (0..3u32) + .map(|k| { + let mid = move |x: f64| 6.0 + 12.0 * f64::from(k) + 3.0 * (TAU * x / 36.0).sin(); + band(&curve(0.0, 36.0, 48, |x| mid(x) - 2.5), &curve(0.0, 36.0, 48, |x| mid(x) + 2.5)) + }) + .collect(); + add(d, "Sine Bands", 36.0, 36.0, TileType::Grid, bands); + // Nine thin waves 4 pt apart, two waves across the tile, each a ninth of a wave on from the one + // above it, so the ninth leads into the first wave of the next tile down. + let lines = (0..9u32) + .map(|k| { + let (y, phase) = (2.0 + 4.0 * f64::from(k), TAU * f64::from(k) / 9.0); + let mid = move |x: f64| y + 2.5 * (TAU * x / 18.0 + phase).sin(); + band(&curve(0.0, 36.0, 72, |x| mid(x) - 0.6), &curve(0.0, 36.0, 72, |x| mid(x) + 0.6)) + }) + .collect(); + add(d, "Ripple Lines", 36.0, 36.0, TileType::Grid, lines); + // Thin rings 3 pt apart about the center and rings 3.3 pt apart about a point 4 pt to its + // right. The two sets cross, and the outer rings reach into the next tile across. + let rings = (1..=7u32) + .map(|k| (24.0, 3.0 * f64::from(k))) + .chain((1..=7u32).map(|k| (28.0, 3.3 * f64::from(k)))) + .map(|(cx, r)| ring(cx, 24.0, r - 0.6, r + 0.6)) + .collect(); + add(d, "Interference Rings", 48.0, 48.0, TileType::Grid, rings); + // Rings 2 pt and 1 pt wide by turns about the tile's corner and about its center, the center's + // starting with the narrower one. The corner's rings are the same rings at all four corners. + let mut rings = vec![]; + for (c, first) in [(0.0, 0), (20.0, 1)] { + let mut r = 0.0; + for k in 0..5 { + let width = if (k + first) % 2 == 0 { 2.0 } else { 1.0 }; + rings.push(ring(c, c, r, r + width)); + r += width + 1.2; + } + } + add(d, "Offset Rings", 40.0, 40.0, TileType::Grid, rings); + // An 8 × 8 checkerboard of 4 pt cells whose columns are bent by a sine wave down the tile and + // whose rows by one across it, each edge drawn through five points. + let warp = |x: f64, y: f64| (x + 2.0 * (TAU * y / 32.0).sin(), y + 2.0 * (TAU * x / 32.0).sin()); + let cells = (0..8u32) + .flat_map(|row| (0..8u32).map(move |col| (col, row))) + .filter(|(col, row)| (col + row) % 2 == 0) + .map(|(col, row)| { + let (x, y) = (4.0 * f64::from(col), 4.0 * f64::from(row)); + let corners = [(x, y), (x + 4.0, y), (x + 4.0, y + 4.0), (x, y + 4.0), (x, y)]; + let points: Vec<(f64, f64)> = corners + .windows(2) + .flat_map(|e| { + let (a, b) = (e[0], e[1]); + (0..4u32).map(move |i| { + let t = f64::from(i) / 4.0; + warp(a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t) + }) + }) + .collect(); + polygon(&points) + }) + .collect(); + add(d, "Warped Checks", 32.0, 32.0, TileType::Grid, cells); + // Six upright stripes 6 pt apart that widen from 0.8 pt to 5.6 pt and narrow again down the tile, + // each a sixth of a wave on from the one to its left. + let stripes = (0..6u32) + .map(|k| { + let (x, phase) = (3.0 + 6.0 * f64::from(k), TAU * f64::from(k) / 6.0); + let half = move |y: f64| 1.6 + 1.2 * (TAU * y / 36.0 + phase).cos(); + let side = |s: f64| -> Vec<(f64, f64)> { curve(0.0, 36.0, 48, |y| x + s * half(y)).into_iter().map(|(y, at)| (at, y)).collect() }; + band(&side(-1.0), &side(1.0)) + }) + .collect(); + add(d, "Pinched Stripes", 36.0, 36.0, TileType::Grid, stripes); + // Four zigzag bands 8 pt apart, two zigzags across the tile; the bend and the width of the bands + // rise and fall from band to band. + let chevrons = [(0.0, 3.0, 4.0), (8.0, 4.5, 2.5), (16.0, 3.0, 1.0), (24.0, 1.5, 2.5)] + .into_iter() + .map(|(y, bend, width)| { + let upper: Vec<(f64, f64)> = (0..=4u32).map(|i| (8.0 * f64::from(i), if i % 2 == 1 { y + bend } else { y })).collect(); + let lower: Vec<(f64, f64)> = upper.iter().map(|&(x, y)| (x, y + width)).collect(); + band(&upper, &lower) + }) + .collect(); + add(d, "Chevron Pulse", 32.0, 32.0, TileType::Grid, chevrons); + // Five nested lens outlines 1.5 pt wide about the center, 14 pt to 78 pt long. The longer ones + // reach across the side edges and cross the next tiles' lenses. + let lenses = (0..5u32) + .map(|k| { + let (l, h) = (7.0 + 8.0 * f64::from(k), 4.0 + 4.0 * f64::from(k)); + PathData::new(vec![lens(24.0, 24.0, l, h, false), lens(24.0, 24.0, l - 1.5, h - 1.5, true)]) + }) + .collect(); + add(d, "Radiating Lenses", 48.0, 48.0, TileType::Grid, lenses); +} + #[cfg(test)] mod tests { use super::*; @@ -326,4 +483,101 @@ mod tests { } assert!(crossing > 0, "no mark reaches past an edge"); } + + const OPTICAL: [&str; 8] = + ["Sine Bands", "Ripple Lines", "Interference Rings", "Offset Rings", "Warped Checks", "Pinched Stripes", "Chevron Pulse", "Radiating Lenses"]; + + fn optical_doc() -> Document { + let mut d = Document::new(100.0, 100.0); + optical_effects(&mut d); + d + } + + /// The paths of pattern `p`'s art, each with its fill rule. + fn paths(p: &PatternDef) -> Vec<(&PathData, vectorcraft_geom::FillRule)> { + p.art + .iter() + .map(|n| match &n.kind { + vectorcraft_doc::NodeKind::Path { path, rule, .. } => (path, *rule), + _ => panic!("{}: art that is not a path", p.name), + }) + .collect() + } + + #[test] + fn optical_effects_is_a_pattern_library_of_eight_bounded_tiles() { + let b = PATTERN_LIBRARIES.iter().find(|b| b.id == "optical-effects").expect("Optical Effects is listed"); + assert_eq!(b.name, "Optical Effects"); + let names: Vec = library_doc(b.id).unwrap().patterns.iter().map(|p| p.name.clone()).collect(); + assert_eq!(names, OPTICAL); + // Made twice, the art is the same. + let (a, again) = (optical_doc(), optical_doc()); + assert_eq!(a.patterns, again.patterns); + for p in &a.patterns { + let (w, h) = (p.width(), p.height()); + assert!((32.0..=48.0).contains(&w) && w == h && p.tile.x0 == 0.0 && p.tile.y0 == 0.0, "{}", p.name); + assert_eq!(p.tile_type, TileType::Grid, "{}", p.name); + let paths = paths(p); + let segments: usize = paths.iter().flat_map(|(path, _)| &path.subpaths).map(SubPath::segment_count).sum(); + assert!(!paths.is_empty() && paths.len() <= 600 && segments <= 20_000, "{}: {} paths, {segments} segments", p.name, paths.len()); + for (path, _) in &paths { + let b = path.bounds().unwrap_or_else(|| panic!("{}: an empty path", p.name)); + assert!([b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite()) && b.width() > 0.0 && b.height() > 0.0, "{}: {b:?}", p.name); + assert!(b.x1 > 0.0 && b.y1 > 0.0 && b.x0 < w && b.y0 < h, "{}: {b:?} does not touch the tile", p.name); + } + } + } + + /// Just either side of a tile's right and bottom edges, the tiling paints the same points: a band + /// cut off at an edge, or one that comes back at another height or width, paints one side only. + /// No path is another moved by a whole tile, which the tiling would draw twice in one place. + #[test] + fn optical_tiles_join_without_a_seam() { + const SAMPLES: u32 = 400; + const STEP: f64 = 1e-4; + for p in &optical_doc().patterns { + let (w, h) = (p.width(), p.height()); + let shapes: Vec<(BezPathFill, Rect)> = + paths(p).into_iter().map(|(path, rule)| ((path.to_bezpath(), rule), path.bounds().unwrap_or(Rect::ZERO))).collect(); + let paints = |q: Point| { + let near = Rect::new(q.x - STEP, q.y - STEP, q.x + STEP, q.y + STEP); + p.offsets_covering(near).into_iter().any(|o| { + let at = q - o; + shapes.iter().any(|((bp, rule), b)| { + let inside = b.x0 <= at.x && at.x <= b.x1 && b.y0 <= at.y && at.y <= b.y1; + let turns = if inside { kurbo::Shape::winding(bp, at) } else { 0 }; + match rule { + vectorcraft_geom::FillRule::EvenOdd => turns % 2 != 0, + vectorcraft_geom::FillRule::NonZero => turns != 0, + } + }) + }) + }; + let (mut differ, mut painted) = (0, 0); + for i in 0..SAMPLES { + let t = (f64::from(i) + 0.37) / f64::from(SAMPLES); + let (right, bottom) = (Point::new(w, t * h), Point::new(t * w, h)); + let (dx, dy) = (kurbo::Vec2::new(STEP, 0.0), kurbo::Vec2::new(0.0, STEP)); + for (a, b) in [(right - dx, right + dx), (bottom - dy, bottom + dy)] { + let (pa, pb) = (paints(a), paints(b)); + differ += usize::from(pa != pb); + painted += usize::from(pa); + } + } + assert!(differ <= 2, "{}: {differ} of {} points across the edges differ", p.name, 2 * SAMPLES); + assert!(painted > 0, "{}: nothing crosses an edge", p.name); + let bounds: Vec = shapes.iter().map(|(_, b)| *b).collect(); + let same = |a: &Rect, b: Rect| { + (a.x0 - b.x0).abs() < 1e-9 && (a.y0 - b.y0).abs() < 1e-9 && (a.x1 - b.x1).abs() < 1e-9 && (a.y1 - b.y1).abs() < 1e-9 + }; + for b in &bounds { + for (dx, dy) in [(w, 0.0), (-w, 0.0), (0.0, h), (0.0, -h), (w, h), (w, -h), (-w, h), (-w, -h)] { + let moved = *b + kurbo::Vec2::new(dx, dy); + assert!(!bounds.iter().any(|o| same(o, moved)), "{}: {b:?} is drawn twice by the tiling", p.name); + } + } + } + } + + type BezPathFill = (vectorcraft_geom::BezPath, vectorcraft_geom::FillRule); } diff --git a/crates/engine/src/tests/swatchlib.rs b/crates/engine/src/tests/swatchlib.rs index 57d6c23d06..a02fb079c9 100644 --- a/crates/engine/src/tests/swatchlib.rs +++ b/crates/engine/src/tests/swatchlib.rs @@ -806,7 +806,7 @@ fn a_grit_texture_adds_once_undoes_and_saves() { let list = run(&mut s, "swatch.library.list", json!({})); let ids: Vec<&str> = list["libraries"].as_array().unwrap().iter().filter(|l| l["category"] == "patterns").map(|l| l["id"].as_str().unwrap()).collect(); - assert_eq!(ids, ["geometric-patterns", "grit-textures"]); + assert_eq!(ids, ["geometric-patterns", "grit-textures", "optical-effects"]); let got = run(&mut s, "swatch.library.get", json!({"library": "Grit Textures"})); let names: Vec<&str> = got["swatches"].as_array().unwrap().iter().map(|w| w["name"].as_str().unwrap()).collect(); assert_eq!(names, ["Fine Grain", "Coarse Grit", "Sand", "Speckle", "Stipple", "Scratches", "Sparse Flecks", "Dense Flecks"]); @@ -831,6 +831,49 @@ fn a_grit_texture_adds_once_undoes_and_saves() { assert_eq!(back.swatch("Stipple").map(|w| &w.paint), doc(&s).swatch("Stipple").map(|w| &w.paint)); } +/// Optical Effects is listed after Grit Textures, and reading it adds nothing. Clicking one of +/// its patterns brings it with its swatch and fills the selection with it, once, as one undo step +/// that also takes both away, and both stay after a save and reopen. +#[test] +fn an_optical_effect_adds_once_fills_undoes_and_saves() { + let mut s = session(); + let counts = |d: &vectorcraft_doc::Document| (d.patterns.len(), d.swatches_iter().count()); + let empty = counts(doc(&s)); + let list = run(&mut s, "swatch.library.list", json!({})); + let ids: Vec<&str> = + list["libraries"].as_array().unwrap().iter().filter(|l| l["category"] == "patterns").map(|l| l["id"].as_str().unwrap()).collect(); + assert_eq!(ids, ["geometric-patterns", "grit-textures", "optical-effects"]); + let got = run(&mut s, "swatch.library.get", json!({"library": "Optical Effects"})); + let names: Vec<&str> = got["swatches"].as_array().unwrap().iter().map(|w| w["name"].as_str().unwrap()).collect(); + assert_eq!( + names, + ["Sine Bands", "Ripple Lines", "Interference Rings", "Offset Rings", "Warped Checks", "Pinched Stripes", "Chevron Pulse", "Radiating Lenses"] + ); + assert_eq!(counts(doc(&s)), empty, "reading a library adds no pattern or swatch"); + let id = vectorcraft_doc::NodeId(run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].as_u64().unwrap()); + let undo = undo_len(&s); + let r = run(&mut s, "swatch.library.add", json!({"library": "optical-effects", "names": ["Warped Checks"], "apply": "fill"})); + assert_eq!((r["added"].clone(), r["applied"].clone()), (json!(["Warped Checks"]), json!("Warped Checks"))); + assert_eq!((counts(doc(&s)), undo_len(&s)), ((empty.0 + 1, empty.1 + 1), undo + 1)); + let def = doc(&s).pattern("Warped Checks").expect("the pattern comes with its swatch").clone(); + assert_eq!(def.art.len(), 32, "the black cells of an 8 × 8 checkerboard"); + assert!(matches!(doc(&s).swatch("Warped Checks").map(|w| &w.paint), Some(Paint::Pattern { pattern, .. }) if pattern == "Warped Checks")); + let filled = |d: &vectorcraft_doc::Document| matches!(d.node(id).unwrap().appearance.fill_paint(), Paint::Pattern { pattern, .. } if pattern == "Warped Checks"); + assert!(filled(doc(&s))); + let r = run(&mut s, "swatch.library.add", json!({"library": "optical-effects", "names": ["Warped Checks"]})); + assert_eq!((r["added"].clone(), r["existing"].clone()), (json!([]), json!(["Warped Checks"]))); + assert_eq!((counts(doc(&s)), undo_len(&s)), ((empty.0 + 1, empty.1 + 1), undo + 1), "added once"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(counts(doc(&s)), empty); + assert!(doc(&s).pattern("Warped Checks").is_none() && doc(&s).swatch("Warped Checks").is_none() && !filled(doc(&s))); + run(&mut s, "edit.redo", json!({})); + assert!(filled(doc(&s))); + let back = vectorcraft_format::load(&vectorcraft_format::save(doc(&s), false)).unwrap(); + assert_eq!(back.pattern("Warped Checks"), Some(&def)); + assert_eq!(back.swatch("Warped Checks").map(|w| &w.paint), doc(&s).swatch("Warped Checks").map(|w| &w.paint)); + assert!(filled(&back)); +} + /// A device name is saved with a `-` after it, which can be another library's own name: neither /// library replaces the other, and saving either again replaces only itself. #[test] diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 97e5b335d3..240444ac20 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -5851,6 +5851,7 @@ theme Appearance Външен вид Ruby… Руби… Ruby Руби Geometric Guides Геометрични водачи + Optical Effects Оптични ефекти Geometric Patterns Геометрични мостри Grit Textures Зърнести текстури Ease In Плавно начало diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 75353321ff..22d1455f73 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -5868,6 +5868,7 @@ mark-style Japanese Japanski Ruby… Ruby… Ruby Ruby Geometric Guides Geometrijske vodilice + Optical Effects Optički efekti Geometric Patterns Geometrijski uzorci Grit Textures Zrnaste teksture Ease In Ublaži ulaz diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 75e0b9ea20..2d6e8799d0 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -2914,6 +2914,7 @@ digits Malayalam Malaiàlam Opens network connections Obre connexions de xarxa Open… Obre… Optical Òptic + Optical Effects Efectes òptics Optical Margin Alignment Alineació òptica de marges Dynamic Text Text dinàmic Dynamic Text Presets Valors predefinits de text dinàmic diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 5d37ee136e..3c1170bf9d 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6033,6 +6033,7 @@ mark-style Japanese Japonské Ruby… Ruby… Ruby Ruby Geometric Guides Geometrická vodítka + Optical Effects Optické efekty Geometric Patterns Geometrické vzorky Grit Textures Zrnité textury Ease In Pozvolný začátek diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 8167f80138..2a1b9d8007 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -5866,6 +5866,7 @@ swatch Bright Violet Kraftig violet Ruby… Ruby… Ruby Ruby Geometric Guides Geometriske hjælpelinjer + Optical Effects Optiske effekter Geometric Patterns Geometriske mønstre Grit Textures Kornede teksturer Ease In Langsom start diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index bd4bb38b43..0805be2caf 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5622,6 +5622,7 @@ effect Soft Weich Ruby… Ruby… Ruby Ruby Geometric Guides Geometrische Hilfslinien + Optical Effects Optische Effekte Geometric Patterns Geometrische Muster Grit Textures Körnige Texturen Ease In Langsam starten diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 9860f8ad50..278b6cf81a 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -5984,6 +5984,7 @@ effect Points Σημεία Ruby… Ruby… Ruby Ruby Geometric Guides Γεωμετρικοί οδηγοί + Optical Effects Οπτικά εφέ Geometric Patterns Γεωμετρικά μοτίβα Grit Textures Κοκκώδεις υφές Ease In Ομαλή αρχή diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 10c0c0bf87..f0f804e008 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -5975,6 +5975,7 @@ mark-style Japanese Japonesas Ruby… Ruby… Ruby Ruby Geometric Guides Guías geométricas + Optical Effects Efectos ópticos Geometric Patterns Motivos geométricos Grit Textures Texturas granuladas Ease In Entrada suave diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 7845235f65..471b98e76c 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6063,6 +6063,7 @@ swatch Bright Violet Kirkas violetti Ruby… Ruby… Ruby Ruby Geometric Guides Geometriset apuviivat + Optical Effects Optiset tehosteet Geometric Patterns Geometriset kuviot Grit Textures Rakeiset tekstuurit Ease In Hidas alku diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 5e2895f9c2..fe3b98d0e8 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6028,6 +6028,7 @@ theme Appearance Apparence Ruby… Ruby… Ruby Ruby Geometric Guides Repères géométriques + Optical Effects Effets optiques Geometric Patterns Motifs géométriques Grit Textures Textures granuleuses Ease In Accélération progressive diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index aca21d7eec..74fb288598 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -3170,6 +3170,7 @@ antiAlias Sharp शार्प Opens network connections नेटवर्क कनेक्शन ओपन करें OpenType Features ओपन टाइप सुविधाएँ Optical ऑप्टिकल + Optical Effects ऑप्टिकल इफ़ेक्ट्स Optical Margin Alignment ऑप्टिकल मार्जिन संरेखण Dynamic Text डायनैमिक टेक्स्ट Dynamic Text Presets डायनैमिक टेक्स्ट प्रीसेट diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 45d19b0ca9..55b97d4d21 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6016,6 +6016,7 @@ imageTrace Outline Körvonal Ruby… Ruby… Ruby Ruby Geometric Guides Geometriai segédvonalak + Optical Effects Optikai effektusok Geometric Patterns Geometriai mintázatok Grit Textures Szemcsés textúrák Ease In Lassú kezdés diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 8e2b383e2d..a55f7699bf 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -5866,6 +5866,7 @@ swatch Bright Violet Violet cerah Ruby… Ruby… Ruby Ruby Geometric Guides Panduan Geometris + Optical Effects Efek Optis Geometric Patterns Pola Geometris Grit Textures Tekstur Butiran Ease In Awal Lambat diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 26e1f29651..a9ba306b9f 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5656,6 +5656,7 @@ lighting Light Luce Ruby… Ruby… Ruby Ruby Geometric Guides Guide geometriche + Optical Effects Effetti ottici Geometric Patterns Motivi geometrici Grit Textures Texture granulose Ease In Inizio graduale diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 30873028e8..fab0450610 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5706,6 +5706,7 @@ lighting Light ライト Ruby… ルビ… Ruby ルビ Geometric Guides 幾何ガイド + Optical Effects オプティカル効果 Geometric Patterns 幾何パターン Grit Textures 粒状テクスチャ Ease In イーズイン diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 8e9d9d049d..c8274c9b31 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -5973,6 +5973,7 @@ imageTrace Outline კონტური Ruby… რუბი… Ruby რუბი Geometric Guides გეომეტრიული მიმმართველები + Optical Effects ოპტიკური ეფექტები Geometric Patterns გეომეტრიული პატერნები Grit Textures მარცვლოვანი ტექსტურები Ease In ნელი დასაწყისი diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index f00e6cdeba..93b4ce1bb9 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5617,6 +5617,7 @@ lighting Light 광원 Ruby… 루비… Ruby 루비 Geometric Guides 기하학적 안내선 + Optical Effects 광학 효과 Geometric Patterns 기하학적 패턴 Grit Textures 입자 텍스처 Ease In 서서히 시작 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index bc00b76698..282b409a58 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -5917,6 +5917,7 @@ swatch Bright Violet Felviolet Ruby… Ruby… Ruby Ruby Geometric Guides Geometrische hulplijnen + Optical Effects Optische effecten Geometric Patterns Geometrische patronen Grit Textures Korrelige texturen Ease In Langzaam beginnen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 23940fb80f..1899b72951 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5887,6 +5887,7 @@ mark-style Japanese Japońskie Ruby… Ruby… Ruby Ruby Geometric Guides Geometryczne linie pomocnicze + Optical Effects Efekty optyczne Geometric Patterns Wzorki geometryczne Grit Textures Tekstury ziarniste Ease In Łagodny początek diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 32c4b4bcc0..13eee61ed3 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -5948,6 +5948,7 @@ mark-style Japanese Japonesas Ruby… Ruby… Ruby Ruby Geometric Guides Guias geométricas + Optical Effects Efeitos ópticos Geometric Patterns Padrões geométricos Grit Textures Texturas granuladas Ease In Entrada suave diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 15d3c3a380..f1321b48d8 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5642,6 +5642,7 @@ lighting Light Свет Ruby… Руби… Ruby Руби Geometric Guides Геометрические направляющие + Optical Effects Оптические эффекты Geometric Patterns Геометрические узоры Grit Textures Зернистые текстуры Ease In Плавное начало diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 5ffc963fad..999be89513 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -6055,6 +6055,7 @@ font-script Japanese Japanska Ruby… Ruby… Ruby Ruby Geometric Guides Geometriska stödlinjer + Optical Effects Optiska effekter Geometric Patterns Geometriska mönster Grit Textures Korniga texturer Ease In Långsam start diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 86356d74ba..753f6024f6 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6028,6 +6028,7 @@ axis Y Axis Y ekseni Snap to Tangent Teğete Yasla Snap to Perpendicular Dikeye Yasla Geometric Guides Geometrik Kılavuzlar + Optical Effects Optik Efektler Geometric Patterns Geometrik Desenler Grit Textures Grenli Dokular @msg `method` must be classic or perceptual `method` classic veya perceptual olmalıdır diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index ebba0f5c19..ad649f4ab0 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5636,6 +5636,7 @@ lighting Light Світло Ruby… Рубі… Ruby Рубі Geometric Guides Геометричні напрямні + Optical Effects Оптичні ефекти Geometric Patterns Геометричні візерунки Grit Textures Зернисті текстури Ease In Плавний початок diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 0ec0fd4e40..93e9d93881 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5637,6 +5637,7 @@ theme Appearance Giao diện Ruby… Ruby… Ruby Ruby Geometric Guides Đường dẫn hình học + Optical Effects Hiệu ứng quang học Geometric Patterns Họa tiết hình học Grit Textures Kết cấu hạt Ease In Chậm lúc đầu diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 9fc1cdc4e8..45cd676f69 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -5870,6 +5870,7 @@ lighting Light 光源 Ruby… 注音… Ruby 注音 Geometric Guides 几何参考线 + Optical Effects 光学效果 Geometric Patterns 几何图案 Grit Textures 颗粒纹理 Ease In 缓入 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 685f7887fb..e6393341b3 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5674,6 +5674,7 @@ lighting Light 光源 Ruby… 注音… Ruby 注音 Geometric Guides 幾何參考線 + Optical Effects 光學效果 Geometric Patterns 幾何圖樣 Grit Textures 顆粒紋理 Ease In 緩入 diff --git a/crates/ui-egui/src/panels/library_panel/tests.rs b/crates/ui-egui/src/panels/library_panel/tests.rs index f28e7e5540..10acd4603e 100644 --- a/crates/ui-egui/src/panels/library_panel/tests.rs +++ b/crates/ui-egui/src/panels/library_panel/tests.rs @@ -155,9 +155,10 @@ fn find_filters_list_view_and_arrows_step_through_the_libraries() { assert!(ctx.read_response(tile_id(panel, "#00FF00")).is_none()); let first = SWATCH_IDS[0]; let last = SwatchLibraries::list(&app).pop().unwrap().id; - assert_eq!(last, "grit-textures", "gradient and then pattern libraries come after the swatch libraries"); + assert_eq!(last, "optical-effects", "gradient and then pattern libraries come after the swatch libraries"); assert_eq!(neighbour::(&app, "radial-glows", 1).as_deref(), Some("geometric-patterns")); assert_eq!(neighbour::(&app, "geometric-patterns", 1).as_deref(), Some("grit-textures")); + assert_eq!(neighbour::(&app, "grit-textures", 1).as_deref(), Some("optical-effects")); assert_eq!(neighbour::(&app, first, -1), Some(last), "wraps around"); assert_eq!(neighbour::(&app, "web-safe-216", 1).as_deref(), Some(SWATCH_IDS[1])); } diff --git a/docs/mcp.md b/docs/mcp.md index 7bf5ae9956..dd910b162a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3373,9 +3373,10 @@ in the `swatch.list` shape (`library` is an id or a name). `swatch.library.add { into the document as one undo step: a colour group's name brings the whole group, a swatch's name the swatch alone, no names the whole library; swatches the document already has (same name and paint) are reported under `existing` and not added again. `apply: "fill"|"stroke"` also applies the first one, in the same undo step (what clicking a -swatch in the library panel does). The pattern libraries Geometric Patterns (`geometric-patterns`) and Grit Textures -(`grit-textures`: seamless black grain, grit, sand, speckle, stipple, scratch and fleck tiles), category `patterns`, -hold pattern swatches drawn in code; adding one also adds its pattern, as pasting art painted with it does +swatch in the library panel does). The pattern libraries Geometric Patterns (`geometric-patterns`), Grit Textures +(`grit-textures`: seamless black grain, grit, sand, speckle, stipple, scratch and fleck tiles) and Optical Effects +(`optical-effects`: VectorCraft's own black wave, ring, warped-check, pinched-stripe, chevron and lens tiles), +category `patterns`, hold pattern swatches drawn in code; adding one also adds its pattern, as pasting art painted with it does (the same pattern the document has is reused, and a name another pattern or swatch has gets a number). `swatch.resetDefaults {replace?}` brings back the missing default swatches. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b1ceaf70ea..e6bcb23301 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1294,7 +1294,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Double-clicking the Objects on Path tool opens Objects on Path Tool Options ([Objects on Path](https://helpx.adobe.com/illustrator/using/objects-on-path.html)): the nine-point Pivot grid and the Rotate field set the attachment point and angle of the objects attached next, and groups already attached keep theirs; Reset puts the center and 0° back. Save keeps the options across tool switches and saves them with the preferences (`tool.setOption {tool: "objectsOnPath"}`). - **Paint and appearance (M3):** - **CSS swatch libraries:** fractional linear and radial gradient stop positions are preserved, so nearby stops such as 12.5% and 12.890625% remain distinct instead of collapsing to 13%. Reference: [CSS Images 3 color stops](https://www.w3.org/TR/css-images-3/#color-stop-syntax). The original synthetic regression is `palette_io::tests::css_preserves_fractional_gradient_stop_positions` in `crates/color/src/palette_io.rs`. Scores and hours are unchanged. - - **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries, six gradient libraries (Organic Fades among them: soft, perceptually mixed multi-stop fades that thin out to transparent) and two pattern libraries (Geometric Patterns, and Grit Textures with eight seamless grain, speckle, stipple, scratch and fleck tiles; their swatches bring their patterns) in a library panel, saved as `.vcswatches`, `.gpl`, swatch exchange (`.ase`) or CSS, and loaded from `.vcswatches`, `.gpl` and `.ase` files (`.ase` both ways: RGB, CMYK, Lab and Gray; global, spot and process; groups) and from color books (`.acb`, read only: the RGB, CMYK or Lab colors of a book such as a Pantone book a user owns, named with its prefix and suffix, spot unless it says process, #831); `swatch.library.copyToUser` copies a loaded library into the User Defined folder without replacing a file, and a file with the same extension and bytes as a User Defined library opens as that library. Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate. + - **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries, six gradient libraries (Organic Fades among them: soft, perceptually mixed multi-stop fades that thin out to transparent) and three pattern libraries (Geometric Patterns; Grit Textures with eight seamless grain, speckle, stipple, scratch and fleck tiles; and Optical Effects with eight of VectorCraft's own seamless wave, ring, warped-check, stripe, chevron and lens tiles; their swatches bring their patterns) in a library panel, saved as `.vcswatches`, `.gpl`, swatch exchange (`.ase`) or CSS, and loaded from `.vcswatches`, `.gpl` and `.ase` files (`.ase` both ways: RGB, CMYK, Lab and Gray; global, spot and process; groups) and from color books (`.acb`, read only: the RGB, CMYK or Lab colors of a book such as a Pantone book a user owns, named with its prefix and suffix, spot unless it says process, #831); `swatch.library.copyToUser` copies a loaded library into the User Defined folder without replacing a file, and a file with the same extension and bytes as a User Defined library opens as that library. Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate. Editing, renaming, deleting or merging a global swatch also changes the colors linked to it in graph designs, the art of brushes and the tile that pattern editing's Cancel puts back, in the same undo step as the rest of the art; Default Swatches with replace unlinks them there too. Add Used Colors (`swatch.addUsedColors`) and Select All Unused (`swatch.unused`) look colors, swatch names, links, tints and gradients up in hash sets instead of searching every swatch and color for each color or linked paint (New Color Group from artwork too), and New Swatch with several colors (`swatch.new`), New Color Group (`swatch.newGroup`) and Edit Color Group (`swatch.editGroup`) name their new swatches from one set of the names already taken. The new swatches keep their names, order and links, and Select All Unused lists the same swatches. Evidence: `add_used_colors_adds_each_new_color_once_with_free_names`, `add_used_colors_global_links_every_paint_of_a_new_color`, `unused_matches_links_tints_colors_gradients_and_patterns_exactly`, `new_swatches_from_colors_are_numbered_after_names_already_taken`, `new_color_group_numbers_its_colors_after_the_group_and_its_moved_swatches`, `new_color_group_from_artwork_lists_each_color_once_with_free_names` and `edit_color_group_numbers_extra_colors_after_the_names_it_keeps` in `crates/engine/src/tests/swatchcmds.rs`; `free_swatch_names_match_naming_each_swatch_as_it_is_added` and `free_swatch_names_match_new_swatch_names_as_swatches_are_added` in `crates/doc/src/swatches.rs`. - **Colorized images (#1466):** a fill color applied to a selected grayscale or 1-bit image (`paint.setFill`) colorizes it with black, white, a process color or a spot color, and refuses a process color when the image has an alpha channel ([Convert color modes](https://web.archive.org/web/20251220223850/https://helpx.adobe.com/illustrator/desktop/manage-colors/select-and-adjust-colors/convert-color-modes.html), "Colorize Grayscale or 1-bit images"); black and white are accepted there too. The canvas draws each pixel in the color as far as the pixel is dark, with white pixels transparent; PDF export writes the image in the color (a spot color as its ink, Registration in every ink) with the image as its soft mask; Separations Preview and printed separations show it on its color's plates; native files keep the color. SVG writes the fill color through a coverage mask (or a colorized PNG in Tiny 1.2); EPS writes binary coverage as an image mask in the color's ink, and a grayscale image as ink samples scaled by coverage, with white pixels left out at Level 3 and gray midtones written over white. From 3c1e3cc1120f48dc0dcb8ea158f9067caf110ede Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 01:00:55 -0800 Subject: [PATCH 05/11] 3D: say that a clipped composite keeps the whole solid in member order With colored lights a surface can be one clipped composite of its facets. The comment above the plain check now states that every surface of such a solid, masked ones included, draws whole in member order. --- crates/effects/src/solids.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 4744202dd0..65bff78e7e 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -842,8 +842,9 @@ fn surfaces_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option Date: Thu, 12 Nov 2026 16:26:48 +0430 Subject: [PATCH 06/11] Fix macOS temporary-path expectations in engine tests Canonicalize test roots before path comparisons and case-variant script probes. Production access checks are unchanged.\n\nFixes #6860 --- crates/engine/src/cmd/fileio/tests_screens.rs | 2 +- crates/engine/src/tests/script_io.rs | 2 +- docs/development.md | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/fileio/tests_screens.rs b/crates/engine/src/cmd/fileio/tests_screens.rs index 86ee164998..0b529c87c6 100644 --- a/crates/engine/src/cmd/fileio/tests_screens.rs +++ b/crates/engine/src/cmd/fileio/tests_screens.rs @@ -730,7 +730,7 @@ fn a_refused_export_into_a_presets_folder_outside_the_read_root_names_it_by_its_ let settings = json!({"formats": [{"format": "png"}], "folder": parent}); s.execute("document.exportPresets.save", &json!({"name": "Parent", "settings": settings})).unwrap(); let e = confine(confined.as_ref(), || s.execute("document.exportForScreens", &json!({"exportPreset": "Parent"}))).unwrap_err().to_string(); - let root = inside.display().to_string(); + let root = crate::cmd::findfiles::plain(inside.canonicalize().unwrap()).display().to_string(); let name = tmp.0.file_name().unwrap().to_string_lossy().into_owned(); assert!(e.contains(&format!("root {root}: {name}")), "{e}"); } diff --git a/crates/engine/src/tests/script_io.rs b/crates/engine/src/tests/script_io.rs index d8c5d1d2e4..667635356c 100644 --- a/crates/engine/src/tests/script_io.rs +++ b/crates/engine/src/tests/script_io.rs @@ -230,7 +230,7 @@ fn script_file_link_escape_refused() { /// not matter, and outside it where case matters, whether or not the file is there. #[test] fn script_file_case_variant_folder() { - let base = folder("case"); + let base = folder("case").canonicalize().unwrap(); write(&base.join("main/own.txt"), b"own"); let upper = base.join("MAIN"); let source = |name: &str| { diff --git a/docs/development.md b/docs/development.md index 2362fc6f1a..07c0f65a77 100644 --- a/docs/development.md +++ b/docs/development.md @@ -1,5 +1,6 @@ # Development +> **Last updated:** 2026-11-12 · **Change:** Tests canonicalize temporary roots before comparing preset refusal paths or probing a case-variant script folder (#6860); production access checks unchanged. > **Last updated:** 2026-11-06 · **Change:** Hindi catalog and zero/one plural rule; desktop Devanagari fallback and optional craft-fonts UI face; native review pending. > **Last updated:** 2026-11-01 · **Change:** Linux: Automatic Linux Display Server opens the window through XWayland when a pen is attached on a Wayland session, so the pen draws (#4967); `VECTORCRAFT_NATIVE_WAYLAND=1` keeps Wayland. > **Last updated:** 2026-10-31 · **Change:** files opened while the app runs open in its window on Windows and Linux (#4892); `--new-instance`. @@ -508,6 +509,7 @@ Scores and hour estimates are unchanged. | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | Tests (#6860): compare preset refusal roots after canonicalization and Windows display normalization; canonicalize the script fixture base before constructing `MAIN`. This keeps macOS `/var` → `/private/var` aliases from masking the case-sensitivity checks; no production-code change. | | 2026-11-06 | minor | Localisation: Hindi catalog, zero/one plurals, desktop and web font requirements; AI/MT draft, native review pending. | | 2026-10-31 | minor | Files opened while the app runs open in its window on Windows and Linux (#4892); `--new-instance` opts out. | | 2026-10-29 | minor | Linux clipboard: core Wayland protocol text fallback where arboard has neither data-control nor X11 (the Flatpak on GNOME).; its reads wait 2 s at most. | From a5d0fe7963b726908daf72d9485cb510fac21cab Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 04:58:20 -0800 Subject: [PATCH 07/11] Tests: font menu and menu bar tests allow for fonts and plug-ins other tests change In a full parallel `cargo test -p vectorcraft-ui-egui` run, other tests add and remove composite fonts in the process-wide font database and remove plug-ins from the plug-in registry. Two tests failed when that happened between their reads, and passed when run alone. - `font_menu::tests::the_scroll_bar_leaves_the_stars_clear` starred `FontDb::global().families()`, but the menu lists the composite fonts first, so the top rows had no star. The test now turns on the starred filter and counts the rows it listed. - `tests::nativemenu::the_menu_bar_is_inert_while_a_file_dialog_is_open` required every menu row from before the dialog to be back afterwards. A row may now be missing only when the composite font or plug-in it names is gone from the database or the registry. - Four font menu tests took the first families from `menu_family_list()`, which can be a composite font that is removed before the frames run. They take them from the families that stay listed. The variable-filter test checks that every family listed before the click is listed again, instead of comparing two frames. --- crates/ui-egui/src/font_menu/tests.rs | 26 +++++++++++++++++-------- crates/ui-egui/src/tests/nativemenu.rs | 27 ++++++++++++++++++++++++-- 2 files changed, 43 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/font_menu/tests.rs b/crates/ui-egui/src/font_menu/tests.rs index a054794379..259408711c 100644 --- a/crates/ui-egui/src/font_menu/tests.rs +++ b/crates/ui-egui/src/font_menu/tests.rs @@ -21,12 +21,13 @@ fn the_thai_filter_lists_fonts_with_thai() { /// stay clear of it. #[test] fn the_scroll_bar_leaves_the_stars_clear() { - // Every family starred, so each row draws its star. + // Every family starred, so each row draws its star. Tests running alongside add fonts and + // composite fonts to the process-wide database, which the menu lists too: the ★ filter keeps + // the rows to the families starred here. let all = vectorcraft_text::FontDb::global().families(); - let families = all.len(); let ctx = egui::Context::default(); let area = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(300.0, 160.0)); - let mut st = super::MenuState::default(); + let mut st = super::MenuState { favorites_only: true, ..Default::default() }; let look = super::MenuLook { samples: false, favorites: &all, ..Default::default() }; let mut stars = vec![]; for _ in 0..3 { @@ -45,6 +46,7 @@ fn the_scroll_bar_leaves_the_stars_clear() { }) .collect(); } + let families = st.rows.len(); assert!(!stars.is_empty() || families == 0, "{families} families, no row stars drawn"); // The rows don't fill the list without enough families to scroll, and then there is no bar. if families as f32 * look.row > area.height() { @@ -62,6 +64,13 @@ fn family(name: &str) -> Row { Row::Family(name.to_string()) } +/// The families the font menu lists that stay listed, in its order: the menu's list without the +/// composite fonts at its top, which tests running alongside define and remove in the process-wide +/// database. +fn lasting_families() -> Vec { + vectorcraft_text::FontDb::global().family_list().iter().filter(|f| !f.starts_with('.')).cloned().collect() +} + #[test] fn recent_fonts_are_first_available_unique_and_expandable() { let names: Vec<_> = ["Alpha", "Bravo", "Charlie"].map(str::to_string).into(); @@ -86,7 +95,7 @@ fn recent_fonts_are_first_available_unique_and_expandable() { #[test] fn recent_section_rebuilds_and_arrows_skip_its_headings() { - let names = vectorcraft_text::FontDb::global().menu_family_list(); + let names = lasting_families(); if names.len() < 3 { return; } @@ -135,7 +144,7 @@ fn recent_section_rebuilds_and_arrows_skip_its_headings() { #[test] fn recent_fonts_obey_search_and_favorite_filters() { - let names = vectorcraft_text::FontDb::global().menu_family_list(); + let names = lasting_families(); if names.len() < 3 { return; } @@ -176,7 +185,7 @@ fn recent_fonts_obey_search_and_favorite_filters() { #[test] fn opening_on_another_font_keeps_the_recent_section_visible() { - let names = vectorcraft_text::FontDb::global().menu_family_list(); + let names = lasting_families(); let Some(current) = names.last() else { return }; let recent = vec![names[0].clone()]; let ctx = egui::Context::default(); @@ -244,7 +253,7 @@ fn the_wheel_steps_one_family_per_notch_and_stops_at_the_ends() { /// others in the database's order, under the favourites (and other) filters. #[test] fn the_wheel_follows_the_menus_order_and_filters() { - let names = vectorcraft_text::FontDb::global().menu_family_list(); + let names = lasting_families(); if names.len() < 3 { return; } @@ -770,8 +779,9 @@ fn the_variable_filter_lists_only_variable_families() { assert_eq!(frame(&mut st, vec![], &favorites).0, [TWO_AXIS_FAMILY]); st.favorites_only = false; // Off again: every family is listed. + let lasting = lasting_families(); let again = click(&mut st, &favorites); assert!(!st.variable_only); - assert_eq!(again, frame(&mut st, vec![], &[]).0); + assert!(lasting.iter().all(|f| has(&again, f)), "{again:?}"); assert!(has(&again, TWO_AXIS_FAMILY) && has(&again, "Source Sans 3") && has(&again, "JetBrains Mono"), "{again:?}"); } diff --git a/crates/ui-egui/src/tests/nativemenu.rs b/crates/ui-egui/src/tests/nativemenu.rs index 65bd01999d..a5a8ea0d7a 100644 --- a/crates/ui-egui/src/tests/nativemenu.rs +++ b/crates/ui-egui/src/tests/nativemenu.rs @@ -69,6 +69,25 @@ fn find<'a>(bar: &'a MenuBar, command: &str) -> &'a native_menu::Item { bar.items().into_iter().find(|it| it.command == Some(command)).unwrap_or_else(|| panic!("no {command} item")) } +/// Is the font or plug-in that an item runs `command` with `params` for gone from the process-wide +/// font database or plug-in registry? A test running alongside can remove a composite font or a +/// plug-in, and the menus then drop its item. +fn source_gone(command: Option<&str>, params: &Value) -> bool { + use vectorcraft_plugins::{PREFIX, effect, export, registry}; + let field = |key| params.get(key).and_then(Value::as_str); + let plugin_gone = |id: Option<&str>| id.is_some_and(|id| registry::get(id).is_none()); + match command { + Some("text.setStyle") => field("font").is_some_and(|f| !vectorcraft_text::FontDb::global().menu_family_list_now().iter().any(|x| x == f)), + Some("plugin.dialog") => plugin_gone(field("id")), + Some("file.openWith") => plugin_gone(field("importer").and_then(export::plugin_id)), + Some("effect.apply" | "effect.dialog") => plugin_gone(field("effect").and_then(effect::plugin_id)), + Some(c) if vectorcraft_engine::cmd::plugin::is_command_id(c) => { + plugin_gone(c.strip_prefix(PREFIX).and_then(|rest| rest.rsplit_once('.')).map(|(id, _)| id)) + } + _ => false, + } +} + #[test] fn vectorcraft_gets_a_mac_app_menu() { let bar = bar(&app(true)); @@ -722,9 +741,13 @@ fn the_menu_bar_is_inert_while_a_file_dialog_is_open() { m.frame(vec![]); m.frame(vec![]); assert!(!m.app.file_dialog_open()); - // Every row from before is back as it was (tests running alongside may add plug-in rows). + // Every row from before is back as it was. Tests running alongside may add rows, and may + // remove the composite font or plug-in a row is for. let after = rows(&m.fake.last()); - assert!(rows(&before).iter().all(|r| after.contains(r)), "the menus are as before"); + for r in rows(&before) { + let listed = after.iter().any(|a| (a.0, &a.1) == (r.0, &r.1)); + assert!(after.contains(&r) || (!listed && source_gone(r.0, &r.1)), "{r:?} is not as before"); + } m.fake.report(native_menu::Event::Click(rulers.1)); m.frame(vec![]); assert_eq!(m.app.ui.view.rulers, !rulers.0); From 5a883996775979403374f8507eaa933ffe9a7c2e Mon Sep 17 00:00:00 2001 From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com> Date: Thu, 12 Nov 2026 17:30:51 +0430 Subject: [PATCH 08/11] Normalize Windows verbatim prefix in script path regression --- crates/engine/src/tests/script_io.rs | 2 +- docs/development.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/tests/script_io.rs b/crates/engine/src/tests/script_io.rs index 667635356c..e056ea2f7c 100644 --- a/crates/engine/src/tests/script_io.rs +++ b/crates/engine/src/tests/script_io.rs @@ -230,7 +230,7 @@ fn script_file_link_escape_refused() { /// not matter, and outside it where case matters, whether or not the file is there. #[test] fn script_file_case_variant_folder() { - let base = folder("case").canonicalize().unwrap(); + let base = crate::cmd::findfiles::plain(folder("case").canonicalize().unwrap()); write(&base.join("main/own.txt"), b"own"); let upper = base.join("MAIN"); let source = |name: &str| { diff --git a/docs/development.md b/docs/development.md index 07c0f65a77..79b10e1593 100644 --- a/docs/development.md +++ b/docs/development.md @@ -1,6 +1,6 @@ # Development -> **Last updated:** 2026-11-12 · **Change:** Tests canonicalize temporary roots before comparing preset refusal paths or probing a case-variant script folder (#6860); production access checks unchanged. +> **Last updated:** 2026-11-12 · **Change:** Tests canonicalize temporary roots and normalize Windows verbatim prefixes before comparing preset refusal paths or probing a case-variant script folder (#6860); production access checks unchanged. > **Last updated:** 2026-11-06 · **Change:** Hindi catalog and zero/one plural rule; desktop Devanagari fallback and optional craft-fonts UI face; native review pending. > **Last updated:** 2026-11-01 · **Change:** Linux: Automatic Linux Display Server opens the window through XWayland when a pen is attached on a Wayland session, so the pen draws (#4967); `VECTORCRAFT_NATIVE_WAYLAND=1` keeps Wayland. > **Last updated:** 2026-10-31 · **Change:** files opened while the app runs open in its window on Windows and Linux (#4892); `--new-instance`. From 1bf09bf72e680ae7b0f3a278a37c8917dbb022b8 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 05:24:22 -0800 Subject: [PATCH 09/11] SVG import: a of a text passes its text properties on to the text The element a shows inherits from the rather than from where it is defined (SVG 2, https://www.w3.org/TR/SVG2/struct.html#UseElement). A text shown by a now takes the font, size, weight, fill, stroke and other text properties the or its parents set, in clip paths too, while the text's own properties still win. Uses with the same parent and properties share one text. A inside the text it shows is ignored for inheritance, and texts built for elements stop at 1 MiB of text with a warning. --- crates/svg/src/import/text.rs | 299 +++++++++++++++++++++++++---- crates/svg/tests/it/clip_text.rs | 14 ++ crates/svg/tests/it/import_text.rs | 62 ++++++ 3 files changed, 338 insertions(+), 37 deletions(-) diff --git a/crates/svg/src/import/text.rs b/crates/svg/src/import/text.rs index 52b6663906..afd719163f 100644 --- a/crates/svg/src/import/text.rs +++ b/crates/svg/src/import/text.rs @@ -247,8 +247,9 @@ pub(super) fn prepare<'s>( && n.tag_name().namespace() == Some(SVG_NS) && !n.ancestors().any(|a| a.has_tag_name("style")) }; - // The slot of each text, by element, for the `` elements in clip paths below. + // The slot of each text, by element, for the `` elements below. let mut slot_of = HashMap::new(); + let mut inst = Instances::default(); for (t, style_t) in xml.descendants().filter(is_text).zip(style_xml.descendants().filter(is_text)) { let label = t.attribute("id").map_or_else(|| "a text".to_string(), |id| format!("text '{id}'")); // CSS Masking Module Level 1 §6.1 (https://www.w3.org/TR/css-masking-1/#ClipPathElement): @@ -275,20 +276,22 @@ pub(super) fn prepare<'s>( // A `` elsewhere can still show the text by its id: the clip path leaves out this // group (a group isn't clip path content), but the `` finds it. if t.attribute("id").is_some() { - placeholder(&mut out, q, &cx.css, t, style_t, &slots.prefix, index, text.marker, &paints); + placeholder(&mut out, q, &cx.css, t, style_t, &slots.prefix, index, text.marker, &paints, true); } } else { - placeholder(&mut out, q, &cx.css, t, style_t, &slots.prefix, index, text.marker, &paints); + placeholder(&mut out, q, &cx.css, t, style_t, &slots.prefix, index, text.marker, &paints, true); } copied = r.end; edits.push((r, out)); slot_of.insert(t.id(), index); slots.texts.push(text); + inst.paints.push(paints); } if slots.texts.is_empty() { return (Cow::Borrowed(svg), slots); } - clip_uses(svg, xml, style_xml, &cx, &slot_of, &mut slots, &mut edits, warnings); + clip_uses(svg, xml, style_xml, &cx, &slot_of, &mut slots, &mut inst, &mut edits, warnings); + text_uses(svg, xml, style_xml, &cx, &slot_of, &mut slots, &mut inst, &mut edits, warnings); edits.sort_by_key(|(r, _)| r.start); let mut out = String::with_capacity(svg.len()); let mut copied = 0; @@ -332,6 +335,7 @@ fn clip_uses( cx: &Ctx, slot_of: &HashMap, slots: &mut TextSlots, + inst: &mut Instances, edits: &mut Vec<(Range, String)>, warnings: &mut Vec, ) { @@ -343,18 +347,16 @@ fn clip_uses( for (k, (u, style_u)) in xml.descendants().filter(is_use).zip(style_xml.descendants().filter(is_use)).enumerate() { let Some(id) = href(u).and_then(|h| h.strip_prefix('#')) else { continue }; let Some(t) = ids.get(id) else { continue }; - let Some(&i) = slot_of.get(&t.id()) else { continue }; - let Some(marker) = slots.texts.get(i).map(|text| text.marker) else { continue }; + let Some(&shared) = slot_of.get(&t.id()) else { continue }; let Some(q) = qualifier(svg, u) else { continue }; // An undisplayed `` adds nothing to the clip (usvg leaves it out as it is). if cx.css.own(style_u, "display").is_some_and(|d| d.trim() == "none") { continue; } let style_t = style_ids.get(id).copied().filter(|s| s.tag_name() == t.tag_name()); - let em = cx.font_size(style_u); - let length = |a: &str, pct: f64| u.attribute(a).and_then(|v| cx.length(v, em, pct)).unwrap_or(0.0); - let (x, y) = (length("x", cx.viewport.0), length("y", cx.viewport.1)); - let placed = format!("{} translate({x} {y})", own_transform(&cx.css, style_u)); + let i = style_t.map_or(shared, |s| use_slot(cx, style_xml, slots, inst, shared, s, style_u, warnings)); + let Some(marker) = slots.texts.get(i).map(|text| text.marker) else { continue }; + let placed = use_placement(cx, u, style_u); let text_xf = match style_t { Some(s) => own_transform(&cx.css, s), None => t.attribute("transform").unwrap_or("").to_string(), @@ -386,6 +388,180 @@ fn clip_uses( } } +/// Replace each `` outside clip paths that shows a text it passes other properties on to by a +/// group holding that text's placeholder as the `` shows it ([`use_slot`]). Every other +/// `` of a text shows the text's own placeholder. +#[allow(clippy::too_many_arguments)] +fn text_uses( + svg: &str, + xml: &roxmltree::Document, + style_xml: &roxmltree::Document, + cx: &Ctx, + slot_of: &HashMap, + slots: &mut TextSlots, + inst: &mut Instances, + edits: &mut Vec<(Range, String)>, + warnings: &mut Vec, +) { + let is_use = |n: &XNode| { + n.is_element() + && n.tag_name().name() == "use" + && n.tag_name().namespace() == Some(SVG_NS) + && !n.ancestors().any(|a| a.is_element() && a.tag_name().name() == "clipPath") + }; + if !xml.descendants().any(|n| is_use(&n)) { + return; + } + let (ids, style_ids) = (first_ids(xml), first_ids(style_xml)); + for (u, style_u) in xml.descendants().filter(is_use).zip(style_xml.descendants().filter(is_use)) { + let Some(id) = href(u).and_then(|h| h.strip_prefix('#')) else { continue }; + let Some(t) = ids.get(id) else { continue }; + let Some(&shared) = slot_of.get(&t.id()) else { continue }; + let Some(style_t) = style_ids.get(id).copied().filter(|s| s.tag_name() == t.tag_name()) else { continue }; + let Some(q) = qualifier(svg, u) else { continue }; + let i = use_slot(cx, style_xml, slots, inst, shared, style_t, style_u, warnings); + let (Some(text), Some(paints)) = (slots.texts.get(i), inst.paints.get(i)) else { continue }; + if i == shared { + continue; + } + let mut out = String::new(); + use_group(&mut out, q, cx, u, style_u); + placeholder(&mut out, q, &cx.css, *t, style_t, &slots.prefix, i, text.marker, paints, false); + let _ = write!(out, ""); + edits.push((u.range(), out)); + } +} + +/// A text's slot, the parent of a `` showing it and the properties that `` sets itself. +type UseKey = (usize, Option, Vec<(String, String)>); + +/// The slots made for texts as `` elements show them ([`use_slot`]). +#[derive(Default)] +struct Instances { + /// The `url(#…)` paints of each slot, by index. + paints: Vec>, + /// The slot made for each [`UseKey`]; `None` where the text's own slot serves. + memo: HashMap>, + /// The nodes and text bytes of the texts built so far, up to [`MAX_INSTANCE_WORK`]. + work: usize, +} + +/// The most nodes and text bytes [`use_slot`] builds texts from, all `` elements together +/// (the file is untrusted). Past it, a `` shows its text with the text's own properties. +const MAX_INSTANCE_WORK: usize = 1 << 20; + +/// The slot of text `style_t` (slot `shared`) as `` `style_u` shows it. The text inherits +/// from the `` there ([`Ctx::instance`]): when that changes it, it gets a new slot; otherwise +/// the result is `shared`. `` elements with one parent that set the same properties share one. +#[allow(clippy::too_many_arguments)] +fn use_slot( + cx: &Ctx, + style_xml: &roxmltree::Document, + slots: &mut TextSlots, + inst: &mut Instances, + shared: usize, + style_t: XNode, + style_u: XNode, + warnings: &mut Vec, +) -> usize { + // A `` inside the text it shows would make the text inherit from itself. + if style_u.ancestors().any(|a| a == style_t) { + return shared; + } + let placing = |name: &str| matches!(name, "id" | "x" | "y" | "width" | "height" | "href" | "transform" | "style"); + let mut own: Vec<(String, String)> = style_u + .attributes() + .filter(|a| a.namespace().is_none() && !placing(a.name())) + .map(|a| (a.name().to_string(), a.value().to_string())) + .collect(); + own.extend(cx.css.declared(style_u).iter().filter(|(k, _)| k.as_str() != "transform").map(|(k, v)| (format!("style {k}"), v.clone()))); + own.sort(); + let key = (shared, style_u.parent_element().map(|p| p.id()), own); + if let Some(slot) = inst.memo.get(&key) { + return slot.unwrap_or(shared); + } + let label = style_t.attribute("id").map_or_else(|| "a text".to_string(), |id| format!("text '{id}'")); + let work = if inst.work < MAX_INSTANCE_WORK { + style_t.descendants().map(|n| if n.is_text() { n.text().map_or(0, str::len) } else { 1 }).sum::() + } else { + 0 + }; + inst.work = inst.work.saturating_add(work); + if work == 0 || inst.work > MAX_INSTANCE_WORK { + inst.work = MAX_INSTANCE_WORK; + let w = format!( + "more than {} KiB of text shown through elements: the rest show their text without the properties the passes on", + MAX_INSTANCE_WORK >> 10 + ); + if !warnings.contains(&w) { + warnings.push(w); + } + return shared; + } + let shown = cx.instance(style_t, style_u); + let mut found = vec![]; + let built = if style_t.tag_name().name() == "flowRoot" { + shown.build_flow(style_t, &label, &mut found) + } else { + shown.build(style_xml, style_t, &label, &mut found) + }; + let differs = |(text, paints): &(PendingText, Vec)| { + slots.texts.get(shared).is_none_or(|old| old.obj != text.obj || old.servers != text.servers || old.under != text.under) + || inst.paints.get(shared) != Some(paints) + }; + let slot = built.filter(differs).map(|(text, paints)| { + for w in found { + if !warnings.contains(&w) { + warnings.push(w); + } + } + slots.texts.push(text); + inst.paints.push(paints); + slots.texts.len() - 1 + }); + inst.memo.insert(key, slot); + slot.unwrap_or(shared) +} + +/// The transform `` `u` places what it shows with: its own, then its `x`/`y` offset (in its +/// font size). `style_u` supplies its styles. +fn use_placement(cx: &Ctx, u: XNode, style_u: XNode) -> String { + let em = cx.font_size(style_u); + let length = |a: &str, pct: f64| u.attribute(a).and_then(|v| cx.length(v, em, pct)).unwrap_or(0.0); + let (x, y) = (length("x", cx.viewport.0), length("y", cx.viewport.1)); + format!("{} translate({x} {y})", own_transform(&cx.css, style_u)) +} + +/// Write the start tag of the group that takes `` `u`'s place: its attributes in no namespace +/// other than its link, place and size, and as its `style` the properties rules and its `style` set +/// on it (from `style_u`), then its placement ([`use_placement`]) and its non-inherited properties +/// and `visibility`, so no rule that matches the group changes it. +fn use_group(out: &mut String, q: &str, cx: &Ctx, u: XNode, style_u: XNode) { + let _ = write!(out, "<{q}g"); + let placing = |name: &str| matches!(name, "style" | "href" | "x" | "y" | "width" | "height" | "transform"); + for a in u.attributes().filter(|a| a.namespace().is_none() && !placing(a.name())) { + let _ = write!(out, " {}=\"{}\"", a.name(), xml_escape(a.value())); + } + let declared = cx.css.declared(style_u); + let mut props: Vec<_> = declared.iter().filter(|(k, _)| k.as_str() != "transform").collect(); + props.sort(); + let mut style: String = props.into_iter().map(|(k, v)| format!("{k}:{v};")).collect(); + let _ = write!(style, "transform:{};", use_placement(cx, u, style_u)); + for (p, initial) in [ + ("opacity", "1"), + ("clip-path", "none"), + ("mask", "none"), + ("filter", "none"), + ("mix-blend-mode", "normal"), + ("isolation", "auto"), + ("display", "inline"), + ] { + let _ = write!(style, "{p}:{};", cx.css.own(style_u, p).as_deref().unwrap_or(initial)); + } + let _ = write!(style, "visibility:{}", cx.css.prop(style_u, "visibility").as_deref().unwrap_or("visible")); + let _ = write!(out, " style=\"{}\">", xml_escape(&style)); +} + /// Write a copy of `` `u` in a ``: its attributes in no namespace, its link as `href`, /// and the properties set on it by rules and its `style` (from `style_u`) as its `style`. fn use_copy(out: &mut String, q: &str, css: &Styles, u: XNode, style_u: XNode) { @@ -455,11 +631,23 @@ fn clip_placeholder(out: &mut String, q: &str, id: &str, transform: &str, props: let _ = write!(out, " d=\"M{} {}H{}V{}H{}Z\"/>", b.x0, b.y0, b.x1, b.y1, b.x0); } -/// Write the placeholder group for text `t` (namespace prefix `q`), slot `index`. +/// Write the placeholder group for text `t` (namespace prefix `q`), slot `index`, with the text's +/// id when `with_id`. #[allow(clippy::too_many_arguments)] -fn placeholder(out: &mut String, q: &str, css: &Styles, t: XNode, style_t: XNode, prefix: &str, index: usize, b: Rect, paints: &[String]) { +fn placeholder( + out: &mut String, + q: &str, + css: &Styles, + t: XNode, + style_t: XNode, + prefix: &str, + index: usize, + b: Rect, + paints: &[String], + with_id: bool, +) { let _ = write!(out, "<{q}g"); - for a in ["id", "systemLanguage", "requiredFeatures", "requiredExtensions"] { + for a in ["id", "systemLanguage", "requiredFeatures", "requiredExtensions"].into_iter().skip(usize::from(!with_id)) { if let Some(v) = t.attribute(a) { let _ = write!(out, " {a}=\"{}\"", xml_escape(v)); } @@ -488,11 +676,15 @@ fn placeholder(out: &mut String, q: &str, css: &Styles, t: XNode, style_t: XNode /// Computed text properties of the document's elements. struct Ctx { - css: Styles, + css: Rc, /// Pixels per inch absolute lengths convert at ([`super::RootUnits::dpi`]). dpi: f64, /// Viewport size in user units (the base of `x`/`y` percentages). viewport: (f64, f64), + /// For the styles of one `` instance: the element the `` shows and the ``. The + /// shown element inherits from the `` rather than from its parent (SVG 2 §5.6, + /// https://www.w3.org/TR/SVG2/struct.html#UseElement). + host: Option<(roxmltree::NodeId, roxmltree::NodeId)>, sizes: RefCell>, weights: RefCell>, elems: RefCell>>, @@ -501,9 +693,10 @@ struct Ctx { impl Ctx { fn new(xml: &roxmltree::Document, dpi: f64) -> Self { let mut cx = Self { - css: Styles::new(xml), + css: Rc::new(Styles::new(xml)), dpi, viewport: (100.0, 100.0), + host: None, sizes: RefCell::default(), weights: RefCell::default(), elems: RefCell::default(), @@ -517,6 +710,37 @@ impl Ctx { cx } + /// The styles of element `shown` as `` `u` shows it. + fn instance(&self, shown: XNode, u: XNode) -> Self { + Self { + css: self.css.clone(), + dpi: self.dpi, + viewport: self.viewport, + host: Some((shown.id(), u.id())), + sizes: RefCell::default(), + weights: RefCell::default(), + elems: RefCell::default(), + } + } + + /// The element `n` inherits from: its parent, or the `` for the element it shows. + fn parent<'a, 'i>(&self, n: XNode<'a, 'i>) -> Option> { + match self.host { + Some((shown, u)) if n.id() == shown => n.document().get_node(u), + _ => n.parent_element(), + } + } + + /// Element `n` and the elements it inherits from, nearest first. + fn lineage<'a, 'i>(&self, n: XNode<'a, 'i>) -> impl Iterator> { + std::iter::successors(Some(n), |a| self.parent(*a)) + } + + /// An inherited property: the nearest specified value other than `inherit`. + fn prop(&self, n: XNode, name: &str) -> Option { + self.lineage(n).find_map(|a| self.css.own(a, name).filter(|v| v != "inherit")) + } + /// A length in user units: absolute units convert at [`Self::dpi`], `em` and `ex` are relative /// to `em` (the font size) and percentages to `pct`. fn length(&self, v: &str, em: f64, pct: f64) -> Option { @@ -559,7 +783,7 @@ impl Ctx { if let Some(s) = self.sizes.borrow().get(&n.id()) { return *s; } - let parent = n.parent_element().map_or(DEFAULT_FONT_SIZE, |p| self.font_size(p)); + let parent = self.parent(n).map_or(DEFAULT_FONT_SIZE, |p| self.font_size(p)); let s = self.css.own(n, "font-size").and_then(|v| self.parse_font_size(&v, parent)).unwrap_or(parent); self.sizes.borrow_mut().insert(n.id(), s); s @@ -570,7 +794,7 @@ impl Ctx { if let Some(w) = self.weights.borrow().get(&n.id()) { return *w; } - let parent = n.parent_element().map_or(400, |p| self.weight(p)); + let parent = self.parent(n).map_or(400, |p| self.weight(p)); let w = match self.css.own(n, "font-weight").as_deref() { Some("normal") => 400, Some("bold") => 700, @@ -594,12 +818,12 @@ impl Ctx { /// A fill or stroke: a ready paint, or a `url(#…)` paint for usvg to resolve. fn paint(&self, n: XNode, prop: &str) -> (Paint, Option) { let initial = if prop == "fill" { Paint::solid(Color::BLACK) } else { Paint::None }; - let Some(v) = self.css.prop(n, prop) else { return (initial, None) }; + let Some(v) = self.prop(n, prop) else { return (initial, None) }; match svgtypes::Paint::from_str(&v) { Ok(svgtypes::Paint::None) => (Paint::None, None), Ok(svgtypes::Paint::Color(c)) => (solid(c), None), Ok(svgtypes::Paint::CurrentColor) => { - (self.css.prop(n, "color").and_then(|c| svgtypes::Color::from_str(&c).ok()).map_or(Paint::solid(Color::BLACK), solid), None) + (self.prop(n, "color").and_then(|c| svgtypes::Color::from_str(&c).ok()).map_or(Paint::solid(Color::BLACK), solid), None) } Ok(svgtypes::Paint::FuncIRI(..)) => (Paint::None, Some(v)), _ => (initial, None), @@ -613,7 +837,7 @@ impl Ctx { } let css = &self.css; let mut st = CharStyle::default(); - if let Some(f) = css.prop(n, "font-family") { + if let Some(f) = self.prop(n, "font-family") { let fam = f.split(',').next().unwrap_or("").trim().trim_matches(|c| c == '\'' || c == '"').to_string(); if !fam.is_empty() { st.font_family = fam; @@ -621,45 +845,46 @@ impl Ctx { } let size = self.font_size(n); st.size = size; - let italic = css.prop(n, "font-style").is_some_and(|s| s == "italic" || s == "oblique"); + let italic = self.prop(n, "font-style").is_some_and(|s| s == "italic" || s == "oblique"); st.font_style = style_name(self.weight(n), italic); // Inherited: `inherit`, `unset`, `revert` and a value CSS ignores leave the one inherited. let inherits = |v: &String| ["inherit", "unset", "revert", "revert-layer"].iter().any(|k| v.trim().eq_ignore_ascii_case(k)); let own = |a: XNode| css.own(a, "font-variation-settings").filter(|v| !inherits(v)).and_then(|v| variation_settings(&v)); - st.font_axes = n.ancestors().filter(|a| a.is_element()).find_map(own).unwrap_or_default(); + st.font_axes = self.lineage(n).find_map(own).unwrap_or_default(); let (fill, mut fill_url) = self.paint(n, "fill"); st.fill = fill; let (stroke, mut stroke_url) = self.paint(n, "stroke"); if !stroke.is_none() || stroke_url.is_some() { st.stroke = stroke; - st.stroke_width = css.prop(n, "stroke-width").and_then(|w| self.length(&w, size, size)).unwrap_or(1.0); - st.stroke_cap = match css.prop(n, "stroke-linecap").as_deref() { + st.stroke_width = self.prop(n, "stroke-width").and_then(|w| self.length(&w, size, size)).unwrap_or(1.0); + st.stroke_cap = match self.prop(n, "stroke-linecap").as_deref() { Some("round") => LineCap::Round, Some("square") => LineCap::Square, _ => LineCap::Butt, }; - st.stroke_join = match css.prop(n, "stroke-linejoin").as_deref() { + st.stroke_join = match self.prop(n, "stroke-linejoin").as_deref() { Some("round") => LineJoin::Round, Some("bevel") => LineJoin::Bevel, _ => LineJoin::Miter, }; st.stroke_miter_limit = - css.prop(n, "stroke-miterlimit").and_then(|m| m.trim().parse::().ok()).filter(|m| m.is_finite()).map_or(4.0, |m| m.max(1.0)); - let pattern: Vec = css.prop(n, "stroke-dasharray").map_or(vec![], |d| list(&d).filter_map(|v| self.length(v, size, size)).collect()); - let offset = css.prop(n, "stroke-dashoffset").and_then(|o| self.length(&o, size, size)).unwrap_or(0.0); + self.prop(n, "stroke-miterlimit").and_then(|m| m.trim().parse::().ok()).filter(|m| m.is_finite()).map_or(4.0, |m| m.max(1.0)); + let pattern: Vec = + self.prop(n, "stroke-dasharray").map_or(vec![], |d| list(&d).filter_map(|v| self.length(v, size, size)).collect()); + let offset = self.prop(n, "stroke-dashoffset").and_then(|o| self.length(&o, size, size)).unwrap_or(0.0); st.stroke_dash = Some(Dash { pattern, offset, align_corners: false }).filter(Dash::is_dashed); } - if let Some(v) = css.prop(n, "letter-spacing").and_then(|v| self.length(&v, size, size)) + if let Some(v) = self.prop(n, "letter-spacing").and_then(|v| self.length(&v, size, size)) && size > 0.0 { st.tracking = v / size * 1000.0; } - if let Some(v) = css.prop(n, "kerning").and_then(|v| self.length(&v, size, size)) + if let Some(v) = self.prop(n, "kerning").and_then(|v| self.length(&v, size, size)) && size > 0.0 { st.kerning = Some(v / size * 1000.0); } - if let Some(d) = css.prop(n, "text-decoration") { + if let Some(d) = self.prop(n, "text-decoration") { st.underline = d.contains("underline"); st.strikethrough = d.contains("line-through"); } @@ -672,11 +897,11 @@ impl Ctx { }; let parent = if n.tag_name().name() == "text" { 0.0 } else { n.parent_element().map_or(0.0, |p| self.elem(p).style.baseline_shift) }; st.baseline_shift = parent + own; - if css.prop(n, "visibility").is_some_and(|v| v == "hidden" || v == "collapse") { + if self.prop(n, "visibility").is_some_and(|v| v == "hidden" || v == "collapse") { (st.fill, st.stroke, fill_url, stroke_url) = (Paint::None, Paint::None, None, None); } - let word_spacing = css.prop(n, "word-spacing").and_then(|v| self.length(&v, size, size)).unwrap_or(0.0); - let order = css.prop(n, "paint-order").and_then(|v| svgtypes::PaintOrder::from_str(&v).ok()).unwrap_or_default().order; + let word_spacing = self.prop(n, "word-spacing").and_then(|v| self.length(&v, size, size)).unwrap_or(0.0); + let order = self.prop(n, "paint-order").and_then(|v| svgtypes::PaintOrder::from_str(&v).ok()).unwrap_or_default().order; let at = |k| order.iter().position(|o| *o == k); let stroke_first = at(svgtypes::PaintOrderKind::Stroke) < at(svgtypes::PaintOrderKind::Fill); let e = Rc::new(ElemStyle { style: st, fill: fill_url, stroke: stroke_url, word_spacing, stroke_first }); @@ -974,7 +1199,7 @@ impl Ctx { .collect(); let under = stroke_under(&mut cells, label, warnings); - let anchor_of = |i: usize| match cs.chars.get(i).and_then(|c| self.css.prop(c.1, "text-anchor")).as_deref() { + let anchor_of = |i: usize| match cs.chars.get(i).and_then(|c| self.prop(c.1, "text-anchor")).as_deref() { Some("middle") => 0.5, Some("end") => 1.0, _ => 0.0, @@ -987,7 +1212,7 @@ impl Ctx { None } }); - let vertical = self.css.prop(t, "writing-mode").is_some_and(|m| m.starts_with("tb") || m.starts_with("vertical")); + let vertical = self.prop(t, "writing-mode").is_some_and(|m| m.starts_with("tb") || m.starts_with("vertical")); self.fit_lengths(&cs.spans, &mut cells, vertical, label, warnings); let mut obj = TextObject::point(Point::ZERO, "", CharStyle::default()); let mut breaks: Vec = vec![]; @@ -1059,7 +1284,7 @@ impl Ctx { } // SVG text runs left to right unless `direction: rtl` says otherwise, whatever its first // strong character: pinned (on every paragraph) when that would read as right to left. - if self.css.prop(t, "direction").is_some_and(|d| d.trim() == "rtl") { + if self.prop(t, "direction").is_some_and(|d| d.trim() == "rtl") { obj.edit_paras(None, |pa| pa.direction = Some(ParaDirection::RightToLeft)); } else if obj.plain_text().split('\n').any(|p| vectorcraft_text::paragraph_is_rtl(p, None)) { obj.edit_paras(None, |pa| pa.direction = Some(ParaDirection::LeftToRight)); diff --git a/crates/svg/tests/it/clip_text.rs b/crates/svg/tests/it/clip_text.rs index 07773dd5f8..a2dcb46156 100644 --- a/crates/svg/tests/it/clip_text.rs +++ b/crates/svg/tests/it/clip_text.rs @@ -183,6 +183,20 @@ fn a_use_of_a_text_in_a_clip_path_clips_where_the_use_places_it() { assert!(glyphs.x0 >= 200.0 - 1e-6 && glyphs.y1 <= 100.0 + 1e-6 && glyphs.y0 > 40.0, "{glyphs:?}"); } +#[test] +fn a_use_of_a_text_in_a_clip_path_passes_its_properties_on_to_the_text() { + let d = open( + r##"HI + + "##, + ); + let (clip, _) = clip_group(&top(&d)[0]); + let NodeKind::Text(t) = &clip.kind else { panic!("{:?}", clip.kind) }; + assert!(near(t.runs[0].style.size, 100.0, 1e-9), "{}", t.runs[0].style.size); + assert_eq!(t.runs[0].style.font_style, "Bold"); + assert!(clip_bounds(clip).height() > 50.0, "{:?}", clip_bounds(clip)); +} + #[test] fn a_clip_path_on_a_use_of_a_text_is_in_the_use_s_user_space() { // The text's transform moves its glyphs to x 200…; the ``'s clip keeps x < 250 of its own diff --git a/crates/svg/tests/it/import_text.rs b/crates/svg/tests/it/import_text.rs index 8b0d0338c6..87fe6cf340 100644 --- a/crates/svg/tests/it/import_text.rs +++ b/crates/svg/tests/it/import_text.rs @@ -191,6 +191,68 @@ fn hidden_text_is_skipped_and_use_instances_are_kept() { assert!(t[1].xf.translation().to_point().distance(Point::new(50.0, 30.0)) < 1e-9); } +/// SVG 2 §5.6 (https://www.w3.org/TR/SVG2/struct.html#UseElement): the element a `` shows +/// inherits from the ``, not from where it is defined. +#[test] +fn a_use_of_a_text_passes_its_properties_on_to_the_text() { + let d = open( + r##"HI + + "##, + ); + let t = texts(&d); + assert_eq!(t.len(), 3, "{t:?}"); + let s: Vec = t.iter().map(|t| t.first_style()).collect(); + assert!(close(s[0].size, 100.0, 1e-9), "{}", s[0].size); + assert_eq!(s[0].fill, Paint::solid(vectorcraft_color::Color::rgb8(255, 0, 0))); + assert!(close(s[1].size, 12.0, 1e-9), "the defaults, not the 's 30: {}", s[1].size); + assert_eq!(s[1].font_style, "Bold"); + assert_eq!(s[1].fill, Paint::solid(vectorcraft_color::Color::BLACK)); + assert!(close(s[2].size, 20.0, 1e-9), "{}", s[2].size); + assert!(t[0].xf.translation().to_point().distance(Point::new(0.0, 50.0)) < 1e-9, "{:?}", t[0].xf); + assert!(t[1].xf.translation().to_point().distance(Point::new(200.0, 50.0)) < 1e-9, "{:?}", t[1].xf); + assert!(t[2].xf.translation().to_point().distance(Point::new(0.0, 250.0)) < 1e-9, "{:?}", t[2].xf); + // The ``'s opacity stays on the object it becomes. + let faded: Vec<&Node> = art(&d).into_iter().filter(|n| (n.opacity - 0.5).abs() < 1e-6).collect(); + assert_eq!(faded.len(), 1, "{faded:?}"); + // The text's own properties win over the ``'s. + let d = open(r##"HI"##); + let s = only_text(&d).first_style(); + assert!(close(s.size, 20.0, 1e-9), "{}", s.size); + assert_eq!(s.fill, Paint::solid(vectorcraft_color::Color::rgb8(0, 0, 255))); +} + +/// A `` inside the text it shows does not make the text inherit from itself. +#[test] +fn a_use_inside_the_text_it_shows_opens() { + let d = open(r##"HI"##); + assert!(texts(&d).iter().all(|t| close(t.first_style().size, 20.0, 1e-9)), "{:?}", texts(&d)); +} + +/// Past 1 MiB of text built for `` elements, the rest show their text with its own properties. +#[test] +fn uses_of_a_long_text_stop_taking_their_properties_past_the_limit() { + let long = "a".repeat(350_000); + let uses: String = (1..=4).map(|i| format!(r##""##, i * 10)).collect(); + let (d, w) = import_with_report(&svg(&format!(r##"{long}{uses}"##))).unwrap(); + let sizes: Vec = texts(&d).iter().map(|t| t.first_style().size).collect(); + assert_eq!(sizes.len(), 4, "{sizes:?}"); + assert!(close(sizes[0], 10.0, 1e-9) && close(sizes[1], 20.0, 1e-9), "{sizes:?}"); + assert!(close(sizes[2], 12.0, 1e-9) && close(sizes[3], 12.0, 1e-9), "{sizes:?}"); + assert_eq!(w.iter().filter(|w| w.contains("KiB of text shown through elements")).count(), 1, "{w:?}"); +} + +/// Many `` elements of one text that inherit the same properties each get it. +#[test] +fn many_uses_of_a_text_in_one_group_all_take_its_properties() { + let uses: String = (0..200).map(|i| format!(r##""##)).collect(); + let d = open(&format!(r##"HI{uses}"##)); + let t = texts(&d); + assert_eq!(t.len(), 200); + assert!(t.iter().all(|t| close(t.first_style().size, 40.0, 1e-9))); + assert!(t[199].xf.translation().to_point().distance(Point::new(199.0, 50.0)) < 1e-9, "{:?}", t[199].xf); +} + #[test] fn text_path_becomes_type_on_a_path() { let d = open( From f317aefa442a8f17c13909b4c6204089dbfab3b7 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 05:32:35 -0800 Subject: [PATCH 10/11] Tests: the file-dialog menu test reruns while fonts or plug-ins change `the_menu_bar_is_inert_while_a_file_dialog_is_open` excused a missing menu row when the composite font or plug-in it named was gone at the time of the check. A test running alongside can remove such a font and define it again between the menu read and the check, and the test then failed. It now runs inside `undisturbed`, as the file's other tests that compare menu reads do. When the shortcut, plug-in or font generation changes during a run, the test runs again on a new app. Every row from before the dialog must then be back as it was. --- crates/ui-egui/src/tests/nativemenu.rs | 108 ++++++++++--------------- 1 file changed, 44 insertions(+), 64 deletions(-) diff --git a/crates/ui-egui/src/tests/nativemenu.rs b/crates/ui-egui/src/tests/nativemenu.rs index a5a8ea0d7a..689ae8a60c 100644 --- a/crates/ui-egui/src/tests/nativemenu.rs +++ b/crates/ui-egui/src/tests/nativemenu.rs @@ -69,25 +69,6 @@ fn find<'a>(bar: &'a MenuBar, command: &str) -> &'a native_menu::Item { bar.items().into_iter().find(|it| it.command == Some(command)).unwrap_or_else(|| panic!("no {command} item")) } -/// Is the font or plug-in that an item runs `command` with `params` for gone from the process-wide -/// font database or plug-in registry? A test running alongside can remove a composite font or a -/// plug-in, and the menus then drop its item. -fn source_gone(command: Option<&str>, params: &Value) -> bool { - use vectorcraft_plugins::{PREFIX, effect, export, registry}; - let field = |key| params.get(key).and_then(Value::as_str); - let plugin_gone = |id: Option<&str>| id.is_some_and(|id| registry::get(id).is_none()); - match command { - Some("text.setStyle") => field("font").is_some_and(|f| !vectorcraft_text::FontDb::global().menu_family_list_now().iter().any(|x| x == f)), - Some("plugin.dialog") => plugin_gone(field("id")), - Some("file.openWith") => plugin_gone(field("importer").and_then(export::plugin_id)), - Some("effect.apply" | "effect.dialog") => plugin_gone(field("effect").and_then(effect::plugin_id)), - Some(c) if vectorcraft_engine::cmd::plugin::is_command_id(c) => { - plugin_gone(c.strip_prefix(PREFIX).and_then(|rest| rest.rsplit_once('.')).map(|(id, _)| id)) - } - _ => false, - } -} - #[test] fn vectorcraft_gets_a_mac_app_menu() { let bar = bar(&app(true)); @@ -702,52 +683,51 @@ fn the_app_bar_hides_its_menus_with_the_mac_menu_bar() { /// answers, the menus are as before and work again. #[test] fn the_menu_bar_is_inert_while_a_file_dialog_is_open() { - let mut m = Mac::new(true); - m.app.services.dialogs_are_sheets = true; - let (shown, _) = crate::tests_picks::off_the_ui_thread(&mut m.app.services); - m.app.run("shape.rectangle", json!({"x": 200, "y": 150, "width": 20, "height": 20})).unwrap(); - m.app.run("select.none", json!({})).unwrap(); - m.frame(vec![]); let rows = |bar: &MenuBar| bar.items().iter().map(|it| (it.command, it.params.clone(), it.enabled)).collect::>(); - let before = m.fake.last(); - let system = [native_menu::HIDE, native_menu::HIDE_OTHERS, native_menu::MINIMIZE]; - assert!(system.iter().all(|c| find(&before, c).enabled)); - assert!(m.app.run("file.open", json!({})).is_err()); - assert!(m.app.file_dialog_open()); - m.frame(vec![]); - let inert = m.fake.last(); - for it in inert.items() { - let kept = it.command.is_some_and(|c| system.contains(&c)); - assert_eq!(it.enabled, kept, "{:?} {} while the dialog is open", it.command, it.label); - } - // A click that raced the menu going inert isn't run; Minimize is. - let rulers = (m.app.ui.view.rulers, find(&before, "view.rulers").clone()); - m.fake.report(native_menu::Event::Click(rulers.1.clone())); - m.frame(vec![]); - assert_eq!(m.app.ui.view.rulers, rulers.0); - m.fake.report(native_menu::Event::Click(find(&inert, native_menu::MINIMIZE).clone())); - assert!(m.frame(vec![]).viewport_output[&egui::ViewportId::ROOT].commands.contains(&egui::ViewportCommand::Minimized(true))); - // A key equivalent that raced it doesn't reach egui. - let mut k = crate::shortcuts::parse("Cmd+A").unwrap(); - k.modifiers.mac_cmd = k.modifiers.command; - m.fake.report(native_menu::Event::Key(k)); - let mut raw = egui::RawInput::default(); - m.app.raw_input_hook(&m.ctx, &mut raw); - assert!(!raw.events.iter().any(|e| matches!(e, Event::Key { .. })), "{:?}", raw.events); - m.key_equivalent("Cmd+A"); - assert_eq!(m.selected(), 0, "⌘A selected nothing"); - // Cancelled: the menus are as before, and work again. - shown.answer(&[]); - m.frame(vec![]); - m.frame(vec![]); - assert!(!m.app.file_dialog_open()); - // Every row from before is back as it was. Tests running alongside may add rows, and may - // remove the composite font or plug-in a row is for. - let after = rows(&m.fake.last()); - for r in rows(&before) { - let listed = after.iter().any(|a| (a.0, &a.1) == (r.0, &r.1)); - assert!(after.contains(&r) || (!listed && source_gone(r.0, &r.1)), "{r:?} is not as before"); - } + // The menus read before the dialog and after it are compared, so the whole run is undisturbed: + // a test running alongside can add or remove a composite font or a plug-in in between. + let (mut m, (before, after, rulers)) = undisturbed(|m| { + m.app.services.dialogs_are_sheets = true; + let (shown, _) = crate::tests_picks::off_the_ui_thread(&mut m.app.services); + m.app.run("shape.rectangle", json!({"x": 200, "y": 150, "width": 20, "height": 20})).unwrap(); + m.app.run("select.none", json!({})).unwrap(); + m.frame(vec![]); + let before = m.fake.last(); + let system = [native_menu::HIDE, native_menu::HIDE_OTHERS, native_menu::MINIMIZE]; + assert!(system.iter().all(|c| find(&before, c).enabled)); + assert!(m.app.run("file.open", json!({})).is_err()); + assert!(m.app.file_dialog_open()); + m.frame(vec![]); + let inert = m.fake.last(); + for it in inert.items() { + let kept = it.command.is_some_and(|c| system.contains(&c)); + assert_eq!(it.enabled, kept, "{:?} {} while the dialog is open", it.command, it.label); + } + // A click that raced the menu going inert isn't run; Minimize is. + let rulers = (m.app.ui.view.rulers, find(&before, "view.rulers").clone()); + m.fake.report(native_menu::Event::Click(rulers.1.clone())); + m.frame(vec![]); + assert_eq!(m.app.ui.view.rulers, rulers.0); + m.fake.report(native_menu::Event::Click(find(&inert, native_menu::MINIMIZE).clone())); + assert!(m.frame(vec![]).viewport_output[&egui::ViewportId::ROOT].commands.contains(&egui::ViewportCommand::Minimized(true))); + // A key equivalent that raced it doesn't reach egui. + let mut k = crate::shortcuts::parse("Cmd+A").unwrap(); + k.modifiers.mac_cmd = k.modifiers.command; + m.fake.report(native_menu::Event::Key(k)); + let mut raw = egui::RawInput::default(); + m.app.raw_input_hook(&m.ctx, &mut raw); + assert!(!raw.events.iter().any(|e| matches!(e, Event::Key { .. })), "{:?}", raw.events); + m.key_equivalent("Cmd+A"); + assert_eq!(m.selected(), 0, "⌘A selected nothing"); + // Cancelled: the menus are as before, and work again. + shown.answer(&[]); + m.frame(vec![]); + m.frame(vec![]); + assert!(!m.app.file_dialog_open()); + (rows(&before), rows(&m.fake.last()), rulers) + }); + // Every row from before is back as it was. + assert!(before.iter().all(|r| after.contains(r)), "the menus are as before"); m.fake.report(native_menu::Event::Click(rulers.1)); m.frame(vec![]); assert_eq!(m.app.ui.view.rulers, !rulers.0); From 909f0ff301802754737138f3442d8cd2502e7974 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 05:43:42 -0800 Subject: [PATCH 11/11] SVG import: the path a text follows counts toward the text limit A text on a path that many elements show was laid out along the whole path once per , and the path was found in the file and parsed again each time, while the 1 MiB limit on texts built for elements counted only the text's nodes and characters. A file with a short text on a very long path and many elements of it could keep the importer busy far past that limit. - The elements of the path a follows now count toward the limit. Past it, the remaining elements show the text with its own properties, and the warning names text paths as well as text. - The path of each is found and parsed once and shared by every that shows its text. Files within the limit import as before. --- crates/svg/src/import/text.rs | 54 +++++++++++++++++++++++++---- crates/svg/src/import/text/tests.rs | 24 +++++++++++++ crates/svg/tests/it/import_text.rs | 23 +++++++++++- 3 files changed, 93 insertions(+), 8 deletions(-) create mode 100644 crates/svg/src/import/text/tests.rs diff --git a/crates/svg/src/import/text.rs b/crates/svg/src/import/text.rs index afd719163f..51bcffa41e 100644 --- a/crates/svg/src/import/text.rs +++ b/crates/svg/src/import/text.rs @@ -442,12 +442,14 @@ struct Instances { paints: Vec>, /// The slot made for each [`UseKey`]; `None` where the text's own slot serves. memo: HashMap>, - /// The nodes and text bytes of the texts built so far, up to [`MAX_INSTANCE_WORK`]. + /// The nodes, text bytes and text path elements of the texts built so far, up to + /// [`MAX_INSTANCE_WORK`]. work: usize, } -/// The most nodes and text bytes [`use_slot`] builds texts from, all `` elements together -/// (the file is untrusted). Past it, a `` shows its text with the text's own properties. +/// The most nodes, text bytes and elements of the paths ``s follow that [`use_slot`] +/// builds texts from, all `` elements together (the file is untrusted). Past it, a `` +/// shows its text with the text's own properties. const MAX_INSTANCE_WORK: usize = 1 << 20; /// The slot of text `style_t` (slot `shared`) as `` `style_u` shows it. The text inherits @@ -482,7 +484,11 @@ fn use_slot( } let label = style_t.attribute("id").map_or_else(|| "a text".to_string(), |id| format!("text '{id}'")); let work = if inst.work < MAX_INSTANCE_WORK { - style_t.descendants().map(|n| if n.is_text() { n.text().map_or(0, str::len) } else { 1 }).sum::() + // Each build lays the text out along the path its `` follows, so the path counts too. + style_t + .descendants() + .map(|n| if n.is_text() { n.text().map_or(0, str::len) } else { cx.path_elements(n).saturating_add(1) }) + .fold(0, usize::saturating_add) } else { 0 }; @@ -490,7 +496,7 @@ fn use_slot( if work == 0 || inst.work > MAX_INSTANCE_WORK { inst.work = MAX_INSTANCE_WORK; let w = format!( - "more than {} KiB of text shown through elements: the rest show their text without the properties the passes on", + "more than {} KiB of text and text paths shown through elements: the rest show their text without the properties the passes on", MAX_INSTANCE_WORK >> 10 ); if !warnings.contains(&w) { @@ -688,6 +694,16 @@ struct Ctx { sizes: RefCell>, weights: RefCell>, elems: RefCell>>, + /// The path each `` read so far follows ([`Self::text_path`]); `None` where it has + /// no usable one. The styles of a `` instance share it, so each path is found and parsed + /// once however many `` elements show its text. + paths: Rc>>>>, +} + +#[cfg(test)] +thread_local! { + /// How many times [`Ctx::read_text_path`] has read a ``'s path on this thread. + static TEXT_PATH_READS: std::cell::Cell = const { std::cell::Cell::new(0) }; } impl Ctx { @@ -700,6 +716,7 @@ impl Ctx { sizes: RefCell::default(), weights: RefCell::default(), elems: RefCell::default(), + paths: Rc::default(), }; let root = xml.root_element(); let dim = |a: &str| root.attribute(a).and_then(|v| cx.length(v, DEFAULT_FONT_SIZE, 0.0)).filter(|v| *v > 0.0); @@ -720,6 +737,7 @@ impl Ctx { sizes: RefCell::default(), weights: RefCell::default(), elems: RefCell::default(), + paths: self.paths.clone(), } } @@ -986,8 +1004,27 @@ impl Ctx { } } - /// The path a `` follows, in the text's user space. - fn text_path(&self, xml: &roxmltree::Document, tp: XNode) -> Option { + /// The path a `` follows, in the text's user space. It is read from the file once + /// ([`Self::paths`]). + fn text_path(&self, xml: &roxmltree::Document, tp: XNode) -> Option> { + if let Some(known) = self.paths.borrow().get(&tp.id()) { + return known.clone(); + } + let path = self.read_text_path(xml, tp).map(Rc::new); + self.paths.borrow_mut().insert(tp.id(), path.clone()); + path + } + + /// How many elements the path of `` `tp` has, when a text built so far follows it + /// ([`Self::text_path`]); 0 for any other node. + fn path_elements(&self, tp: XNode) -> usize { + self.paths.borrow().get(&tp.id()).and_then(|p| p.as_deref()).map_or(0, |p| p.elements().len()) + } + + /// Find and parse the path `` `tp` follows. + fn read_text_path(&self, xml: &roxmltree::Document, tp: XNode) -> Option { + #[cfg(test)] + TEXT_PATH_READS.set(TEXT_PATH_READS.get() + 1); let mut bp = match tp.attribute("path") { Some(d) => BezPath::from_svg(d).ok()?, None => { @@ -1625,3 +1662,6 @@ fn assemble(cells: &[Cell], breaks: &[Break]) -> (Runs, Vec) { } (out, bytes) } + +#[cfg(test)] +mod tests; diff --git a/crates/svg/src/import/text/tests.rs b/crates/svg/src/import/text/tests.rs new file mode 100644 index 0000000000..1508997dcc --- /dev/null +++ b/crates/svg/src/import/text/tests.rs @@ -0,0 +1,24 @@ +use super::*; + +/// The texts [`prepare`] builds from `body` and how many times it read a ``'s path. +fn prepared(body: &str) -> (usize, u64, Vec) { + let svg = format!(r#"{body}"#); + let xml = roxmltree::Document::parse(&svg).unwrap(); + let mut warnings = vec![]; + let before = TEXT_PATH_READS.get(); + let (_, slots) = prepare(&svg, &xml, None, 96.0, &mut warnings); + (slots.texts.len(), TEXT_PATH_READS.get() - before, warnings) +} + +/// Every `` of a text on a path follows the path the text's own build read. +#[test] +fn uses_of_a_text_on_a_path_read_the_path_once() { + let uses: String = (101..=150).map(|i| format!(r##""##)).collect(); + let (texts, reads, warnings) = + prepared(&format!(r##"Path{uses}"##)); + assert_eq!(texts, 51, "the text and one per : {warnings:?}"); + assert_eq!(reads, 1); + // A path with no usable data is not read again either. + let (texts, reads, _) = prepared(&format!(r##"Path{uses}"##)); + assert_eq!((texts, reads), (51, 1)); +} diff --git a/crates/svg/tests/it/import_text.rs b/crates/svg/tests/it/import_text.rs index 87fe6cf340..b25dc23678 100644 --- a/crates/svg/tests/it/import_text.rs +++ b/crates/svg/tests/it/import_text.rs @@ -239,7 +239,28 @@ fn uses_of_a_long_text_stop_taking_their_properties_past_the_limit() { assert_eq!(sizes.len(), 4, "{sizes:?}"); assert!(close(sizes[0], 10.0, 1e-9) && close(sizes[1], 20.0, 1e-9), "{sizes:?}"); assert!(close(sizes[2], 12.0, 1e-9) && close(sizes[3], 12.0, 1e-9), "{sizes:?}"); - assert_eq!(w.iter().filter(|w| w.contains("KiB of text shown through elements")).count(), 1, "{w:?}"); + assert_eq!(w.iter().filter(|w| w.contains("KiB of text and text paths shown through elements")).count(), 1, "{w:?}"); +} + +/// The path a text follows counts toward that limit: each `` of the text lays it out along +/// the whole path again. +#[test] +fn uses_of_a_text_on_a_long_path_stop_taking_their_properties_past_the_limit() { + let d = format!("M0 0{}", "h1".repeat(350_000)); + let uses: String = (1..=4).map(|i| format!(r##""##, i * 10)).collect(); + let body = format!(r##"HI{uses}"##); + let (d, w) = import_with_report(&svg(&body)).unwrap(); + let sizes: Vec = texts(&d).iter().map(|t| t.first_style().size).collect(); + assert_eq!(sizes.len(), 4, "{sizes:?}"); + assert!(close(sizes[0], 10.0, 1e-9) && close(sizes[1], 20.0, 1e-9), "{sizes:?}"); + assert!(close(sizes[2], 12.0, 1e-9) && close(sizes[3], 12.0, 1e-9), "{sizes:?}"); + assert_eq!(w.iter().filter(|w| w.contains("KiB of text and text paths shown through elements")).count(), 1, "{w:?}"); + // The same text on a short path is within the limit however many `` elements show it. + let uses: String = (1..=300).map(|i| format!(r##""##)).collect(); + let body = format!(r##"HI{uses}"##); + let (d, w) = import_with_report(&svg(&body)).unwrap(); + assert!(texts(&d).iter().enumerate().all(|(i, t)| close(t.first_style().size, (i + 1) as f64, 1e-9)), "{w:?}"); + assert!(!w.iter().any(|w| w.contains("shown through elements")), "{w:?}"); } /// Many `` elements of one text that inherit the same properties each get it.