From 4f295e5fee8f98423720cf9b7cf370045c09d8ae Mon Sep 17 00:00:00 2001 From: ruchirguitar <113586074+ruchirguitar@users.noreply.github.com> Date: Thu, 12 Nov 2026 01:06:28 +0530 Subject: [PATCH 01/26] UI: keep numeric field caret on short pointer drags (#1326) A pointer movement under the click tolerance was still treated as a tap, so the focus click selected the whole numeric value. Any movement from the press now keeps the caret; only a tap without movement selects all. Regression test: a_sub_threshold_movement_released_as_a_click_keeps_its_caret. --- crates/ui-egui/src/tests/numfields.rs | 17 +++++++++++++++++ crates/ui-egui/src/widgets/text.rs | 8 ++++---- docs/gaps.md | 2 +- 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/tests/numfields.rs b/crates/ui-egui/src/tests/numfields.rs index 45801c22ec..d7ceed77b9 100644 --- a/crates/ui-egui/src/tests/numfields.rs +++ b/crates/ui-egui/src/tests/numfields.rs @@ -1361,6 +1361,23 @@ fn a_short_drag_released_as_a_click_keeps_its_caret() { assert_eq!(f.frame(vec![enter()], &draw), Some(1273456.0), "typing lands where the drag left the caret"); } +/// #1326: the smallest pointer movement egui still reports as a click (under the 3-point drag +/// threshold) must keep the caret too; only a press released where it went down selects all. +#[test] +fn a_sub_threshold_movement_released_as_a_click_keeps_its_caret() { + let mut f = Field::new(); + let draw = |ui: &mut egui::Ui| numeric_fixture(ui, 0); + f.frame(vec![], &draw); + let at = f.text_point(field_text(0), 2, 7.0); + f.frame(vec![Event::PointerMoved(at), pointer_button(at, true)], &draw); + let pressed = f.selection(); + let nudge = egui::vec2(1.5, 0.0); + f.frame(vec![Event::PointerMoved(at + nudge)], &draw); + f.frame(vec![pointer_button(at + nudge, false)], &draw); + assert_ne!(f.selection(), Some((0, field_text(0).chars().count())), "a 1.5 px movement is a drag, not a tap (#1326)"); + assert_eq!(f.selection(), pressed, "the movement keeps the caret where the press landed"); +} + /// A tap (no movement) on a fresh numeric field still selects all on release. #[test] fn a_tap_without_movement_still_selects_all_on_release() { diff --git a/crates/ui-egui/src/widgets/text.rs b/crates/ui-egui/src/widgets/text.rs index 3551baebd2..5ede108998 100644 --- a/crates/ui-egui/src/widgets/text.rs +++ b/crates/ui-egui/src/widgets/text.rs @@ -171,13 +171,13 @@ pub(super) fn select_all_on_focus(ui: &Ui, resp: &Response, text: &str) { }); } let clicked_focus = released && ui.data_mut(|d| d.remove_temp::(pending)).unwrap_or(false) && resp.clicked_by(egui::PointerButton::Primary); - // A press released a few points away is a short drag, not a tap. egui still reports a - // click, and a release inside one character leaves no text selection, so select-all on - // release would replace the drag the user just made (#1326). + // Any pointer movement from the press is a drag, not a tap. egui still reports a click for + // a movement under its click tolerance, and a release inside one character leaves no text + // selection, so select-all on release would replace the drag the user just made (#1326). let dragged = clicked_focus && ui .data_mut(|d| d.remove_temp::(press_pos)) - .is_some_and(|p0| ui.input(|i| i.pointer.interact_pos()).is_some_and(|p1| (p1 - p0).length() > 3.0)); + .is_some_and(|p0| ui.input(|i| i.pointer.interact_pos()).is_some_and(|p1| (p1 - p0).length() > 0.0)); if resp.lost_focus() { ui.data_mut(|d| { d.remove_temp::(pending); diff --git a/docs/gaps.md b/docs/gaps.md index 07308c801a..be3ffe3f41 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -320,7 +320,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi - **Done:** Divide splits filled paths along open cutting lines and curves (#1307), preserving the frontmost filled appearance, implicit filled closure, holes, unrelated objects and one Undo/Redo; the existing bounded-map fallback remains. -- **Done:** Numeric fields keep a text drag's start when the pointer gains focus and movement exceeds the click threshold (#1326, in part). A completed focus click selects the whole value; keyboard focus, double-click and numeric stepping remain regression-tested. Short movements still classified as clicks can select all; that case remains open. +- **Done:** Numeric fields keep a text drag's start when the pointer gains focus, including short movements that egui still classifies as clicks (#1326). Any pointer movement from the press keeps the caret; only a tap without movement selects the whole value. Keyboard focus, double-click and numeric stepping remain regression-tested (`tests/numfields.rs`, `a_short_drag_released_as_a_click_keeps_its_caret` and `a_sub_threshold_movement_released_as_a_click_keeps_its_caret`). - **Done:** DXF reads control-point SPLINE closure (#1299 follow-up after #1320); open curves keep their open state. DXF export now recognizes circular cubic paths conservatively and writes native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE, and a non-circular ellipse keeps the existing fallback. Alter Paths for Appearance outlines only the strokes a CAD line can't draw (aligned, profiled, offset dashes, wider than 2.11 mm, or in R13 and R14), so a 1 pt circle or arc stays a CIRCLE or ARC. CNC preset and VCarve QA remain open. Escape leaves Artboard editing for Selection; during a drag it first cancels the gesture. Headless egui key events cover idle Escape, active-drag cancellation and a subsequent Escape in `tests/distortkeys.rs`. - **Done:** DXF reads control-point SPLINE closure (#1299 follow-up after #1320); open curves keep their open state. DXF export now recognizes circular cubic paths conservatively and writes native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE. A closed four-cubic ellipse, or a circle scaled out of proportion, is written as a native ELLIPSE in R13 and later; R12 keeps the existing fallback. CNC preset and VCarve QA remain open. Escape leaves Artboard editing for Selection; during a drag it first cancels the gesture. Headless egui key events cover idle Escape, active-drag cancellation and a subsequent Escape in `tests/distortkeys.rs`. From f0e50437c22649353e53f80ac6f572d7df686420 Mon Sep 17 00:00:00 2001 From: Ahmed Gomaa Date: Wed, 11 Nov 2026 23:16:03 +0200 Subject: [PATCH 02/26] 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 03/26] 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 880cdea33cbdbf26c7ec10405e10e025fe0c1d1f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 21:16:20 -0800 Subject: [PATCH 04/26] Brushes: a built-in Dry Media library of grainy chalk, pencil, crayon and charcoal art brushes Window > Brush Libraries > Dry Media (`dry-media`) holds four art brushes: Chalk Grain, Graphite Pencil, Wax Crayon and Charcoal Dust. Each is 100 pt of filled vector paths generated from a fixed seed with integer steps and plain arithmetic, at most 80 paths and 500 anchors, stretched along the path with Tints colorization. The chalk and crayon art has even-odd gaps cut out of its band. The names, sizes and art are VectorCraft's own. A new i18n test checks that every complete catalog names each built-in brush library. --- crates/engine/src/cmd/brushlib.rs | 165 ++++++++++++++++++ crates/engine/src/tests/brushlib.rs | 140 ++++++++++++++- crates/ui-egui/src/i18n/bg.tsv | 5 + crates/ui-egui/src/i18n/bs.tsv | 5 + crates/ui-egui/src/i18n/ca.tsv | 5 + crates/ui-egui/src/i18n/cs.tsv | 5 + crates/ui-egui/src/i18n/da.tsv | 5 + crates/ui-egui/src/i18n/de.tsv | 5 + crates/ui-egui/src/i18n/el.tsv | 5 + crates/ui-egui/src/i18n/es.tsv | 5 + crates/ui-egui/src/i18n/fi.tsv | 5 + crates/ui-egui/src/i18n/fr.tsv | 5 + crates/ui-egui/src/i18n/hi.tsv | 5 + crates/ui-egui/src/i18n/hu.tsv | 5 + crates/ui-egui/src/i18n/id.tsv | 5 + crates/ui-egui/src/i18n/it.tsv | 5 + crates/ui-egui/src/i18n/ja.tsv | 5 + crates/ui-egui/src/i18n/ka.tsv | 5 + crates/ui-egui/src/i18n/ko.tsv | 5 + crates/ui-egui/src/i18n/nl.tsv | 5 + crates/ui-egui/src/i18n/pl.tsv | 5 + crates/ui-egui/src/i18n/pt-br.tsv | 5 + crates/ui-egui/src/i18n/ru.tsv | 5 + crates/ui-egui/src/i18n/sv.tsv | 5 + crates/ui-egui/src/i18n/tests.rs | 14 ++ crates/ui-egui/src/i18n/tr.tsv | 5 + crates/ui-egui/src/i18n/uk.tsv | 5 + crates/ui-egui/src/i18n/vi.tsv | 5 + crates/ui-egui/src/i18n/zh-hans.tsv | 5 + crates/ui-egui/src/i18n/zh-hant.tsv | 5 + .../src/panels/tests_brush_libraries.rs | 12 +- docs/gaps.md | 6 +- docs/mcp.md | 5 +- docs/roadmap.md | 2 +- docs/target-app-parity.md | 9 +- 35 files changed, 476 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/cmd/brushlib.rs b/crates/engine/src/cmd/brushlib.rs index 300d9f6aa1..c6d0b573c4 100644 --- a/crates/engine/src/cmd/brushlib.rs +++ b/crates/engine/src/cmd/brushlib.rs @@ -173,6 +173,7 @@ pub const BUILTIN: &[BuiltinBrushLibrary] = &[ BuiltinBrushLibrary { id: "scatter-shapes", name: "Scatter Shapes", make: scatter_shapes }, BuiltinBrushLibrary { id: "game-trails", name: "Game Trails", make: game_trails }, BuiltinBrushLibrary { id: "energy-beams", name: "Energy Beams", make: energy_beams }, + BuiltinBrushLibrary { id: "dry-media", name: "Dry Media", make: dry_media }, ]; /// Built-in library `id`, made once. @@ -446,6 +447,170 @@ fn energy_beams() -> BrushLibrary { built("Energy Beams", Document::new(100.0, 100.0), brushes) } +/// The fixed-seed number source of the Dry Media grain: integer steps and plain arithmetic, so the +/// art is the same on every run and every platform. +struct Grain(u64); + +impl Grain { + /// The next number in 0..1. + fn next(&mut self) -> f64 { + self.0 = self.0.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407); + (self.0 >> 11) as f64 / (1u64 << 53) as f64 + } + /// The next number in `a`..`b`. + fn range(&mut self, a: f64, b: f64) -> f64 { + a + (b - a) * self.next() + } +} + +/// `floor` at x = `x0` and `x1`, rising smoothly to 1 within `ramp` pt of each. +fn ends(x: f64, x0: f64, x1: f64, ramp: f64, floor: f64) -> f64 { + let t = ((x - x0).min(x1 - x) / ramp).clamp(0.0, 1.0); + floor + (1.0 - floor) * t * t * (3.0 - 2.0 * t) +} + +/// A closed band from x = `x0` to `x1` in `n` steps, at most `half` pt either side of y = 0 times +/// `taper(x)`; each edge point is pulled in by up to `rough` of that. +fn rough_band(g: &mut Grain, (x0, x1): (f64, f64), n: usize, half: f64, rough: f64, taper: impl Fn(f64) -> f64) -> PathData { + let xs: Vec = (0..=n).map(|i| x0 + (x1 - x0) * i as f64 / n as f64).collect(); + let mut pts: Vec = xs.iter().map(|&x| Point::new(x, -half * taper(x) * (1.0 - rough * g.next()))).collect(); + let bottom: Vec = xs.iter().rev().map(|&x| Point::new(x, half * taper(x) * (1.0 - rough * g.next()))).collect(); + pts.extend(bottom); + PathData::single(SubPath::polyline(&pts, true)) +} + +/// A small four-cornered fleck around (`cx`, `cy`), at most `w` × `h` pt either side; one corner +/// in each direction, so its outline never crosses itself. +fn fleck(g: &mut Grain, cx: f64, cy: f64, w: f64, h: f64) -> PathData { + let mut side = |a: f64, b: f64| g.range(a, b); + let pts = [ + (cx - w * side(0.6, 1.0), cy + h * side(-0.3, 0.3)), + (cx + w * side(-0.3, 0.3), cy - h * side(0.6, 1.0)), + (cx + w * side(0.6, 1.0), cy + h * side(-0.3, 0.3)), + (cx + w * side(-0.3, 0.3), cy + h * side(0.6, 1.0)), + ]; + polygon(&pts) +} + +/// `body` with `holes` (inside it, apart from each other) cut out, even-odd, in gray `k`: what +/// lies under the stroke shows through the holes. +fn holed(body: PathData, holes: Vec, k: f32) -> Node { + let children = std::iter::once(body).chain(holes).map(|p| Arc::new(Node::path(NodeId(0), p, Appearance::default()))).collect(); + let mut n = Node::new(NodeId(0), NodeKind::Compound { children, rule: FillRule::EvenOdd }); + n.appearance = Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::gray(k))))], ..Default::default() }.into(); + n +} + +/// Path `p` filled with gray `k` (1 is black). +fn ink(p: PathData, k: f32) -> Node { + filled(p, Paint::solid(Color::gray(k))) +} + +/// Slivers `w` × `h` pt (ranges) in `count` slots along x = 6..94 inside a band `half` pt wide +/// times `taper`. Each stays within `reach` of the band's half width from y = 0, taken where the +/// band is narrowest over the sliver and the 3 pt either side of it, so with `reach` below the +/// band's roughness margin no sliver touches the band's edge or another sliver. +fn slivers(g: &mut Grain, count: usize, half: f64, reach: f64, (w, h): ((f64, f64), (f64, f64)), taper: impl Fn(f64) -> f64) -> Vec { + let slot = 88.0 / count as f64; + (0..count) + .map(|i| { + let cx = 6.0 + slot * (i as f64 + 0.5); + let (w, h) = (g.range(w.0, w.1), g.range(h.0, h.1)); + let room = reach * half * taper(cx - w - 3.0).min(taper(cx + w + 3.0)); + let cy = g.range(-1.0, 1.0) * (room - h).max(0.0); + fleck(g, cx, cy, w, h) + }) + .collect() +} + +/// Dry Media: art brushes of grainy dry drawing marks, chalk, pencil, crayon and charcoal (original +/// vector grain from fixed seeds; every brush 100 pt long, black and grays that take the stroke +/// color as tints, gaps cut out so the paper shows). +pub(crate) fn dry_media() -> BrushLibrary { + // Chalk Grain, 100 × 8 pt: a soft band with narrow scratches and chips along its edges. + let chalk = { + let mut g = Grain(0x0c4a_1c0a); + let taper = |x: f64| ends(x, 0.0, 100.0, 10.0, 0.35); + let body = rough_band(&mut g, (0.0, 100.0), 36, 4.0, 0.25, taper); + let scratches = slivers(&mut g, 14, 4.0, 0.7, ((1.2, 2.4), (0.15, 0.3)), taper); + let mut parts = vec![holed(body, scratches, 0.8)]; + for i in 0..18 { + let cx = 3.0 + 94.0 * (i as f64 + g.range(0.2, 0.8)) / 18.0; + let side = if i % 2 == 0 { -1.0 } else { 1.0 }; + let cy = side * 4.0 * taper(cx) * g.range(0.7, 0.95); + let (w, h) = (g.range(0.3, 0.7), g.range(0.25, 0.5)); + let f = fleck(&mut g, cx, cy, w, h); + parts.push(ink(f, 1.0)); + } + group(parts) + }; + // Graphite Pencil, 100 × 2.5 pt: a solid tapered core with fine slanted hatching at its edges. + let pencil = { + let mut g = Grain(0x009e_4c11); + let core = rough_band(&mut g, (0.0, 100.0), 40, 0.8, 0.12, |x| ends(x, 0.0, 100.0, 18.0, 0.25)); + let mut parts = vec![ink(core, 1.0)]; + for i in 0..24 { + let x0 = 2.0 + 92.0 * (i as f64 + g.range(0.0, 0.4)) / 24.0; + let len = g.range(1.5, 3.0); + let t = g.range(0.12, 0.2); + let slant = g.range(0.1, 0.3); + let y0 = if i % 2 == 0 { -g.range(0.85, 1.05) } else { g.range(0.85, 1.25) - t - slant }; + parts.push(ink(polygon(&[(x0, y0 + slant), (x0 + len, y0), (x0 + len, y0 + t), (x0, y0 + slant + t)]), 0.6)); + } + group(parts) + }; + // Wax Crayon, 100 × 6 pt: a dense band with small gaps in its middle and short overlapping + // marks along both edges. + let crayon = { + let mut g = Grain(0x00c0_a7e5); + let taper = |x: f64| ends(x, 0.0, 100.0, 6.0, 0.5); + let body = rough_band(&mut g, (0.0, 100.0), 30, 2.6, 0.2, taper); + let gaps = slivers(&mut g, 16, 2.6, 0.34, ((0.6, 1.6), (0.15, 0.3)), taper); + let mut parts = vec![holed(body, gaps, 0.9)]; + for i in 0..36 { + let w = g.range(1.0, 2.2); + let cx = (1.0 + w).max(98.0 * (i as f64 + g.range(0.0, 1.0)) / 36.0).min(99.0 - w); + let side = if i % 2 == 0 { -1.0 } else { 1.0 }; + let h = g.range(0.4, 0.6); + // Clear of the gaps, which stay within 0.9 pt of the middle. + let inner = 0.9 + h; + let cy = side * g.range(inner, (2.4 * taper(cx)).max(inner)); + let f = fleck(&mut g, cx, cy, w, h); + parts.push(ink(f, 1.0)); + } + group(parts) + }; + // Charcoal Dust, 100 × 7 pt: a dark band broken into pieces, with flecks of dust either side. + let dust = { + let mut g = Grain(0xc4a2_c0a1); + let mut parts = vec![]; + let mut x0 = 0.0; + while x0 < 100.0 { + let x1 = (x0 + g.range(14.0, 24.0)).min(100.0); + let x1 = if 100.0 - x1 < 8.0 { 100.0 } else { x1 }; + let piece = rough_band(&mut g, (x0, x1), 10, 2.0, 0.3, |x| ends(x, 0.0, 100.0, 8.0, 0.4) * ends(x, x0, x1, 2.0, 0.3)); + parts.push(ink(piece, 1.0)); + x0 = x1 + g.range(1.5, 3.0); + } + for i in 0..30 { + let cx = 2.0 + 96.0 * (i as f64 + g.range(0.2, 0.8)) / 30.0; + let side = if g.next() < 0.5 { -1.0 } else { 1.0 }; + let s = g.range(0.15, 0.3); + let cy = side * g.range(2.3, 3.5 - s); + let f = fleck(&mut g, cx, cy, s, s); + parts.push(ink(f, if i % 3 == 0 { 0.7 } else { 1.0 })); + } + group(parts) + }; + let brushes = vec![ + art("Chalk Grain", chalk, Colorization::Tints), + art("Graphite Pencil", pencil, Colorization::Tints), + art("Wax Crayon", crayon, Colorization::Tints), + art("Charcoal Dust", dust, Colorization::Tints), + ]; + built("Dry Media", Document::new(100.0, 100.0), brushes) +} + // ---------- commands ---------- fn builtin_info(b: &BuiltinBrushLibrary) -> LibraryInfo { diff --git a/crates/engine/src/tests/brushlib.rs b/crates/engine/src/tests/brushlib.rs index 491a471088..73b43b1a76 100644 --- a/crates/engine/src/tests/brushlib.rs +++ b/crates/engine/src/tests/brushlib.rs @@ -104,7 +104,7 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() { let list = run(&mut s, "brush.libraries", json!({})); let libs = list["libraries"].as_array().unwrap(); let ids: Vec<&str> = libs.iter().map(|l| l["id"].as_str().unwrap()).collect(); - assert_eq!(ids, ["art-ribbons", "calligraphic-nibs", "pattern-borders", "scatter-shapes", "game-trails", "energy-beams"]); + assert_eq!(ids, ["art-ribbons", "calligraphic-nibs", "pattern-borders", "scatter-shapes", "game-trails", "energy-beams", "dry-media"]); assert!(libs.iter().all(|l| l["category"] == "builtIn")); let lib = run(&mut s, "brush.library", json!({"library": "art ribbons"})); assert_eq!( @@ -124,6 +124,12 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() { ["Laser Bolt", "Plasma Arc", "Charge Wave", "Spark Trail"] ); assert_eq!(libs[5]["count"].as_u64(), Some(4)); + let dry = run(&mut s, "brush.library", json!({"library": "Dry Media"})); + assert_eq!( + dry["brushes"].as_array().unwrap().iter().map(|b| (b["name"].as_str().unwrap(), b["type"].as_str().unwrap())).collect::>(), + [("Chalk Grain", "art"), ("Graphite Pencil", "art"), ("Wax Crayon", "art"), ("Charcoal Dust", "art")] + ); + assert_eq!((libs[6]["name"].as_str(), libs[6]["count"].as_u64()), (Some("Dry Media"), Some(4))); run(&mut s, "file.new", json!({"width": 200, "height": 200})); let id = run(&mut s, "shape.rectangle", json!({"x": 10, "y": 20, "width": 40, "height": 8}))["id"].as_u64().unwrap(); let added = run(&mut s, "brush.addFromLibrary", json!({"library": "energy-beams", "name": "Laser Bolt", "apply": true, "ids": [id]})); @@ -136,6 +142,7 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() { ("scatter-shapes", "scatter"), ("game-trails", "art"), ("energy-beams", "art"), + ("dry-media", "art"), ] { let (_, lib) = cmd::brushlib::library(&s, id).unwrap(); assert!(lib.len() >= 4, "{id}"); @@ -165,6 +172,137 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() { assert!(s.execute("brush.library", &json!({"library": "nope"})).is_err()); } +/// Whether `p` lies inside the polygon through `pts` (even-odd ray cast). +fn inside(p: vectorcraft_geom::Point, pts: &[vectorcraft_geom::Point]) -> bool { + let mut odd = false; + for (i, a) in pts.iter().enumerate() { + let b = pts[(i + 1) % pts.len()]; + if (a.y > p.y) != (b.y > p.y) && p.x < a.x + (p.y - a.y) * (b.x - a.x) / (b.y - a.y) { + odd = !odd; + } + } + odd +} + +/// Every anchor of the paths in `nodes` and their descendants. +fn anchors(nodes: &[&vectorcraft_doc::Node]) -> Vec { + let mut out = vec![]; + for n in nodes { + n.walk(&mut |c| out.extend(c.path_data().into_iter().flat_map(|p| p.subpaths.iter().flat_map(|sp| sp.anchors.iter().map(|a| a.p))))); + } + out +} + +/// Dry Media: four art brushes of grainy dry marks (chalk, pencil, crayon, charcoal) with names +/// of their own. Each one's art is a bounded set of filled vector paths 100 pt long that takes the +/// stroke color as tints, with real even-odd gaps in the chalk and the crayon; it draws finite art +/// on curved and short paths, is the same every time it is made, and goes into a document in one +/// undo step, once, applied, and through a saved library unchanged. +#[test] +fn the_dry_media_library_holds_grainy_art_brushes_with_bounded_art() { + use vectorcraft_brush::{ArtScale, Colorization}; + use vectorcraft_geom::FillRule; + let mut s = session(); + let (info, lib) = cmd::brushlib::library(&s, "dry-media").unwrap(); + assert_eq!((info.name.as_str(), info.category, lib.name.as_str()), ("Dry Media", "builtIn", "Dry Media")); + let names: Vec = lib.brushes.iter().map(|b| b.name.clone()).collect(); + assert_eq!(names, ["Chalk Grain", "Graphite Pencil", "Wax Crayon", "Charcoal Dust"]); + let mut total = 0; + for (b, (height, gaps)) in lib.brushes.iter().zip([(8.0, true), (2.5, false), (6.0, true), (7.0, false)]) { + let BrushKind::Art(a) = &b.kind else { panic!("{} is not an art brush", b.name) }; + assert_eq!((a.colorization, a.scale, a.width), (Colorization::Tints, ArtScale::Stretch, 100.0), "{}", b.name); + let (mut paths, mut holes) = (0, 0); + a.art.walk(&mut |n| match &n.kind { + NodeKind::Path { path, .. } => { + paths += 1; + assert!(path.subpaths.iter().all(|sp| sp.closed && sp.anchors.len() >= 4), "{}", b.name); + } + NodeKind::Compound { children, rule } => { + assert_eq!(*rule, FillRule::EvenOdd, "{}", b.name); + // The gaps lie inside the band and apart from each other, so each one is a hole. + let body = anchors(&[&*children[0]]); + let boxes: Vec = children[1..].iter().map(|c| c.path_data().unwrap().bounds().unwrap()).collect(); + for (i, c) in children[1..].iter().enumerate() { + assert!(anchors(&[&**c]).iter().all(|p| inside(*p, &body)), "{}: gap {i} leaves the band", b.name); + let r = boxes[i]; + assert!(boxes[..i].iter().all(|o| r.x0 >= o.x1 || o.x0 >= r.x1 || r.y0 >= o.y1 || o.y0 >= r.y1), "{}: gap {i} overlaps", b.name); + } + holes += children.len() - 1; + } + NodeKind::Group { .. } => {} + other => panic!("{}: unexpected {other:?}", b.name), + }); + let pts = anchors(&[&a.art]); + assert!(pts.iter().all(|p| p.x.is_finite() && p.y.is_finite())); + assert!(paths <= 80 && pts.len() <= 500, "{}: {paths} paths, {} anchors", b.name, pts.len()); + assert_eq!(holes > 0, gaps, "{}: {holes} gaps", b.name); + let r = vectorcraft_geom::Rect::from_points(pts[0], pts[0]); + let r = pts.iter().fold(r, |r, p| r.union_pt(*p)); + assert!(r.x0 >= 0.0 && r.x0 < 0.5 && r.x1 <= 100.0 && r.x1 > 99.5, "{}: {r:?}", b.name); + assert!((r.height() - height).abs() <= height * 0.25 && r.y0 < 0.0 && r.y1 > 0.0, "{}: {r:?}", b.name); + total += pts.len(); + // Finite art on a curve and on a 2 pt path. + for (line, width) in [(vec![(0.0, 0.0), (60.0, -40.0), (140.0, 40.0), (200.0, 0.0)], 1.0), (vec![(0.0, 0.0), (2.0, 0.0)], 3.0)] { + let mut bp = vectorcraft_geom::BezPath::new(); + bp.move_to(line[0]); + if line.len() == 4 { + bp.curve_to(line[1], line[2], line[3]); + } else { + bp.line_to(line[1]); + } + let stroke = vectorcraft_doc::StrokeLayer::new(vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), width); + let pieces = vectorcraft_brush::stroke_pieces(&lib.doc, b, &bp, &stroke); + let drawn = anchors(&pieces.iter().collect::>()); + assert!(!drawn.is_empty() && drawn.iter().all(|p| p.x.is_finite() && p.y.is_finite()), "{} on {line:?}", b.name); + } + } + assert!(total <= 1200, "{total} anchors"); + // Made again, the same bytes; the checksum pins them across runs and platforms. + let bytes = serde_json::to_string(&cmd::brushlib::dry_media().brushes).unwrap(); + assert_eq!(bytes, serde_json::to_string(&lib.brushes).unwrap()); + let fnv = bytes.bytes().fold(0xcbf2_9ce4_8422_2325_u64, |h, b| (h ^ u64::from(b)).wrapping_mul(0x0100_0000_01b3)); + assert_eq!(fnv, 12_859_086_488_513_909_464, "the Dry Media art changed"); + // Names of their own: no default brush's, no other built-in library's. + let defaults: Vec<&str> = vectorcraft_brush::defaults().iter().map(|b| b.name.as_str()).collect(); + let others: Vec = cmd::brushlib::BUILTIN + .iter() + .filter(|b| b.id != "dry-media") + .flat_map(|b| cmd::brushlib::library(&s, b.id).unwrap().1.brushes.iter().map(|x| x.name.clone()).collect::>()) + .collect(); + assert!(names.iter().all(|n| !defaults.contains(&n.as_str()) && !others.contains(n)), "{names:?}"); + + // Added and applied to a path in one undo step, which undo takes back. + let path = run(&mut s, "shape.line", json!({"x1": 10, "y1": 10, "x2": 150, "y2": 60}))["id"].as_u64().unwrap(); + let stroke_brush = |s: &Session| doc(s).node(NodeId(path)).unwrap().appearance.stroke().and_then(|st| st.brush.clone()); + let before = (undo_len(&s), brushes(&s)); + let r = run(&mut s, "brush.addFromLibrary", json!({"library": "Dry Media", "name": "Wax Crayon", "apply": true, "ids": [path]})); + assert_eq!((strs(&r["added"]), r["applied"].clone()), (vec!["Wax Crayon"], json!([path]))); + assert_eq!((undo_len(&s), stroke_brush(&s).as_deref()), (before.0 + 1, Some("Wax Crayon"))); + assert_eq!(&brush(&s, "Wax Crayon"), lib.brush("Wax Crayon").unwrap()); + run(&mut s, "edit.undo", json!({})); + assert_eq!((brushes(&s), stroke_brush(&s)), (before.1.clone(), None)); + run(&mut s, "edit.redo", json!({})); + // The rest in one more step; then nothing new to add. + let r = run(&mut s, "brush.addFromLibrary", json!({"library": "dry-media"})); + assert_eq!((strs(&r["added"]), strs(&r["existing"])), (vec!["Chalk Grain", "Graphite Pencil", "Charcoal Dust"], vec!["Wax Crayon"])); + assert_eq!(undo_len(&s), before.0 + 2); + assert_eq!( + brushes(&s), + [before.1.clone(), vec!["Wax Crayon".into(), "Chalk Grain".into(), "Graphite Pencil".into(), "Charcoal Dust".into()]].concat() + ); + let r = run(&mut s, "brush.addFromLibrary", json!({"library": "dry-media"})); + assert_eq!((strs(&r["added"]).len(), undo_len(&s)), (0, before.0 + 2)); + // Saved as a library and loaded back: the same brushes, in the document's order. + let saved = run(&mut s, "brush.saveLibrary", json!({ "names": names, "name": "Dry" })); + let b64 = saved["dataBase64"].as_str().unwrap().to_string(); + let r = run(&mut s, "brush.loadLibrary", json!({"dataBase64": b64, "name": "Dry.vectorcraft"})); + let (_, reopened) = cmd::brushlib::library(&s, r["library"].as_str().unwrap()).unwrap(); + assert_eq!(reopened.len(), lib.len()); + for b in &lib.brushes { + assert_eq!(reopened.brush(&b.name), Some(b), "{}", b.name); + } +} + #[test] fn a_saved_library_reopens_and_its_brushes_join_another_document_in_one_step() { let dir = folder("user"); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index bc67d8fd87..67f69080db 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -6022,6 +6022,11 @@ axis Y Axis Y ос Calligraphic Nibs Калиграфски писци Pattern Borders Бордюри с мостри Scatter Shapes Разсеяни фигури + Dry Media Сухи материали + Chalk Grain Тебеширено зърно + Graphite Pencil Графитен молив + Wax Crayon Восъчен пастел + Charcoal Dust Въглен прах Save Brush Library… Запази библиотека с четки… @msg no empty artboards to delete няма празни работни области за изтриване Scale Presets Пресети за мащаб diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 6454cffcc9..9e20a00f2c 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -6072,6 +6072,11 @@ axis Y Axis Y osa Calligraphic Nibs Kaligrafski vrhovi Pattern Borders Ivice od uzoraka Scatter Shapes Raspršeni oblici + Dry Media Suhi mediji + Chalk Grain Zrno krede + Graphite Pencil Grafitna olovka + Wax Crayon Voštana bojica + Charcoal Dust Ugljena prašina Save Brush Library… Sačuvaj biblioteku četkica… @msg `{_1}` has no brushes to apply `{_1}` nema četkica za primjenu @msg missing `library` (see brush.libraries) nedostaje `library` (pogledajte brush.libraries) diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 1567e0e8b5..40f96d0859 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -3824,6 +3824,11 @@ digits Malayalam Malaiàlam Scatter Brush Options Opcions del pinzell de dispersió Scatter Graph Tool Eina de gràfic de dispersió Scatter Shapes Formes de dispersió + Dry Media Tècniques seques + Chalk Grain Gra de guix + Graphite Pencil Llapis de grafit + Wax Crayon Cera de colors + Charcoal Dust Pols de carbó Scatter: Dispersió: Scissors Tisores Scissors Tool Eina de tisores diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index e9df0812f2..78f43ab4a2 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6271,6 +6271,11 @@ axis Y Axis osa Y Calligraphic Nibs Kaligrafická pera Pattern Borders Vzorkové okraje Scatter Shapes Rozptýlené tvary + Dry Media Suché techniky + Chalk Grain Křídové zrno + Graphite Pencil Grafitová tužka + Wax Crayon Vosková pastelka + Charcoal Dust Uhlový prach Save Brush Library… Uložit knihovnu štětců… @msg `{_1}` has no brushes to apply `{_1}` nemá žádné štětce k použití @msg missing `library` (see brush.libraries) chybí `library` (viz brush.libraries) diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index e259ecba84..22fe87c421 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -6037,6 +6037,11 @@ axis Y Axis Y-akse Calligraphic Nibs Kalligrafiske penne Pattern Borders Mønsterborter Scatter Shapes Spredte former + Dry Media Tørre medier + Chalk Grain Kridtkorn + Graphite Pencil Grafitblyant + Wax Crayon Vokskridt + Charcoal Dust Kulstøv Save Brush Library… Gem penselbibliotek… @msg no empty artboards to delete ingen tomme tegneflader at slette Scale Presets Skaleringsforudindstillinger diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index abfaaa13a7..87f5876eba 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5854,6 +5854,11 @@ axis Y Axis Y-Achse Calligraphic Nibs Kalligrafie-Federn Pattern Borders Musterbordüren Scatter Shapes Streuformen + Dry Media Trockene Medien + Chalk Grain Kreidekorn + Graphite Pencil Graphitstift + Wax Crayon Wachsmalstift + Charcoal Dust Kohlestaub Save Brush Library… Pinsel-Bibliothek speichern… @msg `{_1}` has no brushes to apply `{_1}` hat keine Pinsel zum Anwenden @msg missing `library` (see brush.libraries) `library` fehlt (siehe brush.libraries) diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 4611904ff0..efab4b490e 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -6222,6 +6222,11 @@ axis Y Axis Άξονας Y Calligraphic Nibs Καλλιγραφικές πένες Pattern Borders Περιθώρια μοτίβου Scatter Shapes Σχήματα διασποράς + Dry Media Ξηρά μέσα + Chalk Grain Κόκκος κιμωλίας + Graphite Pencil Μολύβι γραφίτη + Wax Crayon Κηρομπογιά + Charcoal Dust Σκόνη κάρβουνου Save Brush Library… Αποθήκευση βιβλιοθήκης πινέλων… @msg `{_1}` has no brushes to apply Η `{_1}` δεν έχει πινέλα για εφαρμογή @msg missing `library` (see brush.libraries) λείπει το `library` (δείτε brush.libraries) diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index b91d89b8c0..cbce0ab008 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -6213,6 +6213,11 @@ axis Y Axis Eje Y Calligraphic Nibs Plumillas caligráficas Pattern Borders Bordes de motivo Scatter Shapes Formas de dispersión + Dry Media Técnicas secas + Chalk Grain Grano de tiza + Graphite Pencil Lápiz de grafito + Wax Crayon Cera de colores + Charcoal Dust Polvo de carboncillo Save Brush Library… Guardar biblioteca de pinceles… @msg `{_1}` has no brushes to apply `{_1}` no tiene pinceles para aplicar @msg missing `library` (see brush.libraries) falta `library` (consulte brush.libraries) diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index e296459895..f4154755b7 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6301,6 +6301,11 @@ axis Y Axis Y-akseli Calligraphic Nibs Kalligrafiakärjet Pattern Borders Kuvioreunukset Scatter Shapes Sirontamuodot + Dry Media Kuivat välineet + Chalk Grain Liitujyvä + Graphite Pencil Grafiittikynä + Wax Crayon Vahaliitu + Charcoal Dust Hiilipöly Save Brush Library… Tallenna sivellinkirjasto… @msg `{_1}` has no brushes to apply Kohteessa `{_1}` ei ole käytettäviä siveltimiä @msg missing `library` (see brush.libraries) `library` puuttuu (katso brush.libraries) diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index ff206b9317..4be80cbfba 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6266,6 +6266,11 @@ axis Y Axis Axe Y Calligraphic Nibs Plumes calligraphiques Pattern Borders Bordures de motif Scatter Shapes Formes de dispersion + Dry Media Techniques sèches + Chalk Grain Grain de craie + Graphite Pencil Crayon graphite + Wax Crayon Craie grasse + Charcoal Dust Poussière de fusain Save Brush Library… Enregistrer la bibliothèque de formes… @msg `{_1}` has no brushes to apply `{_1}` n'a aucune forme à appliquer @msg missing `library` (see brush.libraries) `library` manquant (voir brush.libraries) diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 6698f10a4b..d8407860f2 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -434,6 +434,11 @@ antiAlias Sharp शार्प Plasma Arc प्लाज़्मा चाप Charge Wave चार्ज तरंग Spark Trail चिंगारी पथ + Dry Media सूखे माध्यम + Chalk Grain चॉक के दाने + Graphite Pencil ग्रेफाइट पेंसिल + Wax Crayon मोम क्रेयॉन + Charcoal Dust चारकोल धूल Art using a deleted global swatch keeps its colour. हटाए गए ग्लोबल स्वॉच वाला आर्टवर्क अपना रंग बनाए रखता है। Artboard आर्टबोर्ड Artboard {n} of {total} {total} में से आर्टबोर्ड {n} diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index a8d02ca4c4..a24dcf20ee 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6254,6 +6254,11 @@ axis Y Axis Y tengely Calligraphic Nibs Kalligrafikus tollhegyek Pattern Borders Mintázatszegélyek Scatter Shapes Szórt alakzatok + Dry Media Száraz technikák + Chalk Grain Krétaszemcse + Graphite Pencil Grafitceruza + Wax Crayon Zsírkréta + Charcoal Dust Szénpor Save Brush Library… Ecsetkönyvtár mentése… @msg `{_1}` has no brushes to apply A(z) `{_1}` nem tartalmaz alkalmazható ecsetet @msg missing `library` (see brush.libraries) hiányzik a `library` (lásd: brush.libraries) diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 04581cd727..bf4bf9af40 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -6037,6 +6037,11 @@ axis Y Axis Sumbu Y Calligraphic Nibs Pena kaligrafi Pattern Borders Bingkai pola Scatter Shapes Bentuk sebar + Dry Media Media kering + Chalk Grain Butiran kapur + Graphite Pencil Pensil grafit + Wax Crayon Krayon lilin + Charcoal Dust Debu arang Save Brush Library… Simpan pustaka kuas… @msg no empty artboards to delete tidak ada artboard kosong untuk dihapus Scale Presets Preset skala diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 82827964fe..e1d073b1f2 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5888,6 +5888,11 @@ axis Y Axis Asse Y Calligraphic Nibs Pennini calligrafici Pattern Borders Bordi a motivo Scatter Shapes Forme a dispersione + Dry Media Tecniche secche + Chalk Grain Grana di gesso + Graphite Pencil Matita di grafite + Wax Crayon Pastello a cera + Charcoal Dust Polvere di carboncino Save Brush Library… Salva libreria pennelli… @msg `{_1}` has no brushes to apply `{_1}` non ha pennelli da applicare @msg missing `library` (see brush.libraries) manca `library` (vedi brush.libraries) diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 42a4aa3cf2..babf405210 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5953,6 +5953,11 @@ axis Y Axis Y 軸 Calligraphic Nibs カリグラフィペン先 Pattern Borders パターンボーダー Scatter Shapes 散布シェイプ + Dry Media ドライメディア + Chalk Grain チョークの粒子 + Graphite Pencil 黒鉛鉛筆 + Wax Crayon ワックスクレヨン + Charcoal Dust 木炭の粉 Save Brush Library… ブラシライブラリを保存… @msg `{_1}` has no brushes to apply `{_1}` に適用できるブラシがありません @msg missing `library` (see brush.libraries) `library` がありません (brush.libraries を参照) diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 30b0abd6bf..db7edfa9e7 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6222,6 +6222,11 @@ axis Y Axis Y ღერძი Calligraphic Nibs კალიგრაფიული წვერები Pattern Borders პატერნის ბორდიურები Scatter Shapes გაბნეული ფიგურები + Dry Media მშრალი მასალები + Chalk Grain ცარცის მარცვალი + Graphite Pencil გრაფიტის ფანქარი + Wax Crayon ცვილის ფანქარი + Charcoal Dust ნახშირის მტვერი Save Brush Library… ფუნჯების ბიბლიოთეკის შენახვა… @msg `{_1}` has no brushes to apply `{_1}`-ში გამოსაყენებელი ფუნჯები არ არის @msg missing `library` (see brush.libraries) `library` აკლია (იხ. brush.libraries) diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 3bc8360b49..3af02c656c 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5849,6 +5849,11 @@ axis Y Axis Y축 Calligraphic Nibs 서예 펜촉 Pattern Borders 패턴 테두리 Scatter Shapes 흩뿌리기 모양 + Dry Media 건식 재료 + Chalk Grain 분필 입자 + Graphite Pencil 흑연 연필 + Wax Crayon 왁스 크레용 + Charcoal Dust 목탄 가루 Save Brush Library… 브러시 라이브러리 저장… @msg `{_1}` has no brushes to apply `{_1}`에 적용할 브러시가 없습니다 @msg missing `library` (see brush.libraries) `library`가 없습니다(brush.libraries 참조) diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index c6f37b432a..254a77856b 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -6088,6 +6088,11 @@ axis Y Axis Y-as Calligraphic Nibs Kalligrafische penpunten Pattern Borders Patroonranden Scatter Shapes Strooivormen + Dry Media Droge media + Chalk Grain Krijtkorrel + Graphite Pencil Grafietpotlood + Wax Crayon Waskrijt + Charcoal Dust Houtskoolstof Save Brush Library… Penseelbibliotheek opslaan… @msg no empty artboards to delete geen lege tekengebieden om te verwijderen Scale Presets Schaalvoorinstellingen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 3647b56f54..833c381071 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -6125,6 +6125,11 @@ axis Y Axis Oś Y Calligraphic Nibs Stalówki kaligraficzne Pattern Borders Obramowania wzorków Scatter Shapes Kształty rozproszone + Dry Media Techniki suche + Chalk Grain Ziarno kredy + Graphite Pencil Ołówek grafitowy + Wax Crayon Kredka woskowa + Charcoal Dust Pył węglowy Save Brush Library… Zapisz bibliotekę pędzli… @msg `{_1}` has no brushes to apply `{_1}` nie ma pędzli do zastosowania @msg missing `library` (see brush.libraries) brak `library` (zobacz brush.libraries) diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 530a62719a..e39abfa421 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -6186,6 +6186,11 @@ axis Y Axis Eixo Y Calligraphic Nibs Penas Caligráficas Pattern Borders Bordas de Padrão Scatter Shapes Formas de Dispersão + Dry Media Técnicas Secas + Chalk Grain Grão de Giz + Graphite Pencil Lápis de Grafite + Wax Crayon Giz de Cera + Charcoal Dust Pó de Carvão Save Brush Library… Salvar Biblioteca de Pincéis… @msg `{_1}` has no brushes to apply `{_1}` não tem pincéis para aplicar @msg missing `library` (see brush.libraries) falta `library` (consulte brush.libraries) diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 341dc196f4..75a556f585 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5882,6 +5882,11 @@ axis Y Axis Ось Y Calligraphic Nibs Каллиграфические перья Pattern Borders Узорные каймы Scatter Shapes Рассеянные фигуры + Dry Media Сухие материалы + Chalk Grain Меловое зерно + Graphite Pencil Графитный карандаш + Wax Crayon Восковой мелок + Charcoal Dust Угольная пыль Save Brush Library… Сохранить библиотеку кистей… @msg `{_1}` has no brushes to apply В `{_1}` нет кистей для применения @msg missing `library` (see brush.libraries) не указан `library` (см. brush.libraries) diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index ed59d98113..0cb3950ca9 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -6290,6 +6290,11 @@ axis Y Axis Y-axel Calligraphic Nibs Kalligrafiska pennspetsar Pattern Borders Mönsterbårder Scatter Shapes Spridningsformer + Dry Media Torra medier + Chalk Grain Kritkorn + Graphite Pencil Grafitpenna + Wax Crayon Vaxkrita + Charcoal Dust Koldamm Save Brush Library… Spara penselbibliotek… @msg `{_1}` has no brushes to apply `{_1}` har inga penslar att använda @msg missing `library` (see brush.libraries) `library` saknas (se brush.libraries) diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index c63e000795..47b860d0d1 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -184,6 +184,20 @@ fn font_height_reference_labels_are_in_complete_catalogs() { } } +/// The Window › Brush Libraries menu, built from the built-in library table, names each library. +#[test] +fn built_in_brush_library_names_are_in_complete_catalogs() { + let mut bad = vec![]; + for lang in LANGUAGES.iter().filter(|lang| lang.complete_menus) { + let missing: Vec<&str> = + vectorcraft_engine::cmd::brushlib::BUILTIN.iter().map(|b| b.name).filter(|l| lang.catalog().plain(l).is_none()).collect(); + if !missing.is_empty() { + bad.push(format!("{} is missing {missing:?}", lang.code)); + } + } + assert!(bad.is_empty(), "{}", bad.join("\n")); +} + /// Preference choices and the marquee's E hint share the same translated labels. #[test] fn marquee_selection_labels_are_in_complete_catalogs() { diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index dc89f33802..6d16b3e5f5 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6227,6 +6227,11 @@ axis Y Axis Y ekseni Calligraphic Nibs Kaligrafi Uçları Pattern Borders Desen Kenarlıkları Scatter Shapes Dağılım Şekilleri + Dry Media Kuru Teknikler + Chalk Grain Tebeşir Dokusu + Graphite Pencil Grafit Kalem + Wax Crayon Mum Boya + Charcoal Dust Kömür Tozu Save Brush Library… Fırça Kitaplığını Kaydet… @msg `{_1}` has no brushes to apply `{_1}` içinde uygulanacak fırça yok @msg missing `library` (see brush.libraries) `library` eksik (bkz. brush.libraries) diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 8d67846ecd..8ca9966c75 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5868,6 +5868,11 @@ axis Y Axis Вісь Y Calligraphic Nibs Каліграфічні пера Pattern Borders Візерункові облямівки Scatter Shapes Розсіяні фігури + Dry Media Сухі матеріали + Chalk Grain Крейдяне зерно + Graphite Pencil Графітний олівець + Wax Crayon Восковий олівець + Charcoal Dust Вугільний пил Save Brush Library… Зберегти бібліотеку пензлів… @msg `{_1}` has no brushes to apply У `{_1}` немає пензлів для застосування @msg missing `library` (see brush.libraries) не вказано `library` (див. brush.libraries) diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 8f1ea434fd..a07a2f65b5 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5869,6 +5869,11 @@ axis Y Axis Trục Y Calligraphic Nibs Ngòi bút thư pháp Pattern Borders Viền họa tiết Scatter Shapes Hình phân tán + Dry Media Chất liệu khô + Chalk Grain Hạt phấn + Graphite Pencil Bút chì than chì + Wax Crayon Bút sáp màu + Charcoal Dust Bụi than Save Brush Library… Lưu thư viện cọ… @msg `{_1}` has no brushes to apply `{_1}` không có cọ nào để áp dụng @msg missing `library` (see brush.libraries) thiếu `library` (xem brush.libraries) diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 05c6539bf7..b009ebb080 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -6109,6 +6109,11 @@ axis Y Axis Y 轴 Calligraphic Nibs 书法笔尖 Pattern Borders 图案边框 Scatter Shapes 散布形状 + Dry Media 干性画材 + Chalk Grain 粉笔颗粒 + Graphite Pencil 石墨铅笔 + Wax Crayon 蜡笔 + Charcoal Dust 炭粉 Save Brush Library… 保存画笔库… @msg `{_1}` has no brushes to apply `{_1}` 中没有可应用的画笔 @msg missing `library` (see brush.libraries) 缺少 `library`(请参阅 brush.libraries) diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index cb8a61362e..9e51e4c17d 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5906,6 +5906,11 @@ axis Y Axis Y 軸 Calligraphic Nibs 書法筆尖 Pattern Borders 圖樣邊框 Scatter Shapes 散佈形狀 + Dry Media 乾性畫材 + Chalk Grain 粉筆顆粒 + Graphite Pencil 石墨鉛筆 + Wax Crayon 蠟筆 + Charcoal Dust 炭粉 Save Brush Library… 儲存筆刷資料庫… @msg `{_1}` has no brushes to apply `{_1}` 中沒有可套用的筆刷 @msg missing `library` (see brush.libraries) 未提供 `library`(請參閱 brush.libraries) diff --git a/crates/ui-egui/src/panels/tests_brush_libraries.rs b/crates/ui-egui/src/panels/tests_brush_libraries.rs index 6918e9118f..e57c127319 100644 --- a/crates/ui-egui/src/panels/tests_brush_libraries.rs +++ b/crates/ui-egui/src/panels/tests_brush_libraries.rs @@ -135,9 +135,12 @@ fn the_window_menu_and_the_brushes_panel_open_brush_libraries() { json!("pattern-borders"), json!("scatter-shapes"), json!("game-trails"), - json!("energy-beams") + json!("energy-beams"), + json!("dry-media") ] ); + let dry = entries.iter().find(|e| under(e) && e.params["library"] == "dry-media").map(|e| e.label.as_str()); + assert_eq!(dry, Some("Dry Media")); let enabled = |cmd: &str| entries.iter().find(|e| under(e) && e.command.as_deref() == Some(cmd)).map(|e| e.enabled); assert_eq!(enabled("window.brushLibrary.other"), Some(true)); assert_eq!(enabled("ui.saveBrushLibrary"), Some(true), "a new document has the default brushes"); @@ -151,6 +154,13 @@ fn the_window_menu_and_the_brushes_panel_open_brush_libraries() { for t in ["Open Brush Library", "Save Brush Library…"] { assert!(texts.iter().any(|x| x.ends_with(t)), "{t}: {texts:?}"); } + // Dry Media opens in a library panel, and a click on Chalk Grain adds it in one step. + let r = app.run("window.brushLibrary", json!({"library": "Dry Media"})).unwrap(); + assert_eq!((r["open"].as_str(), r["count"].as_u64()), (Some("dry-media"), Some(4))); + let undo = undo_len(&app); + click(&mut app, &ctx, "Chalk Grain", Modifiers::NONE, 1.0); + assert!(has(&app, "Chalk Grain") && !has(&app, "Wax Crayon")); + assert_eq!(undo_len(&app), undo + 1); // Without brushes there is nothing to save. let all: Vec = vectorcraft_brush::library(&app.session.doc().unwrap().doc).into_iter().map(|b| b.name).collect(); app.run("brush.delete", json!({ "names": all })).unwrap(); diff --git a/docs/gaps.md b/docs/gaps.md index a228181abb..271bce11b4 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -631,8 +631,8 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi libraries ([public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/create-new-brush-libraries.html), [import](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/import-brushes-from-brush-libraries-or-other-files.html)): Window › Brush Libraries and the Brushes panel's Brush Libraries Menu open each library in a library panel of - its own (six original libraries generated in code: Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter - Shapes, Game Trails and Energy Beams; User Defined; Other Library… reads the brushes of any document VectorCraft opens); a + its own (seven original libraries generated in code: Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter + Shapes, Game Trails, Energy Beams and Dry Media (grainy chalk, pencil, crayon and charcoal art brushes); User Defined; Other Library… reads the brushes of any document VectorCraft opens); a click adds a brush to the document's Brushes with the patterns, symbols and other resources its art uses, as one undo step (applied to the selected paths in the same step), dragging one onto a path adds and applies it and @@ -650,7 +650,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi Game HUD with Health Fill, Mana Glow, Damage Flash, Quest Outline, Coin Shine and Boss Alert, and Status Effects with Poison Tint, Freeze Edge, Burn Aura, Stun Pulse, Stealth Fade and Shield Buff; User Defined; Other Library…; `graphicStyle.libraries`, `graphicStyle.addFromLibrary`). -- **Missing:** more built-in brush libraries beyond the six shipped (and further graphic-style libraries beyond the eight shipped, and further symbol libraries beyond Basic +- **Missing:** more built-in brush libraries beyond the seven shipped (and further graphic-style libraries beyond the eight shipped, and further symbol libraries beyond Basic Shapes, UI Frames, Map Markers, Inventory Icons and Skill Icons; original, generated in code, never Adobe's); dynamic symbols' `.ai` read and write; Global Edit of groups holding type or images, rotated copies as matches and highlighting matches before the start; 9-slice guides outside the symbol's art (a dragged guide stops at the art's edge). diff --git a/docs/mcp.md b/docs/mcp.md index 7f6df79837..501e24d284 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3402,8 +3402,9 @@ error. ## Brush libraries -Brush libraries are read-only sets of brushes: six generated in code (Art Ribbons, Calligraphic Nibs, Pattern -Borders, Scatter Shapes, Game Trails, Energy Beams), the `.vectorcraft` files in the user library folder of the desktop app (User +Brush libraries are read-only sets of brushes: seven generated in code (Art Ribbons, Calligraphic Nibs, Pattern +Borders, Scatter Shapes, Game Trails, Energy Beams, and Dry Media with the Chalk Grain, Graphite Pencil, Wax Crayon and +Charcoal Dust art brushes, id `dry-media`), the `.vectorcraft` files in the user library folder of the desktop app (User Defined) and the brushes of any document loaded with `brush.loadLibrary` (a document without brushes of its own has the default ones). `brush.libraries` lists them (`id`, `name`, `category`: `builtIn`, `user` or `loaded`, `count`) and `brush.library {library}` returns one's brushes (`name`, `type`; `library` is an id or a name). diff --git a/docs/roadmap.md b/docs/roadmap.md index 41066cbb49..92275336aa 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -149,7 +149,7 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l | M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type, and the Edit Custom Dictionary dialog (#4936; the Check Spelling dialog remains); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Hanging punctuation keeps its published amounts under Glyph Scaling, and Standard burasagari counts a hung line start as room; Fullwidth `!` / `?` keep an upright fallback in vertical type; Hyphenation… and Justification… per paragraph (#2310, #2755), Auto Leading also driving the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel (including font-defined Stylistic Sets), Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | | M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Anchor Point spine conversion/handle editing and selection priority (#2544), with #2425 Shift reshape preserved; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 | | M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder, Color Adjustments and Document Raster Effects Settings; PDF export warns when the pixel cap lowers raster-effect resolution; Artistic › Colored Pencil, Cutout, Dry Brush, Film Grain, Fresco, Neon Glow, Paint Daubs, Palette Knife, Poster Edges, Rough Pastels, Smudge Stick, Sponge, Underpainting and Watercolor and Sketch › Halftone Pattern, Bas Relief, Charcoal, Conté Crayon, Photocopy, Stamp, Torn Edges and Water Paper done (57/57 Photoshop filters); Revolve with automatic smooth vector shading, default-on circular caps and continuous rims, Extrude & Bevel, Inflate and Rotate with one to ten colored directional lights and four original lighting presets; SVG Filters, remaining raster effects and 3D materials and shadows pending | 81–130 | -| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol libraries done (Window › Symbol Libraries, five generated libraries (Basic Shapes, UI Frames, Map Markers, Inventory Icons, Skill Icons), User Defined, Other Library…, Save Symbols); brush libraries done (Window › Brush Libraries, six generated libraries, User Defined, Other Library…, Save Brushes); symbol editing shows draggable 9-slice guides (#5280), with guides outside the artwork still unsupported; brushes/symbols in progress | 23–37 | +| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol libraries done (Window › Symbol Libraries, five generated libraries (Basic Shapes, UI Frames, Map Markers, Inventory Icons, Skill Icons), User Defined, Other Library…, Save Symbols); brush libraries done (Window › Brush Libraries, seven generated libraries including Dry Media, User Defined, Other Library…, Save Brushes); symbol editing shows draggable 9-slice guides (#5280), with guides outside the artwork still unsupported; brushes/symbols in progress | 23–37 | | M11 | Artboards & views (selected-set deletion shared by Artboards menu, trash and Properties, retaining artboard 1 when all are selected; Artboard deletion keys keep the first of an all-selected set and protect artwork; history prunes invalid indices while preserving valid multiselection (history tests and full workspace CI pass); click-to-fit objects with the Artboard tool (#1227), artboard escape routing and document setup modal handoff done; artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator with synchronized Artboard tool selection done; Window › Arrange › Tile and Consolidate All Windows done; multiple windows, presentation polish) (cached canvas reprojection preserves scale during resize, rotation and DPI changes; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) (cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; regression-tested; estimates unchanged); status-bar pointer coordinates follow the active ruler origin and General units | 🟡; Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting includes all drawn document art across artboards and off-board; artwork bounds are cached by active document and revision, and selection uses the shared bounds cache across artboards | 15–25 | | M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (junction-returning pieces retain their positioned boundaries; a simpler contour walk with identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments; large images trace with a warning; Auto Color keeps rare distinct colours; quick presets trace at once and the Image Trace button opens the adjustment controls; Properties reopens them; tiny fitted contours retain islands and holes; Create Strokes: exact crop EDT, Guo–Hall thinning and maximal-disc graph simplification; 15 presets, the 14 documented ones each with an enhanced version; Ignore Color, Shapes, Auto Grouping and Gradients options; measured in `image-trace-quality.md`; closed outlines retain their interior after straightening; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients; live trace result bounds and selection highlights exclude the retained bitmap except in source-image views), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; Graph Design and Marker designs, Transpose and Switch x/y, `\|` label line breaks, the pie options, drop shadows, the legend across the top, overlapping widths, First Row/Column in Front, filled lines, kept label formatting and the Graph Data worksheet done; graph Column designs pending; Vector Halftone Preview keeps the selection box without drawing dot selection marks (#6369) | 6–10 | | M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 MCP `add_text` exposes existing vertical point, area and path type; `script.run` and File › Scripts › Other Script… run JavaScript files, and Window › Script Console shows their output; the script document model and batch work remain | 18–27 | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 908895ad64..7269f440a0 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -218,7 +218,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Drawing tools (Pen, Curvature, Pencil, shapes, Shaper) | 7 | 85% | Shaper construction widget, Pencil/Pen nuances, Blob Brush options, Touch Type, Dimension tool; see [ui-parity.md](ui-parity.md) | 8–13 | estimated; Curvature point display regression-tested (#1236, in part); Polygon/Star exact-size count persistence regression-covered; Live Corners' Relative rounding done (#3091), score unchanged; Copy and Cut with the Pen or Curvature tool take the anchors a Cmd/Ctrl-lent Direct Selection picked (#6123), score and hours unchanged | | Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 80% | Shape Builder on the reporter's file (#937: art past 4,096 segments is cut by lines again; unconfirmed on the file itself); gap detection shares Live Paint’s finder, with a 72 pt custom cap and the existing options; Live Paint Gap Options | 4–8 | estimated; Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog (lowered from 86%: open issues); compound attributes regression-tested (#1197); single-result source names survive SVG export/import (#1293), with [SVG ID semantics](https://www.w3.org/TR/SVG2/struct.html#IDAttribute) and original-shape tests; Simplify component selection and preview regression-tested (#1272); Simplify of a Direct Selection part of a path regression-tested (`tests/simplify_part.rs`); Divide open-cutting-path geometry and appearance regression-tested (#1307); the Shape Builder's edge-source grid is bounded in memory and files a segment once per cell (`planar.rs` test `rect_index_holds_a_bounded_number_of_references`); live compound shapes (Make, Alt-click, member Shape Modes, Release, Expand, saved and exported) regression-tested (#4522) | | Colour, swatches, gradients, patterns, mesh, recolor | 7 | 92% | Eyedropper copies effective character dimensions across text transforms (#6404); 30.0's gradient additions are done (dither, perceptual blending, presets); freeform gradients in SVG/PDF, mixed spot/process gradients in SVG, PDF gradients mixing a spot color with RGB or Lab stops, and dithered spot gradient fills and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, which PDF export writes as images in process colors| 4–7 | estimated; Recolor model selection is available through the engine and MCP (#2520), and the dialog's color sliders (HSB, RGB, CMYK, Lab, Tint for a spot color) edit the selected row's new color in its mode, and the Edit tab's Global Adjust sliders (Saturation, Brightness, Temperature, Luminosity, -100% to 100%) change every recolored color at once (lowered from 96%: target moved); unpainted zero-width stroke restoration fixed (#1253), score and hours unchanged; Eyedropper source reads regression-tested (#1251); screen-bounded Recolor Edit chip list (#3416), score and hours unchanged | -| Strokes, brushes, width profiles | 5 | 88% | Shift-double-click adjoining adjustment is implemented; brush libraries are done (six generated libraries including Game Trails and Energy Beams, User Defined, Other Library…, Save Brushes); Art brush Overlap and Pattern brush auto corners are done; remaining: pen pressure on macOS, Stylus Wheel, more built-in brush libraries, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | estimated; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed; evidence below; finite-arithmetic follow-up regressions and full CI passed (evidence below) | +| Strokes, brushes, width profiles | 5 | 88% | Shift-double-click adjoining adjustment is implemented; brush libraries are done (seven generated libraries including Game Trails, Energy Beams and Dry Media, User Defined, Other Library…, Save Brushes); Art brush Overlap and Pattern brush auto corners are done; remaining: pen pressure on macOS, Stylus Wheel, more built-in brush libraries, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | estimated; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed; evidence below; finite-arithmetic follow-up regressions and full CI passed (evidence below) | | Appearance, transparency, graphic styles, masks | 5 | 97% | raster effect reach in preview bounds | 1–2 | estimated; Transform typing, sliders, preview, and undo are covered in `crates/ui-egui/src/panels/tests_appearance.rs` | | 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, painter visibility); materials, light presets, shadows, ray tracing, the rest of 3D Classic (Revolve, Rotate, Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | | Type core | 9 | 80% | Auto-leading style resets are implemented; Hyphenation… and Justification… options are done (#2310, #2755), and a paragraph's Auto Leading also sets the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; remaining: Optical Margin Alignment, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated; eleven-language pattern hyphenation in both composers, with grapheme and shaping-cluster protection (#4947, `hyphen::tests` and `layout::wrapping_tests`); Sanskrit patterns pending an allowlisted source; recent-font groups in font pickers (#3515); full-name font matches regression-tested (#1258); mixed paragraph alignment indication implemented; headless regressions, full integration CI and visual review pass; localized legacy family-name fallback with loaded/catalog regressions (#2208); Fractional Widths per type object regression-tested; color emoji fonts with bitmap glyphs (`sbix`, `CBDT`) draw and export; threading ports: every selected area type and type on a path shows its in and out ports (empty, arrow, red "+"), and the Selection tool threads from either, the in port into a frame before it, and a double-click on a threaded port breaks the thread there (`threads::tests::break_splits_the_thread_and_the_story_flows_into_the_first_part`, `select::tests::a_double_click_on_a_threaded_port_breaks_the_thread`, `canvas::tests::a_double_click_on_a_threaded_out_port_breaks_the_thread`) (#2572; `select::tests::a_click_on_the_in_port_threads_into_a_frame_before_it`, `text::tests::selected_type_shows_its_ports_in_each_state`, `threads::tests::add_before_threads_the_story_from_the_new_frame_into_the_clicked_one`, live screenshot); the Glyphs panel highlights and scrolls to the character selected with the Type tool (#4908, `panels::glyphs` tests); the OpenType panel lists only `ss01`–`ss20` sets present in the selected font and uses its custom names when supplied; Type-range desktop Copy publishes styled art and keeps font family, size and color on canvas Paste after editing (#5636); paint-only Arabic harakat keep joining with conditional GDEF-mark ownership and Insert Kashidas base-letter paint (`tests::harakat`) | @@ -487,7 +487,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Drawing tools | 7 | 86% | rough-square Shaper recognition and orientation steps corrected; editable Shaper Groups landed; Pencil: Alt key toggles to Smooth Tool (#2001); remaining: Shaper construction widget/gesture refinements, pencil and the remaining pen modifier nuances, Blob Brush Merge Only with Selection, Touch Type, Live Corners' Relative rounding | 7–12 | | Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 86% | Live Paint gap options, Shape Builder gap detection, options (Tool Options dialog, clicking a stroke in merge mode) and Shift-drag marquee | 3–6 | | Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | freeform gradients in SVG/PDF and mixed spot/process gradients in SVG (exported as stops; PDF export keeps the spot inks in a DeviceN shading, except next to RGB or Lab stops; dithered fills and Perceptual gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve are written as images in process colors), pattern fills in Expand | 2–4; Eyedropper source reads regression-tested (#1251) | -| Strokes, brushes, width profiles | 5 | 90% | more built-in brush libraries (six generated in code including Game Trails and Energy Beams are done; Art brush Overlap and Pattern brush auto corners are done), pen pressure on macOS (the windowing layer reports none there), Stylus Wheel, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | +| Strokes, brushes, width profiles | 5 | 90% | more built-in brush libraries (seven generated in code including Game Trails, Energy Beams and Dry Media are done; Art brush Overlap and Pattern brush auto corners are done), pen pressure on macOS (the windowing layer reports none there), Stylus Wheel, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | | Appearance, transparency, graphic styles, masks | 5 | 98% | raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 | | Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap (since done: #3061, #3063, #3785), SVG Filters, Transform Patterns and Scale Strokes & Effects on a fill's or stroke's own Transform effect | 6–10 | | 3D and Materials | 4 | ~25% | Initial Revolve, Extrude & Bevel, Inflate and Rotate, with simple lighting; materials, light presets, shadows, ray tracing, Revolve (Classic), Rotate (Classic), Map Art and more complete visibility remain | 25–45 | @@ -539,7 +539,7 @@ since 2026-10-01. | **Interaction fidelity:** modifiers, cursors, small behaviours | ~30–40% | The dedicated pass hasn't started, and there has been no side-by-side session with Illustrator yet. A power user notices this first | | **Look & feel** | ~75–80% | Measured against Illustrator 2026 screenshots (2026-10-02); the panels added since haven't been re-measured | | **File interop** | ~85% | SVG/SVGZ, PDF and PDF-compatible `.ai` (and legacy version 7 `.ai` out, keeping layers, groups and type as text), EPS, DXF, EMF/WMF, raster formats, PSD export and PSD/PSB placement (merged image), Place and Links, Package, Print, clipboard flavours. Affinity documents (Affinity 1–3) open natively as editable art, checked against 51 pinned public files (33 current `.af`), with selected saved-thumbnail comparisons and every-board native/SVG/PDF/PSD checks for current files; Affinity export is missing and effects, adjustments and brushes don't import. Affinity isn't an Illustrator feature, so this doesn't raise the parity score. The editing copy inside an Illustrator EPS or `.ai` is read for its structure: layers, groups, names, hidden objects and type, colours, gradients, transparency, images and every artboard (see Out of scope for what it doesn't read yet); DWG has no open spec | -| **Bundled content:** brush, symbol, style and swatch libraries | ~46% | Ours are original and generated in code, and far fewer than Illustrator ships; symbol libraries have five (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons, Skill Icons), graphic style libraries eight (including Game HUD and Status Effects) and brush libraries six (Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams) beside User Defined and Other Library… | +| **Bundled content:** brush, symbol, style and swatch libraries | ~46% | Ours are original and generated in code, and far fewer than Illustrator ships; symbol libraries have five (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons, Skill Icons), graphic style libraries eight (including Game HUD and Status Effects) and brush libraries seven (Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams, Dry Media) beside User Defined and Other Library… | | **Performance** | unverified | Multithreaded, off-thread rendering and caches are in place. The last budget run (2026-10-01, loaded machine) measured 290 ms for a 50k-path fit against a 16 ms budget. Re-run `vectorcraft-cli perf` on an idle machine | | **Robustness** | good, new | ~2,840 tests, property tests, no panics in shipped code (lints, the `guard` safety net, import fuzzing), Data Recovery. Missing: a corpus of real-world files, Windows/Linux/browser QA | | **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work, and Finder opens every readable format with the macOS app (#295, #354); on macOS the menus are in the system menu bar, laid out as a Mac app's, with the in-window menus as a fallback (`--in-window-menus`; verified on Windows and by cross-compilation, not yet by hand on a Mac); hybrid-graphics laptops render on the power-saving GPU by default on Windows and macOS (#306), and Windows starts on Direct3D 12, falling back to OpenGL, and loads no Vulkan driver unless `WGPU_BACKEND` asks for it (#545: Vulkan flickered black on an Intel GPU, DX12 and OpenGL didn't; #806: a faulty Vulkan driver crashed the app before its window appeared; verified by unit tests and the reporter's runs with `WGPU_BACKEND`), and Linux on the GPU the desktop runs on, since a Wayland compositor may refuse frames from the other one; a window that fails on its GPU while starting up starts again on the next one instead of crashing (#502; the dual-GPU Wayland case is verified by unit tests and cross-compilation, not on such hardware); web drops land in the document they were dropped on (#359); desktop drops carry no pointer position (winit 0.30), so a dropped document opens as a tab of its own and a picture or text is placed in the middle of the view, while the web places anything dropped on the canvas and opens what is dropped off it, as Illustrator does (#472); a web page that loses its WebGPU device or WebGL context restarts its graphics with the same documents (#369); on Linux/Wayland the clipboard reads the compositor's own (data-control) and copied image files paste, but files can't be dropped on the window under native Wayland until eframe moves to winit 0.31 (#398; the Wayland side is verified by compilation only), so Preferences › Performance › Linux Display Server › X11 (XWayland) opens the window through XWayland, where drops work (#1151; verified by unit tests and cross-compilation, not on a Wayland desktop); on Linux the Paste menu checks the system clipboard on a background thread, so a clipboard owner that never answers no longer freezes the window, while Windows and macOS keep their quick in-line check (#510; the X11 hang verified by the contributor, here by headless tests); on KDE Plasma 6.3+ under Wayland, pens and pen displays reach the app only under XWayland (`WAYLAND_DISPLAY=`) or with KWin's `KWIN_WAYLAND_EMULATE_TABLET=1`, since winit 0.30 has no Wayland tablet input (#491, not verified on hardware); Windows/Linux packaging and accessibility are pending | @@ -836,7 +836,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Expand of symbol instances:** Object › Expand turns selected symbol instances (without effects, not in perspective, in groups or placed documents too) into their art as Break Link to Symbol places it, one level deep, as one undo step; the Expand dialog's Fill and Stroke reach that art (`object.expand`, `crates/engine/src/tests/expand_symbols.rs`; estimated, score unchanged). - **Reset Transformation** ([Transform symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/transform-symbols.html), [Symbols panel options](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html)): the Symbols panel menu's Reset Transformation and the Control bar's Reset, shown while symbol instances are selected, give the selected instances their symbol's own scale again, without rotation or shear, as one undo step (`symbol.instance.resetTransform`); an instance in perspective is left as it is. VectorCraft's own rules: each instance keeps the center of its bounding box in place, reflection is removed too, and the menu item and the button are enabled only while a selected instance would change. Tests: `tests::brushsym::reset_transformation_restores_the_symbols_scale_and_keeps_the_center`, `reset_transformation_keeps_the_center_of_art_away_from_the_origin`, `reset_transformation_ignores_a_stored_size_that_is_not_positive`, `reset_transformation_leaves_unchanged_instances_and_refuses_bad_ids`, `panels::symbols::tests::reset_transformation_is_enabled_for_a_transformed_instance_and_resets_it`. - **Expand of dimensions:** Object › Expand, Expand Appearance, Create Outlines and Outline Stroke turn a dimension whose art they change into a plain group of that art, which stays as it is through later moves, scales and edits of what it measured; undo gives the live dimension back (`crates/engine/tests/it/dimensions.rs`; VectorCraft's own rule; estimated, score unchanged). - - **Brush libraries** ([public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/create-new-brush-libraries.html), [import](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/import-brushes-from-brush-libraries-or-other-files.html), [Brushes panel](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/brushes-panel-overview.html)): Window › Brush Libraries, the Brushes panel's Brush Libraries Menu and its menu's Open Brush Library open each library in a library panel of its own, as swatch, graphic style and symbol libraries do. The built-in libraries (Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams) are original brushes generated in code (`crates/engine/src/cmd/brushlib.rs`); Striped Ribbon's art is filled with the library's own pattern and Sparkles scatters the library's own symbol; Game Trails adds motion art brushes (Speed Line, Dash Trail, Burst Ray, Zigzag); Energy Beams adds laser and plasma art brushes (Laser Bolt, Plasma Arc, Charge Wave, Spark Trail). A click on a brush adds it to the document's Brushes as one undo step with every resource its art uses (patterns, symbols, swatches, graphic styles, images), makes it the current brush and, with paths selected, applies it to them in the same step; a brush the document already has is not added again and a different brush of the same name comes in numbered ("Wave 2"). Shift/Cmd-clicks select brushes for Add to Brushes; a brush dragged onto a path is added and applied to it, and one dragged onto the Brushes panel is added. Save Brushes… / the Brushes panel's Save Brush Library… write the document's brushes (or the selected ones) as a `.vectorcraft` library into the `Brushes` folder next to the preferences, listed under User Defined, or to a file; Other Library… reads the brushes of any document VectorCraft opens. Thumbnails, in the library panels, the Brushes panel and Brush Options, render brush art with the patterns and symbols it uses. Copying brushed art now carries the patterns and symbols its brush's art uses too. Tests: `crates/engine/src/tests/brushlib.rs`, `crates/ui-egui/src/panels/tests_brush_libraries.rs`, `panels::brushes::tests::library_brush_previews_show_the_patterns_their_art_uses`, `canvas::tests::a_library_brush_dropped_on_a_path_is_added_and_applied_in_one_step`. + - **Brush libraries** ([public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/create-new-brush-libraries.html), [import](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/import-brushes-from-brush-libraries-or-other-files.html), [Brushes panel](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/brushes-panel-overview.html)): Window › Brush Libraries, the Brushes panel's Brush Libraries Menu and its menu's Open Brush Library open each library in a library panel of its own, as swatch, graphic style and symbol libraries do. The built-in libraries (Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams, Dry Media) are original brushes generated in code (`crates/engine/src/cmd/brushlib.rs`); Striped Ribbon's art is filled with the library's own pattern and Sparkles scatters the library's own symbol; Game Trails adds motion art brushes (Speed Line, Dash Trail, Burst Ray, Zigzag); Energy Beams adds laser and plasma art brushes (Laser Bolt, Plasma Arc, Charge Wave, Spark Trail). A click on a brush adds it to the document's Brushes as one undo step with every resource its art uses (patterns, symbols, swatches, graphic styles, images), makes it the current brush and, with paths selected, applies it to them in the same step; a brush the document already has is not added again and a different brush of the same name comes in numbered ("Wave 2"). Shift/Cmd-clicks select brushes for Add to Brushes; a brush dragged onto a path is added and applied to it, and one dragged onto the Brushes panel is added. Save Brushes… / the Brushes panel's Save Brush Library… write the document's brushes (or the selected ones) as a `.vectorcraft` library into the `Brushes` folder next to the preferences, listed under User Defined, or to a file; Other Library… reads the brushes of any document VectorCraft opens. Thumbnails, in the library panels, the Brushes panel and Brush Options, render brush art with the patterns and symbols it uses. Copying brushed art now carries the patterns and symbols its brush's art uses too. Tests: `crates/engine/src/tests/brushlib.rs`, `crates/ui-egui/src/panels/tests_brush_libraries.rs`, `panels::brushes::tests::library_brush_previews_show_the_patterns_their_art_uses`, `canvas::tests::a_library_brush_dropped_on_a_path_is_added_and_applied_in_one_step`. + - **Dry Media library** ([help page](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/about-brushes.html)): Window › Brush Libraries › Dry Media holds four art brushes with textured strokes like dry drawing materials, each 100 pt long: Chalk Grain (about 8 pt high), Graphite Pencil (about 2.3 pt), Wax Crayon (about 5.5 pt) and Charcoal Dust (about 7 pt). The brushes, their names, sizes and art are VectorCraft's own: each is filled vector paths from a fixed seed, at most 80 paths and 500 anchors, stretched along the path with Tints colorization, and the chalk and crayon art has even-odd gaps where the paper shows (`brush.libraries`, `brush.addFromLibrary`; `the_dry_media_library_holds_grainy_art_brushes_with_bounded_art`). - A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, Remove Anchor Points when anchors are selected, masks, compound paths, guides, Transform, Arrange, Select, Export Selection, and on type Bullets and Numbering and Spelling › Auto Spell Check, checked while it is on), or the view commands on empty canvas. - A right-click over locked objects adds an Unlock submenu to the canvas context menu. The submenu lists up to 20 locked objects under the pointer, topmost first, by the name the Layers panel shows, and choosing one unlocks it (`layer.setProps`). A locked group is listed as one item. Objects on hidden, locked or template layers are not listed. - Native macOS save sheets offer a localised Format menu (#866), built from the request's existing format filters. It selects the suggested filename's format and updates the suffix when changed, including legacy `.drawcraft` names. `apps/vectorcraft/src/mac_save_format.rs` has filename regressions; an actual native SVG save, selection of PDF and cancellation without changing the saved document were verified. From f8c234e67030e5b765ccf3ba3ae02e8d79a0c5be Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 21:08:07 -0800 Subject: [PATCH 05/26] Type: Dynamic Text turns point type into area type with Block on Type > Dynamic Text and the Contextual Task Bar now also take point type. Turning it on makes the point type area type with Block on, in the same undo step: - Along the lines, the frame is as wide as the widest line as typed with its paragraph's indents, measured with the word gap, so that line keeps its size and stays where it was across the page by its own alignment. The other lines are set to that width. When negative indents take more than that line's width, the frame is as wide as the line, placed so the line keeps its aligned point, and Block makes the line larger. - The frame starts at the top of the text and grows down (to the left for vertical type) to hold every fitted line, around any Text Wrap objects above it, so nothing overflows. - The new frame takes the default Area Type Options and keeps only the gaps; options kept from an earlier frame do not carry over. - Point type with no line to fit (empty, or only spaces and breaks) is left as it is, and so is point type when `on` is false. Without `on`, point type counts as off, and the Type menu item is unchecked when point type comes first in the selection. A gap given with nothing it can apply to is an error. Block now subtracts a list level's indent from the room it sets each line to, as the layout does. Before, a nested list item was first set too large for its line and then made smaller on a later pass. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/engine/src/cmd/typecmd.rs | 1 + crates/engine/src/cmd/typecmd/block.rs | 35 ++- crates/engine/src/cmd/typecmd/block_tests.rs | 252 ++++++++++++++++++- crates/engine/src/cmd/typemenu.rs | 173 +++++++++++-- crates/engine/src/tests/dynamic_text.rs | 10 +- crates/ui-egui/src/canvas/task_bar.rs | 4 +- crates/ui-egui/src/menus/state.rs | 6 +- crates/ui-egui/src/menus/tests.rs | 12 +- crates/ui-egui/src/tests/taskbar.rs | 16 ++ docs/mcp.md | 19 +- docs/target-app-parity.md | 1 + 11 files changed, 487 insertions(+), 42 deletions(-) diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 33dad39d93..d492917bfe 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -1365,6 +1365,7 @@ fn set_path_handle(s: &mut Session, p: &Value) -> Result { #[cfg(test)] mod area_tests; mod block; +pub(crate) use block::needed_room; #[cfg(test)] mod block_tests; diff --git a/crates/engine/src/cmd/typecmd/block.rs b/crates/engine/src/cmd/typecmd/block.rs index 06453f4983..c10abbc965 100644 --- a/crates/engine/src/cmd/typecmd/block.rs +++ b/crates/engine/src/cmd/typecmd/block.rs @@ -89,6 +89,39 @@ fn line_widths(db: &vectorcraft_text::FontDb, t: &TextObject) -> std::collection out } +/// The room along its lines that a frame of `t`'s text needs for Block to keep the widest line as +/// typed at its present size: that line's width plus its paragraph's indents, as [`block_fit`] +/// measures them, and a margin of under half a size step so that the line is not made a step +/// smaller; with the widest line's first byte and its paragraph's index. A line whose negative +/// indents take more than its width needs its own width. None when no line has a width. +pub(crate) fn needed_room(t: &TextObject) -> Option<(f64, usize, usize)> { + let points = t.style_scale().map(|s| s.points).filter(|p| p.is_finite() && *p > 1e-12)?; + let text = t.plain_text(); + let lines = typed_lines(&text); + let widths = line_widths(vectorcraft_text::FontDb::global(), t); + let sizes = first_sizes(&t.runs, &lines); + lines + .iter() + .zip(&sizes) + .filter_map(|(l, size)| { + let width = widths.get(&l.start).copied().filter(|w| w.is_finite() && *w > 1e-9)?; + let size = size * points; + if l.content == l.start || !(size.is_finite() && size > 0.0) { + return None; + } + let para = t.para_at(l.para); + let first = if l.para_start { para.first_line_indent } else { 0.0 }; + let indents = para.laid_out_left_indent() + para.right_indent + first; + // Negative indents that take more than the line's width leave the line no room at its + // size; the frame is then as wide as the line itself, and Block makes it larger. + let room = if width + indents > 1e-9 { width + indents } else { width }; + // A tenth of a point more size widens the line by 0.1 * width / size. + Some((room + 0.04 * width / size, l.start, l.para)) + }) + .filter(|r| r.0.is_finite() && r.0 > 1e-9) + .reduce(|a, b| if b.0 > a.0 { b } else { a }) +} + /// The size a line set at `size` points and `width` wide takes to fill `avail`, its width growing /// by `slope` a point of size (`width / size` when the whole line follows its size): rounded down /// to tenths and kept within [`SIZES`]. A line that fits and would not fit a tenth larger keeps its @@ -152,7 +185,7 @@ pub(crate) fn block_fit(t: &mut TextObject) -> bool { let width = widths.get(&l.start).copied().filter(|w| w.is_finite() && *w > 1e-9)?; let para = t.para_at(l.para); let first = if l.para_start { para.first_line_indent } else { 0.0 }; - let avail = cell - para.left_indent - para.right_indent - first; + let avail = cell - para.laid_out_left_indent() - para.right_indent - first; let size = size * points; if !(size.is_finite() && size > 0.0) { return None; diff --git a/crates/engine/src/cmd/typecmd/block_tests.rs b/crates/engine/src/cmd/typecmd/block_tests.rs index ec458a0c9b..0354a630b3 100644 --- a/crates/engine/src/cmd/typecmd/block_tests.rs +++ b/crates/engine/src/cmd/typecmd/block_tests.rs @@ -188,13 +188,25 @@ fn bad_params_and_other_type_are_refused() { assert!(!text(&s, id).dynamic_text); let point = s.execute("text.create", &json!({"x": 10, "y": 500, "text": "point"})).unwrap()["id"].as_u64().unwrap(); s.execute("select.set", &json!({"ids": [point]})).unwrap(); + // Off leaves point type as it is, with no undo step, and a gap needs it turned on. + let n = undo_len(&s); + assert_eq!(s.execute("type.dynamicText", &json!({"on": false})).unwrap()["ids"], json!([point])); + let e = s.execute("type.dynamicText", &json!({"on": false, "wordGap": 200})).unwrap_err().to_string(); + assert!(e.contains("need area type"), "{e}"); + assert_eq!(undo_len(&s), n); + assert!(matches!(text(&s, point).kind, TextKind::Point)); + let shape = s.execute("shape.rectangle", &json!({"x": 500, "y": 500, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [shape]})).unwrap(); let e = s.execute("type.dynamicText", &json!({})).unwrap_err().to_string(); assert!(e.contains("select type on a path or area type"), "{e}"); - // With point type selected too, only the area type changes. + // With point type selected too, both turn on, the point type as area type with Block on. s.execute("select.set", &json!({"ids": [id, point]})).unwrap(); + let n = undo_len(&s); let r = s.execute("type.dynamicText", &json!({})).unwrap(); - assert_eq!(r["ids"], json!([id])); - assert!(text(&s, id).dynamic_text && !text(&s, point).dynamic_text); + assert_eq!(r["ids"], json!([id, point])); + assert!(text(&s, id).dynamic_text && text(&s, point).dynamic_text); + assert!(matches!(text(&s, point).kind, TextKind::Area { .. })); + assert_eq!(undo_len(&s), n + 1); // A gap with only type on a path selected is an error, and nothing changes. let path = s.execute("path.create", &json!({"d": "M60 550 L340 550"})).unwrap()["id"].as_u64().unwrap(); let along = s.execute("text.createInPath", &json!({"path": path, "mode": "onPath", "text": "Along"})).unwrap()["id"].as_u64().unwrap(); @@ -438,6 +450,228 @@ fn threaded_frames_are_refused_and_threading_turns_block_off() { assert!(matches!(text(&s, a).kind, TextKind::Area { .. })); } +/// 12 pt point type at (300, 100) with `extra` params for `text.create`, selected. +fn point(s: &mut Session, text: &str, extra: Value) -> u64 { + let mut p = json!({"x": 300, "y": 100, "size": 12, "text": text}); + if let (Some(p), Some(extra)) = (p.as_object_mut(), extra.as_object()) { + p.extend(extra.clone()); + } + let id = s.execute("text.create", &p).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + id +} + +/// Where the first glyph of the laid-out line starting at byte `start` is in the document. +fn line_start(t: &TextObject, start: usize) -> vectorcraft_geom::Point { + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + let l = lay.lines.iter().find(|l| l.start == start).expect("a line starts there"); + t.xf * lay.glyphs[l.glyph_start].origin +} + +#[test] +fn point_type_becomes_area_type_with_block_at_its_widest_line() { + for justify in ["left", "center", "right"] { + let mut s = session(); + let id = point(&mut s, "Short\nA much longer line", json!({})); + s.execute("text.setStyle", &json!({"ids": [id], "justify": justify})).unwrap(); + let before = line_start(&text(&s, id), 6); + let n = undo_len(&s); + let r = s.execute("type.dynamicText", &json!({})).unwrap(); + assert_eq!((r["on"].clone(), r["ids"].clone()), (json!(true), json!([id]))); + assert_eq!((r["wordGap"].clone(), r["lineGap"].clone()), (json!(100.0), json!(0.0)), "the reply gives the new frame's gaps"); + assert_eq!(undo_len(&s), n + 1, "{justify}: one undo step"); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Dynamic Text"); + let t = text(&s, id); + assert!(t.dynamic_text && matches!(t.kind, TextKind::Area { .. }), "{justify}"); + assert_eq!(t.area.fit, AreaFit::None); + assert_block(&t); + assert_eq!(size_at(&t, 6), 12.0, "{justify}: the widest line keeps its size"); + assert!(size_at(&t, 0) > 24.0, "{justify}: the short line grows to the frame's width: {}", size_at(&t, 0)); + // Across, the widest line stays where it was; the short line above it grew, so it moved down. + let after = line_start(&t, 6); + assert!((after.x - before.x).abs() < 0.05, "{justify}: the widest line stays where it was across: {before:?} → {after:?}"); + assert!(after.y > before.y + 12.0, "{justify}"); + assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow, "{justify}: the frame holds every line"); + // Edits fit it again as area type; undo brings the point type back as it was. + s.execute("edit.undo", &json!({})).unwrap(); + let back = text(&s, id); + assert!(matches!(back.kind, TextKind::Point) && !back.dynamic_text && size_at(&back, 0) == 12.0, "{justify}"); + } +} + +#[test] +fn point_type_with_gaps_indents_and_vertical_type_turns_into_block() { + let mut s = session(); + // The gaps apply to the frame point type becomes. + let id = point(&mut s, "one\ntwo words here", json!({})); + let r = s.execute("type.dynamicText", &json!({"wordGap": 300, "lineGap": 4})).unwrap(); + assert_eq!((r["wordGap"].clone(), r["lineGap"].clone()), (json!(300.0), json!(4.0))); + let t = text(&s, id); + assert!(t.dynamic_text && (t.area.word_gap, t.area.line_gap) == (300.0, 4.0)); + assert_block(&t); + assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow); + // Indents: the frame takes them in, so the widest line keeps its size. + let id = point(&mut s, "Tiny\nThe widest line of all", json!({"y": 300})); + s.execute("text.setFormat", &json!({"ids": [id], "leftIndent": 20, "firstLineIndent": 10})).unwrap(); + let before = line_start(&text(&s, id), 5); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let t = text(&s, id); + assert_block(&t); + assert_eq!(size_at(&t, 5), 12.0); + assert!((line_start(&t, 5).x - before.x).abs() < 0.05, "{before:?} → {:?}", line_start(&t, 5)); + // A nested list item: the frame takes in the level's indent too. + let id = point(&mut s, "Tiny\nThe widest item of all", json!({"y": 200})); + s.execute("text.setFormat", &json!({"ids": [id], "list": "bullet", "listLevel": 3})).unwrap(); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let t = text(&s, id); + assert_eq!(size_at(&t, 5), 12.0, "nested list"); + assert!(size_at(&t, 0) > 12.0); + assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow); + // Mixed alignments: the widest line keeps its size and its place. + let id = point(&mut s, "Left\nThe widest line, right aligned", json!({"y": 400})); + let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + if let Some(NodeKind::Text(t)) = doc.node_mut(NodeId(id)).map(|n| &mut n.kind) { + let right = vectorcraft_doc::ParaStyle { justify: vectorcraft_doc::Justify::Right, ..t.para.clone() }; + t.set_paragraph_styles(vec![t.para.clone(), right]); + } + let before = line_start(&text(&s, id), 5); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let t = text(&s, id); + assert_eq!(size_at(&t, 5), 12.0, "mixed alignments"); + assert!((line_start(&t, 5).x - before.x).abs() < 0.05, "{before:?} → {:?}", line_start(&t, 5)); + // A right-to-left list, whose indents the layout mirrors. + let id = point(&mut s, "קצר\nשורה ארוכה בהרבה מהראשונה", json!({"y": 450})); + s.execute("text.setFormat", &json!({"ids": [id], "list": "bullet", "rightIndent": 9})).unwrap(); + let widest = "קצר\n".len(); + let before = line_start(&text(&s, id), widest); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let t = text(&s, id); + assert_eq!(size_at(&t, widest), 12.0, "right-to-left list"); + assert!((line_start(&t, widest).x - before.x).abs() < 0.05, "{before:?} → {:?}", line_start(&t, widest)); + // Point type that was area type with its own options: the new frame takes the defaults but the + // gaps. + let id = point(&mut s, "Was\nonce a frame of two columns", json!({"y": 500})); + s.execute("type.convertToAreaType", &json!({"ids": [id]})).unwrap(); + s.execute( + "text.areaOptions", + &json!({"columns": 2, "gutter": 12, "inset": 6, "verticalAlign": "bottom", "firstBaselineMin": 40, "wordGap": 150}), + ) + .unwrap(); + s.execute("type.convertToPointType", &json!({"ids": [id]})).unwrap(); + let before = line_start(&text(&s, id), 4); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let t = text(&s, id); + let defaults = vectorcraft_doc::AreaOptions { word_gap: 150.0, ..Default::default() }; + assert_eq!(t.area, defaults); + assert_eq!(size_at(&t, 4), 12.0, "options from an earlier frame"); + assert!((line_start(&t, 4).x - before.x).abs() < 0.05, "{before:?} → {:?}", line_start(&t, 4)); + assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow); + // Vertical type: the frame is as tall as the longest column and grows to the left. + let id = point(&mut s, "縦書き\nもっと長い縦書きの行", json!({"x": 600, "y": 40, "vertical": true})); + let before = line_start(&text(&s, id), "縦書き\n".len()); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let t = text(&s, id); + assert!(t.vertical && t.dynamic_text && matches!(t.kind, TextKind::Area { .. })); + assert_block(&t); + assert_eq!(size_at(&t, "縦書き\n".len()), 12.0); + let after = line_start(&t, "縦書き\n".len()); + assert!((after.y - before.y).abs() < 0.05, "the longest column stays where it was down the page: {before:?} → {after:?}"); + assert!(after.x < before.x - 12.0, "the short column before it grew, so it moved left: {before:?} → {after:?}"); + assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow); +} + +#[test] +fn point_type_with_negative_indents_wider_than_its_line_turns_into_block() { + // Block makes the line larger; it keeps its start, center or end by its alignment. + let ends = |t: &TextObject| { + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + let l = &lay.lines[0]; + ((t.xf * vectorcraft_geom::Point::new(l.x0, l.baseline)).x, (t.xf * vectorcraft_geom::Point::new(l.x1 - l.trailing, l.baseline)).x) + }; + for justify in ["left", "center", "right"] { + let mut s = session(); + let id = point(&mut s, "Short", json!({})); + s.execute("text.setStyle", &json!({"ids": [id], "justify": justify})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [id], "leftIndent": -200})).unwrap(); + let (b0, b1) = ends(&text(&s, id)); + let n = undo_len(&s); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + assert_eq!(undo_len(&s), n + 1, "{justify}"); + let t = text(&s, id); + assert!(t.dynamic_text && matches!(t.kind, TextKind::Area { .. }), "{justify}: the frame is as wide as the line"); + assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow, "{justify}"); + assert!(size_at(&t, 0) > 12.0, "{justify}: Block makes the line larger"); + let (a0, a1) = ends(&t); + let kept = match justify { + "left" => (a0, b0), + "center" => ((a0 + a1) / 2.0, (b0 + b1) / 2.0), + _ => (a1, b1), + }; + assert!((kept.0 - kept.1).abs() < 0.05, "{justify}: the line keeps its aligned point: {b0},{b1} → {a0},{a1}"); + } +} + +#[test] +fn point_type_under_a_text_wrap_object_turns_into_block_that_holds_every_line() { + let mut s = session(); + let id = point(&mut s, "One\nTwo\nThree\nA much longer line than the rest", json!({})); + // A wrap object above the type, across the top of the frame it becomes. + let r = s.execute("shape.rectangle", &json!({"x": 250, "y": 80, "width": 400, "height": 40})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + s.execute("object.textWrap.make", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let t = text(&s, id); + assert!(t.dynamic_text && matches!(t.kind, TextKind::Area { .. })); + assert!(!t.wrap.is_empty(), "the new area type takes the wrap object"); + assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow, "the frame holds every line around the wrap"); + // A wrap object deeper than any size Block sets, across the whole frame: the lines go below it. + let id = point(&mut s, "Below", json!({"x": 1000, "y": 100})); + let r = s.execute("shape.rectangle", &json!({"x": 900, "y": 80, "width": 400, "height": 9000})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + s.execute("object.textWrap.make", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let t = text(&s, id); + assert!(t.dynamic_text && !t.wrap.is_empty()); + assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow, "the frame reaches past a deep wrap object"); + // An inverted wrap object far below the type holds the lines inside it: the frame reaches it. + let id = point(&mut s, "Inside", json!({"x": 1600, "y": 100})); + let r = s.execute("shape.rectangle", &json!({"x": 1550, "y": 9000, "width": 400, "height": 100})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + s.execute("object.textWrap.make", &json!({})).unwrap(); + s.execute("object.textWrap.options", &json!({"invert": true})).unwrap(); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let t = text(&s, id); + assert!(t.dynamic_text && !t.wrap.is_empty()); + assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow, "the frame reaches a distant inverted wrap object"); +} + +#[test] +fn point_type_with_nothing_to_fit_is_left_as_it_is() { + let mut s = session(); + for blank in ["", " ", "\n\n"] { + let id = point(&mut s, blank, json!({})); + let before = text(&s, id); + let n = undo_len(&s); + let r = s.execute("type.dynamicText", &json!({})).unwrap(); + assert_eq!(r["on"], json!(true)); + assert_eq!(undo_len(&s), n, "{blank:?}: no undo step"); + assert_eq!(text(&s, id), before, "{blank:?}"); + // A gap needs type it can apply to. + let e = s.execute("type.dynamicText", &json!({"wordGap": 200})).unwrap_err().to_string(); + assert!(e.contains("need area type"), "{blank:?}: {e}"); + } + // A line far shorter than the widest one stops at the size limit, and the frame still holds it. + let id = point(&mut s, &format!(".\n{}", "w".repeat(400)), json!({"size": 6})); + s.execute("type.dynamicText", &json!({})).unwrap(); + let t = text(&s, id); + assert!(t.dynamic_text); + assert_eq!(size_at(&t, 0), 1296.0); + assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow); +} + #[test] fn long_text_fits_in_a_bounded_number_of_layouts() { let mut s = session(); @@ -451,4 +685,16 @@ fn long_text_fits_in_a_bounded_number_of_layouts() { // pass and the caches), plus the reply's own. let bound = 2 * (2 + 2 * super::block::BLOCK_PASSES as u64) + 4; assert!(used <= bound, "{used} layouts"); + // Long point type: measured, fitted in its frame and fitted again at its depth, with the + // layouts that place and measure the frame. + let id = point(&mut s, &story, json!({})); + let before = vectorcraft_text::test_fonts::layouts_on_this_thread(); + s.execute("type.dynamicText", &json!({"on": true})).unwrap(); + let used = vectorcraft_text::test_fonts::layouts_on_this_thread() - before; + let t = text(&s, id); + assert!(t.dynamic_text && matches!(t.kind, TextKind::Area { .. })); + assert_block(&t); + assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow); + let bound = 2 * (2 + 2 * super::block::BLOCK_PASSES as u64) + 8; + assert!(used <= bound, "point type: {used} layouts"); } diff --git a/crates/engine/src/cmd/typemenu.rs b/crates/engine/src/cmd/typemenu.rs index bb0e426c9b..78dab584b3 100644 --- a/crates/engine/src/cmd/typemenu.rs +++ b/crates/engine/src/cmd/typemenu.rs @@ -7,7 +7,7 @@ use vectorcraft_doc::{AreaFit, CharStyle, Document, Justify, LockedArtboards, No use vectorcraft_geom::{Affine, Rect, shapes}; use super::object::choice; -use super::typecmd::{area_text, block_gaps, refresh_bounds}; +use super::typecmd::{area_text, block_gaps, needed_room, refresh_bounds}; use super::{ CommandSpec, EngineError, Result, Session, bad, checked_ids_param, cmd, find_command, has_clipboard, has_doc, has_selection, number_or, switch_or, targets, text_param, @@ -65,7 +65,7 @@ pub fn specs() -> Vec { "Dynamic Text", ["Type"], None, - "{ids?|id?, on?: bool, wordGap?: % of a word space 0..1000 (100), lineGap?: pt -1296..1296 (0)} turn Dynamic Text on or off for the selected type on a path and area type (on omitted: the opposite of the first object's setting, or on when a gap is given). On type on a path, the layout scales the text by one factor (every character's size, leading and baseline shift; Type on a Path Options › Spacing stays) so that it fills the span between its start and end brackets, larger or smaller, and fits it again after every edit to the text, the path or the brackets; the first character's size moves in steps of 0.1 pt within 0.1 to 1,296 pt, and at 0.1 pt text that still runs past the end bracket overflows. The stored sizes keep their proportions. On area type it is Block: each line as typed (each paragraph, and each forced line break in one; lines are not broken again) is set to the frame's width, less the inset, the gutters and the paragraph's indents, by changing its font size only: its first character's size, in steps of 0.1 pt within 0.1 to 1,296 pt, the line's other characters keeping their proportions to it, with baseline shifts following. Every character's leading is its size plus lineGap, and the layout sets every paragraph with wordGap as its Desired word spacing, its Minimum or Maximum moving out to wordGap when wordGap lies outside it (the paragraphs keep their own Justification… values, which apply again when Block is off; the gaps are area type's, and are kept while Block is off; a gap with no area type in the selection is an error). The sizes are written into the characters and fitted again after every edit to the text or the frame, in the same undo step; at a size limit a line can be narrower than the frame or wrap. An empty line keeps its size. Block replaces Auto Size and Shrink Text to Fit (Area Type Options › Block is the same setting). Off, the text keeps the sizes it was drawn at. Point type in the selection is left as it is; a selection holding type in a thread is refused (threading type turns Dynamic Text off, keeping its drawn sizes). When nothing changes, no undo step is recorded → {on, ids, fitScale (the first object's factor; 1 when off and for area type), wordGap?, lineGap? (the first area type's; left out without area type)}", + "{ids?|id?, on?: bool, wordGap?: % of a word space 0..1000 (100), lineGap?: pt -1296..1296 (0)} turn Dynamic Text on or off for the selected type on a path and area type, or turn it on for point type (on omitted: the opposite of the first object's setting, point type counting as off, or on when a gap is given). On type on a path, the layout scales the text by one factor (every character's size, leading and baseline shift; Type on a Path Options › Spacing stays) so that it fills the span between its start and end brackets, larger or smaller, and fits it again after every edit to the text, the path or the brackets; the first character's size moves in steps of 0.1 pt within 0.1 to 1,296 pt, and at 0.1 pt text that still runs past the end bracket overflows. The stored sizes keep their proportions. On area type it is Block: each line as typed (each paragraph, and each forced line break in one; lines are not broken again) is set to the frame's width, less the inset, the gutters and the paragraph's indents, by changing its font size only: its first character's size, in steps of 0.1 pt within 0.1 to 1,296 pt, the line's other characters keeping their proportions to it, with baseline shifts following. Every character's leading is its size plus lineGap, and the layout sets every paragraph with wordGap as its Desired word spacing, its Minimum or Maximum moving out to wordGap when wordGap lies outside it (the paragraphs keep their own Justification… values, which apply again when Block is off; the gaps are area type's, and are kept while Block is off; a gap with no area type in the selection, and no point type it turns into area type, is an error). The sizes are written into the characters and fitted again after every edit to the text or the frame, in the same undo step; at a size limit a line can be narrower than the frame or wrap. An empty line keeps its size. Block replaces Auto Size and Shrink Text to Fit (Area Type Options › Block is the same setting). Off, the text keeps the sizes it was drawn at. Turning it on turns point type into area type with Block on, with the default Area Type Options but the gaps (options kept from an earlier frame do not carry over): the frame is as wide (along the lines) as the widest line as typed with its paragraph's indents, so that line keeps its size and sits where it was (by its own alignment), the other lines are set to that width (when negative indents take more than that line's width, the frame is as wide as the line, and Block makes it larger), and the frame then grows in depth (down, or to the left for vertical type) to hold every fitted line, around any Text Wrap objects above it; the gaps apply to it as to area type. Point type with no line to fit (empty, or only spaces and breaks) is left as it is, and so is point type when it is turned off; a selection holding type in a thread is refused (threading type turns Dynamic Text off, keeping its drawn sizes). When nothing changes, no undo step is recorded → {on, ids, fitScale (the first object's factor; 1 when off and for area type), wordGap?, lineGap? (the first area type's; left out without area type)}", has_dynamic_type, dynamic_text ), @@ -325,13 +325,7 @@ fn to_area(s: &mut Session, p: &Value) -> Result { // A little slack on the right so the text doesn't rewrap. let top = lay.lines.first().map(|l| l.baseline - l.ascent).unwrap_or(b.y0).min(b.y0); let slack = size * 0.5 + trailing; - // Slack goes where the alignment leaves room, so the text doesn't move or rewrap. - let (sl, sr) = match t.para_at(0).justify { - Justify::Center | Justify::JustifyCenter => (slack * 0.5, slack * 0.5), - Justify::Right | Justify::JustifyRight => (slack, 0.0), - Justify::Auto if lay.lines.first().is_some_and(|l| l.rtl) => (slack, 0.0), - _ => (0.0, slack), - }; + let (sl, sr) = slack_sides(t.para_at(0).justify, lay.lines.first().is_some_and(|l| l.rtl), slack); let frame = Rect::new(b.x0 - sl, top, b.x1 + sr, b.y1.max(top + 1.0)); t.kind = TextKind::Area { frame: shapes::rectangle(frame) }; refresh_bounds(t); @@ -341,6 +335,107 @@ fn to_area(s: &mut Session, p: &Value) -> Result { Ok(ids_json(&ids)) } +/// How `slack` added to point type's lines in a frame is shared before and after them: where the +/// alignment leaves room, so the text doesn't move or rewrap. +fn slack_sides(justify: Justify, rtl: bool, slack: f64) -> (f64, f64) { + match justify { + Justify::Center | Justify::JustifyCenter => (slack * 0.5, slack * 0.5), + Justify::Right | Justify::JustifyRight => (slack, 0.0), + Justify::Auto if rtl => (slack, 0.0), + _ => (0.0, slack), + } +} + +/// Point type `t` turned into area type with Dynamic Text's Block on, with the default Area Type +/// Options but its gaps: the frame takes the room along the lines that keeps the widest line as +/// typed at its size ([`needed_room`]), placed so that line stays where it is by its own +/// alignment, and after the fit it takes the depth that holds every line, so nothing overflows. +/// Lines run in line space (for vertical type, down the page), and the frame is mapped to the +/// type's own space by the layout's `line_xf`. `wrap` is the Text Wrap shapes the new area type +/// takes, in the type's own space, which the fit and the depth follow. When negative indents take +/// more than the widest line's width, the frame is as wide as that line and placed so that the +/// line keeps its aligned point. None when no line has a +/// width (empty text, or only spaces and breaks), which Block leaves at its size. +fn point_to_block(t: &TextObject, wrap: &[vectorcraft_doc::WrapShape]) -> Option { + let db = vectorcraft_text::FontDb::global(); + // Options point type kept from a frame it had before (columns, inset, alignment, first + // baseline) do not carry over to the new frame. + let mut t = t.clone(); + let (word_gap, line_gap) = (t.area.word_gap, t.area.line_gap); + t.area = vectorcraft_doc::AreaOptions { word_gap, line_gap, ..Default::default() }; + t.wrap = wrap.to_vec(); + let t = &t; + // The room is measured as Block sets the lines in a frame, with the word gap. + let as_block = TextObject { dynamic_text: true, kind: TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)) }, ..t.clone() }; + let (room, widest, para_index) = needed_room(&as_block)?; + let lay = vectorcraft_text::layout(db, t); + let first = lay.lines.first()?; + let line = lay.lines.iter().find(|l| l.start == widest)?; + // Across, the widest line as it is aligned, with its paragraph's indents, which the frame holds. + let para = t.para_at(para_index); + let starts = widest == 0 || t.plain_text().as_bytes().get(widest - 1) == Some(&b'\n'); + let start_indent = para.laid_out_left_indent() + if starts { para.first_line_indent } else { 0.0 }; + // A horizontal right-to-left list paragraph has its indents mirrored, as the layout sets them. + let mirror = line.rtl && !t.vertical && para.list.is_some(); + let (left, right) = if mirror { (para.right_indent, start_indent) } else { (start_indent, para.right_indent) }; + let (lo, hi) = (line.x0 - left, line.x1 - line.trailing + right); + let top = lay.lines.iter().map(|l| l.baseline - l.ascent).fold(first.baseline - first.ascent, f64::min); + let bottom = lay.lines.iter().map(|l| l.baseline + l.descent).fold(top, f64::max); + if ![lo, hi, top, bottom, room].iter().all(|v| v.is_finite()) { + return None; + } + // Negative indents can take more than the line's width (hi below lo); the frame is then wider + // than the line with its indents, and the same split of the difference keeps the line's + // aligned point (its start, center or end) where it was when Block makes the line fill it. + let (sl, _) = slack_sides(para.justify, line.rtl, room - (hi - lo)); + let frame = |depth: f64| lay.line_xf.transform_rect_bbox(Rect::new(lo - sl, top, lo - sl + room, top + depth.max(1.0))); + let start = frame(bottom - top); + let mut out = t.clone(); + out.kind = TextKind::Area { frame: shapes::rectangle(start) }; + out.dynamic_text = true; + refresh_bounds(&mut out); + // The lines fitted in one cell from the frame's top deep enough for all of them (every line at + // the largest size Block sets), around any wrap shapes, give the depth the frame takes. + let mut probe = out.clone(); + let points = t.style_scale().map_or(1.0, |s| s.points).max(1e-12); + let deep = (bottom - top) + (lay.lines.len() as f64 + 1.0) * 3.0 * (1296.0 + out.area.line_gap.abs()) / points; + // Wrap shapes push lines on past them (an inverted one holds them inside it), so the cell also + // reaches as far again as the farthest one reaches past the frame's top. + let inv = lay.line_xf.inverse(); + let deep = deep + + wrap + .iter() + .filter_map(|w| w.path.bounds().map(|r| inv.transform_rect_bbox(r)).map(|r| (r.y1 - top).max(0.0) + 2.0 * w.wrap.offset.abs())) + .filter(|d| d.is_finite()) + .fold(0.0, f64::max); + probe.kind = TextKind::Area { frame: shapes::rectangle(frame(deep)) }; + if !wrap.is_empty() { + refresh_bounds(&mut probe); + } + let laid = vectorcraft_text::layout(db, &probe); + if laid.overflow { + return Some(out); + } + let mut grown = start; + for l in &laid.lines { + for y in [l.baseline - l.ascent, l.baseline + l.descent] { + grown = grown.union_pt(laid.line_xf * vectorcraft_geom::Point::new(l.x0, y)); + } + } + // Only the depth grows (for vertical type, the columns run on to the left), with a hundredth + // of a point to spare. + if t.vertical { + (grown.y0, grown.y1, grown.x0) = (start.y0, start.y1, grown.x0 - 0.01); + } else { + (grown.x0, grown.x1, grown.y1) = (start.x0, start.x1, grown.y1 + 0.01); + } + if grown.is_finite() && grown != start { + out.kind = TextKind::Area { frame: shapes::rectangle(grown) }; + refresh_bounds(&mut out); + } + Some(out) +} + /// (run, byte within the run) for a byte offset into the plain text. fn locate(runs: &[TextRun], byte: usize) -> Option<(usize, usize)> { let mut off = 0; @@ -542,14 +637,23 @@ fn dynamic_type(n: Option<&Node>) -> Option<&TextObject> { path_text(n).or_else(|| area_text(n)) } -/// `type.dynamicText` is enabled for a selection holding type on a path or area type, none of it -/// in a thread. +/// Type `type.dynamicText` takes: type on a path, area type, and point type, which it turns into +/// area type with Block on. +fn dynamic_target(n: Option<&Node>) -> Option<&TextObject> { + match n.map(|n| &n.kind) { + Some(NodeKind::Text(t)) if matches!(t.kind, TextKind::Point) => Some(t), + _ => dynamic_type(n), + } +} + +/// `type.dynamicText` is enabled for a selection holding type on a path, area type or point type, +/// none of it in a thread. fn has_dynamic_type(s: &Session) -> std::result::Result<(), String> { has_selection(s)?; let st = s.active().ok_or("no document open")?; let d = &st.doc; let selected: Vec = st.selection.objects.to_vec(); - let types: Vec = text_ids(d, &selected).into_iter().filter(|id| dynamic_type(d.node(*id)).is_some()).collect(); + let types: Vec = text_ids(d, &selected).into_iter().filter(|id| dynamic_target(d.node(*id)).is_some()).collect(); if types.is_empty() { return Err("select type on a path or area type".into()); } @@ -575,32 +679,46 @@ fn threaded_ids(d: &Document, ids: &[NodeId]) -> String { /// Type › Dynamic Text: set `on` (without it, the opposite of the first object's setting, or on /// when a gap is given) on the selected type on a path and area type (Block, with its gaps), in -/// one undo step, and none when nothing changes. Turning it off keeps the sizes the text was drawn -/// at ([`bake_dynamic_text`]). +/// one undo step, and none when nothing changes. Turning it on also turns the selected point type +/// into area type with Block on ([`point_to_block`]). Turning it off keeps the sizes the text was +/// drawn at ([`bake_dynamic_text`]). fn dynamic_text(s: &mut Session, p: &Value) -> Result { const C: &str = "type.dynamicText"; let (word_gap, line_gap) = block_gaps(p, C)?; - let (ids, on, planned) = { + let (ids, on, planned, converted) = { let d = &s.doc()?.doc; - let ids: Vec = texts(s, p, C)?.into_iter().filter(|i| dynamic_type(d.node(*i)).is_some()).collect(); + let ids: Vec = texts(s, p, C)?.into_iter().filter(|i| dynamic_target(d.node(*i)).is_some()).collect(); let first = *ids.first().ok_or_else(|| bad(C, "select type on a path or area type"))?; if let Some(why) = threaded_refusal(d, &ids) { return Err(bad(C, why)); } let gap_given = word_gap.is_some() || line_gap.is_some(); - if gap_given && !ids.iter().any(|id| area_text(d.node(*id)).is_some()) { + let on = switch_or(p, "on", gap_given || !dynamic_type(d.node(first)).is_some_and(|t| t.dynamic_text), C)?; + // Point type with a line to fit, which turning it on makes area type. + let converts = |id: &NodeId| on && dynamic_target(d.node(*id)).is_some_and(|t| matches!(t.kind, TextKind::Point) && needed_room(t).is_some()); + if gap_given && !ids.iter().any(|id| area_text(d.node(*id)).is_some() || converts(id)) { return Err(bad(C, "wordGap and lineGap need area type in the selection")); } - let on = switch_or(p, "on", gap_given || !dynamic_type(d.node(first)).is_some_and(|t| t.dynamic_text), C)?; // Each object as the command leaves it, and only those it changes. let mut planned: Vec<(NodeId, TextObject)> = vec![]; + // The point type that becomes area type, with the gaps applied, by id. + let mut converted: Vec<(NodeId, TextObject)> = vec![]; for id in &ids { - let Some(t) = dynamic_type(d.node(*id)) else { continue }; + let Some(t) = dynamic_target(d.node(*id)) else { continue }; let mut after = t.clone(); - if matches!(t.kind, TextKind::Area { .. }) { + if matches!(t.kind, TextKind::Area { .. } | TextKind::Point) { after.area.word_gap = word_gap.unwrap_or(t.area.word_gap); after.area.line_gap = line_gap.unwrap_or(t.area.line_gap); } + if matches!(t.kind, TextKind::Point) { + // Point type has no Dynamic Text of its own: on, it becomes area type with Block + // on; off, or with no line to fit, it stays as it is. + if let Some(block) = on.then(|| point_to_block(&after, &[])).flatten() { + planned.push((*id, block)); + converted.push((*id, after)); + } + continue; + } match (on, t.dynamic_text) { (true, true) => refresh_bounds(&mut after), (true, false) => { @@ -620,7 +738,7 @@ fn dynamic_text(s: &mut Session, p: &Value) -> Result { planned.push((*id, after)); } } - (ids, on, planned) + (ids, on, planned, converted) }; if !planned.is_empty() { s.edit("Dynamic Text", |d, _| { @@ -629,6 +747,19 @@ fn dynamic_text(s: &mut Session, p: &Value) -> Result { **t = after.clone(); } } + if converted.is_empty() { + return Ok(()); + } + // The new area type takes the Text Wrap shapes above it; one that has any is fitted + // again around them, with the depth that holds every line. + for id in vectorcraft_doc::text::resolve_wrap(None, d) { + let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { continue }; + let block = converted.iter().find(|(c, _)| *c == id).and_then(|(_, src)| point_to_block(src, &t.wrap)); + match block { + Some(block) => **t = block, + None => refresh_bounds(t), + } + } Ok(()) })?; } diff --git a/crates/engine/src/tests/dynamic_text.rs b/crates/engine/src/tests/dynamic_text.rs index ae1a72fa08..6b221590b0 100644 --- a/crates/engine/src/tests/dynamic_text.rs +++ b/crates/engine/src/tests/dynamic_text.rs @@ -88,19 +88,17 @@ fn dynamic_text_refuses_bad_params_and_other_type() { assert!(err(s.execute("type.dynamicText", &json!({"on": 1}))).contains("on")); assert_eq!(undo_len(&s), before); assert!(!text(&s, id).dynamic_text); - // Point type alone: refused (area type takes Block, `typecmd::block_tests`). let point = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "Point"})).unwrap()["id"].as_u64().unwrap(); - s.execute("select.set", &json!({"ids": [point]})).unwrap(); - assert!(err(s.execute("type.dynamicText", &json!({}))).contains("select type on a path or area type")); let empty = s.execute("shape.rectangle", &json!({"x": 300, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); s.execute("select.set", &json!({"ids": [empty]})).unwrap(); assert!(s.execute("type.dynamicText", &json!({})).is_err()); - // With point type selected too, only the type on a path changes. + // With point type selected too, both change in one undo step: the point type becomes area + // type with Block on (`typecmd::block_tests`). let before = undo_len(&s); s.execute("select.set", &json!({"ids": [point, id.0]})).unwrap(); let r = s.execute("type.dynamicText", &json!({"on": true})).unwrap(); - assert_eq!(r["ids"], json!([id.0])); - assert!(!text(&s, NodeId(point)).dynamic_text); + assert_eq!(r["ids"], json!([point, id.0])); + assert!(text(&s, NodeId(point)).dynamic_text && text(&s, id).dynamic_text); assert_eq!(undo_len(&s), before + 1); } diff --git a/crates/ui-egui/src/canvas/task_bar.rs b/crates/ui-egui/src/canvas/task_bar.rs index a7a7f0a45c..77e21e49bc 100644 --- a/crates/ui-egui/src/canvas/task_bar.rs +++ b/crates/ui-egui/src/canvas/task_bar.rs @@ -153,8 +153,8 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { TextKind::Point => items.push((tl!("Convert To Area Type"), "dc-type-area", "type.convertToAreaType", none())), TextKind::OnPath { .. } => items.push((tl!("Type on a Path Options"), "dc-type-path", "type.pathOptions", none())), } - // Dynamic Text turns on or off for type on a path and area type (Block), as the Type menu's item does; - // left out for point type and type in a thread + // Dynamic Text turns on or off for type on a path and area type (Block), and turns point type into area + // type with Block on, as the Type menu's item does; left out for type in a thread // (). if crate::menus::enabled(app, "type.dynamicText") { items.push((tl!("Dynamic Text"), "maximize-2", "type.dynamicText", none())); diff --git a/crates/ui-egui/src/menus/state.rs b/crates/ui-egui/src/menus/state.rs index ae7b7bb891..8b731c95b7 100644 --- a/crates/ui-egui/src/menus/state.rs +++ b/crates/ui-egui/src/menus/state.rs @@ -41,9 +41,9 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option { crate::panels::character::shared_text_flag(app, |t| t.vertical) == Some(id == "type.orientation.vertical") } "type.opticalMarginAlignment" => crate::panels::character::shared_text_flag(app, |t| t.optical_margin) == Some(true), - // Dynamic Text of the first selected type on a path or area type, which the item turns the - // other way. - "type.dynamicText" => selected_text(|k| !matches!(k, vectorcraft_engine::doc::TextKind::Point)).is_some_and(|t| t.dynamic_text), + // Dynamic Text of the first selected type, which the item turns the other way (point type + // counts as off: the item turns it into area type with Block on). + "type.dynamicText" => selected_text(|_| true).is_some_and(|t| t.dynamic_text && !matches!(t.kind, vectorcraft_engine::doc::TextKind::Point)), "view.outline" => app.outline_view(), "view.pixelPreview" => v.pixel_preview, "view.trimView" => v.trim_view, diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index 3d1577e1be..f4dff96dd8 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -313,8 +313,8 @@ fn optical_margin_alignment_is_a_checked_type_menu_item() { assert_eq!(checked(&app, "type.orientation.horizontal", &Value::Null), Some(false)); } -/// Type › Dynamic Text runs `type.dynamicText`, is enabled for type on a path and area type that -/// are in no thread, and is checked while the selected type has it on. +/// Type › Dynamic Text runs `type.dynamicText`, is enabled for point type and for type on a path +/// and area type that are in no thread, and is checked while the selected type has it on. #[test] fn dynamic_text_is_a_checked_type_menu_item() { let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); @@ -334,7 +334,8 @@ fn dynamic_text_is_a_checked_type_menu_item() { assert!(!enabled(&app, id)); let point = app.run("text.create", json!({"x": 10, "y": 10, "text": "Point"})).unwrap()["id"].clone(); app.run("select.set", json!({"ids": [point]})).unwrap(); - assert!(!enabled(&app, id), "point type"); + assert!(enabled(&app, id), "point type, which it turns into area type with Block on"); + assert_eq!(checked(&app, id, &Value::Null), Some(false), "point type"); let path = app.run("path.create", json!({"d": "M60 150 L340 150"})).unwrap()["id"].clone(); let t = app.run("text.createInPath", json!({"path": path, "mode": "onPath", "text": "Along", "at": [60, 150]})).unwrap()["id"].clone(); app.run("select.set", json!({"ids": [t]})).unwrap(); @@ -356,6 +357,11 @@ fn dynamic_text_is_a_checked_type_menu_item() { assert_eq!(checked(&app, id, &Value::Null), Some(false)); app.run(id, json!({})).unwrap(); assert_eq!(checked(&app, id, &Value::Null), Some(true)); + // Point type first: unchecked, as the item turns it on. + app.run("select.set", json!({"ids": [point, area]})).unwrap(); + assert_eq!(checked(&app, id, &Value::Null), Some(false), "point type first"); + app.run(id, json!({})).unwrap(); + assert_eq!(checked(&app, id, &Value::Null), Some(true), "both on"); } /// Type › Dynamic Text Presets › Circle, Arch and Bow run `type.dynamicTextPreset` with their diff --git a/crates/ui-egui/src/tests/taskbar.rs b/crates/ui-egui/src/tests/taskbar.rs index d42be49ff9..47015639c8 100644 --- a/crates/ui-egui/src/tests/taskbar.rs +++ b/crates/ui-egui/src/tests/taskbar.rs @@ -232,6 +232,22 @@ fn area_type_offers_dynamic_text() { assert!(has(&texts, "Convert To Point Type") && !has(&texts, "Dynamic Text"), "{texts:?}"); } +/// Point type offers Dynamic Text beside Convert To Area Type, which turns it into area type with +/// Block on; the bar then offers what area type does. +#[test] +fn point_type_offers_dynamic_text() { + let mut app = app(); + let t = id(app.run("text.create", json!({"x": 60, "y": 80, "text": "Fit\nme in a frame"})).unwrap()); + app.run("tool.select", json!({"tool": "selection"})).unwrap(); + select(&mut app, &[t]); + let (_, texts) = bar(&mut app); + assert!(has(&texts, "Convert To Area Type") && has(&texts, "Dynamic Text"), "{texts:?}"); + click(&mut app, "Dynamic Text"); + assert!(matches!(kind(&app, t), NodeKind::Text(ref t) if t.dynamic_text && matches!(t.kind, TextKind::Area { .. }))); + let (_, texts) = bar(&mut app); + assert!(has(&texts, "Convert To Point Type") && has(&texts, "Dynamic Text"), "{texts:?}"); +} + /// The path's anchors (its first subpath). fn anchors(app: &VectorcraftApp, id: u64) -> Vec { match kind(app, id) { diff --git a/docs/mcp.md b/docs/mcp.md index aeed51157a..2c23a0b0f1 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6923,7 +6923,7 @@ lines. The rest are VectorCraft's own rules: - The layout sets every paragraph with `wordGap` as its Desired word spacing (Justification…), and its Minimum or Maximum word spacing moves out to `wordGap` when `wordGap` lies outside it. The paragraphs keep their own Justification… values, which apply again when Block is off. Each character's leading is its size plus `lineGap`. A - gap given with no area type in the selection is an error. + gap given with no area type in the selection, and no point type that it turns into area type, is an error. - The sizes are written into the characters, so every export and the Character panel see them. After every edit to the text or the frame (typing, `text.editRange`, character and paragraph changes, `text.areaOptions {width}`, a frame resize) the lines are fitted again, in the edit's own undo step. The lines are then measured again, in at @@ -6932,14 +6932,27 @@ lines. The rest are VectorCraft's own rules: - An empty line, or a line of spaces, keeps its size. At 1,296 pt a line can be narrower than the frame; at 0.1 pt it can still wrap. A frame with no room along its lines changes nothing. Right-to-left lines are fitted as left-to-right ones are. -- Off keeps the sizes the text was drawn at. On replaces Auto Size and Shrink Text to Fit. Point type in the selection - is left as it is. Area type in a thread is refused with an error that names it (`text.areaOptions {fit: "block"}` +- Off keeps the sizes the text was drawn at. On replaces Auto Size and Shrink Text to Fit. Area type in a thread is refused with an error that names it (`text.areaOptions {fit: "block"}` refuses it too); threading a frame turns Block off, keeping its sizes. Without `on`, it toggles from the first object's setting, or turns on when a gap is given. A value that is not a boolean, or a gap outside its range, is an error. When nothing changes, no undo step is recorded. - Saved and opened, the native file keeps Block on with its gaps and the sizes in the characters; with Block off it keeps the sizes. +Turning it on also takes point type, in the same undo step (the Contextual Task Bar offers it beside Convert To Area +Type). [Adjust formatting and resize text](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/adjust-formatting-and-resize-text-with-dynamic-text.html) +applies Dynamic Text, Block among its shapes, to existing text. VectorCraft's own rules for point type: + +- It becomes area type with Block on and the default Area Type Options, keeping only its gaps: options it kept from + an earlier frame (columns, inset, vertical alignment, first baseline) do not carry over. Along its lines, the frame + is as wide as the widest line as typed with its paragraph's indents, so that line keeps its size and stays where it + was across the page by its own alignment; the other lines are set to that width. When negative indents take more + than the widest line's width, the frame is as wide as that line, and Block makes it larger. The frame starts at the + top of the text and then grows down (to the left for vertical type) to hold every fitted line, around any Text Wrap + objects above it, so nothing overflows. +- The gaps given apply to its frame. Point type with no line to fit (empty, or only spaces and breaks) is left as it + is, and so is point type when `on` is false. Without `on`, point type counts as off. + The reply is `{on, ids, fitScale, wordGap, lineGap}` (`fitScale` as for type on a path, 1 when the first object is area type; the gaps are the first area type's). diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 5e0cf03a3a..3ef8845ca8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1189,6 +1189,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Type on a Path Options › Align to Path (option names as in [Create type on a path](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-type-on-a-path.html)) moves each character by its own font, size and vertical scale: Ascender runs the top of the font's em box along the path (the ideographic em box that its `BASE` table, its typographic or `hhea` box or its ideographs give, else an em box centered on the cap height), Descender runs the bottom of the font's bounding box along it, and Center runs a line at half the height of the top of the font's H above the baseline along it. In vertical type, Ascender and Descender move the characters half an em, and Center leaves them on the path. These distances are VectorCraft's own values (`tests::on_path_align_runs_each_glyph_by_its_own_metrics`, `tests::on_path_align_finds_the_em_box_of_each_kind_of_face`, `tests::on_path_align_in_vertical_type_moves_half_an_em`). Type › Dynamic Text (`type.dynamicText`, also on the Contextual Task Bar for type on a path) sizes type on a path to fill the span between its start and end brackets and fits it again after each edit to the text, the path or a bracket ([Dynamic Text overview](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/dynamic-text-overview.html), [start and end points](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/reposition-start-and-end-points.html)). VectorCraft's own rules: one factor scales every size, leading and baseline shift; the first character's size moves in 0.1 pt steps within 0.1 to 1,296 pt; turning it off or threading the type keeps the drawn sizes; SVG, EPS and the editing data write the drawn sizes (`crates/text/src/tests/fit.rs`, `crates/engine/src/tests/dynamic_text.rs`). Dynamic Text on area type is Block (Type › Dynamic Text, the Contextual Task Bar, Area Type Options › Block, the Properties panel's Dynamic Text checkbox with Word Gap and Line Gap; [Dynamic Text overview](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/dynamic-text-overview.html)): each line as typed is set to the frame's width by its font size, again after every edit to the text or the frame. VectorCraft's own rules: lines are not broken again, the first character's size moves in 0.1 pt steps within 0.1 to 1,296 pt, Word Gap (0 to 1,000 %, default 100) is the Desired word spacing and Line Gap (-1,296 to 1,296 pt, default 0) is added to each size for the leading, the sizes are written into the characters, threaded frames cannot use it, and Convert To Point Type turns it off, keeping the sizes (`crates/engine/src/cmd/typecmd/block_tests.rs`). + Dynamic Text on point type (Type › Dynamic Text, the Contextual Task Bar; [Adjust formatting and resize text](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/adjust-formatting-and-resize-text-with-dynamic-text.html) applies it to existing text) turns it into area type with Block on. VectorCraft's own rules: the frame is as wide as the widest line with its indents, so that line keeps its size and place across the page, and it grows in depth to hold every fitted line (`crates/engine/src/cmd/typecmd/block_tests.rs`). Type › Dynamic Text Presets › Circle, Arch and Bow (`type.dynamicTextPreset`) put point type or type on a path on a circular, upward-curving or downward-curving path with Dynamic Text on, with the text's position against the path (above, center, below) and its direction ([Dynamic Text overview](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/dynamic-text-overview.html)). VectorCraft's own rules: the circle is as wide as the text and the text starts at its bottom; Arch and Bow are a 120° arc whose chord is as wide as the text; above, center and below are Align to Path's Descender, Center and Ascender (`crates/engine/src/tests/dynamic_text.rs`). The frames of threaded text share one Optical Margin Alignment setting: turning it on or off in one frame sets it in every frame, threading frames gives the thread its first frame's setting, and frames taken out of a thread keep it. The frames of threaded text share one writing direction: Type › Type Orientation (`type.orientation.vertical`, `type.orientation.horizontal`) in one frame sets every frame, threading frames gives the thread its first frame's direction, frames taken out of a thread keep theirs, and the story flows through every frame in the thread's direction. From 9c5a2fa2b87309916d4331afdd3f1456cb67cbcf Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 00:37:18 -0800 Subject: [PATCH 06/26] 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 07/26] =?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 08/26] 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 01:20:37 -0800 Subject: [PATCH 09/26] Select: test Not Aligned to Pixel Grid with images, symbols, type and live objects Adds tests for Select > Object > Not Aligned to Pixel Grid with embedded images, symbol instances, point, area and path type, gradient meshes, placed documents, guides and live shapes, with paths on template layers, hidden and locked sublayers and locked artboards, and with the paths in clip groups, blends, envelopes, repeats and Intertwine groups. A compound path is selected by its own property, whatever the property of its paths. The command's params text and docs/mcp.md now say that it looks into clip groups, blends, envelopes, repeats and Intertwine groups. --- crates/engine/src/cmd/select.rs | 2 +- crates/engine/src/tests/pixelalign.rs | 199 ++++++++++++++++++++++++++ docs/mcp.md | 9 +- docs/target-app-parity.md | 2 +- 4 files changed, 207 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/select.rs b/crates/engine/src/cmd/select.rs index aeeac58855..a2c7b3a8d4 100644 --- a/crates/engine/src/cmd/select.rs +++ b/crates/engine/src/cmd/select.rs @@ -184,7 +184,7 @@ pub fn specs() -> Vec { "Not Aligned to Pixel Grid", ["Select", "Object"], None, - "{} the paths (not guides) and compound paths whose pixel-aligned property (object.setPixelAligned) is off, looking into groups but not into compound paths → {count}", + "{} the paths (not guides) and compound paths whose pixel-aligned property (object.setPixelAligned) is off, looking into groups, clip groups, blends, envelopes, repeats and Intertwine groups but not into compound paths → {count}", has_doc, not_pixel_aligned ), diff --git a/crates/engine/src/tests/pixelalign.rs b/crates/engine/src/tests/pixelalign.rs index adb8bae15d..06057d1fc0 100644 --- a/crates/engine/src/tests/pixelalign.rs +++ b/crates/engine/src/tests/pixelalign.rs @@ -959,3 +959,202 @@ fn not_aligned_to_pixel_grid_leaves_out_what_cant_be_selected() { assert_eq!(s.execute("select.object.notAlignedToPixelGrid", &json!({})).unwrap()["count"], 1); assert_eq!(selected_ids(&s), vec![inside]); } + +/// Run Not Aligned to Pixel Grid with `before` selected, check that it makes no undo step and that +/// its count is the number of objects it selected → the selected ids, sorted. +fn not_aligned(s: &mut Session, before: &[u64]) -> Vec { + s.execute("select.set", &json!({ "ids": before })).unwrap(); + let steps = undo_len(s); + let r = s.execute("select.object.notAlignedToPixelGrid", &json!({})).unwrap(); + let ids = selected_ids(s); + assert_eq!(r["count"], json!(ids.len()), "{r}"); + assert_eq!(undo_len(s), steps); + ids +} + +fn sorted(mut ids: Vec) -> Vec { + ids.sort_unstable(); + ids +} + +/// What Select All selects, sorted. +fn all_ids(s: &mut Session) -> Vec { + s.execute("select.all", &json!({})).unwrap(); + selected_ids(s) +} + +fn kind(s: &Session, id: u64) -> NodeKind { + s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind.clone() +} + +/// Images, symbol instances, point type, area type and type on a path cannot take the property, so +/// Not Aligned to Pixel Grid leaves them out though Select All selects them; the path a symbol was +/// made from is the symbol's art, not an object of the document. +#[test] +fn not_aligned_to_pixel_grid_leaves_out_images_symbol_instances_and_type() { + let mut s = new_doc(); + let p = path(&mut s, "M10.3 20.6 L40.7 20.6 L40.7 30.3 L10.3 30.3 Z", 1.0); + let png = crate::tests_links::png(4, 4, [255, 0, 0]); + s.execute("file.place", &json!({"name": "red.png", "dataBase64": vectorcraft_format::base64_encode(&png), "link": false})).unwrap(); + let image = s.doc().unwrap().selection.objects[0].0; + let art = path(&mut s, "M60.5 60 L80 60 L80 80 L60.5 80 Z", 1.0); + let made = s.execute("symbol.new", &json!({"name": "Mark", "ids": [art]})).unwrap()["id"].as_u64().unwrap(); + let placed = s.execute("symbol.place", &json!({"name": "Mark", "x": 200.3, "y": 200.3})).unwrap()["id"].as_u64().unwrap(); + let point = s.execute("text.create", &json!({"x": 100, "y": 100, "text": "point"})).unwrap()["id"].as_u64().unwrap(); + let frame = json!({"width": 80.5, "height": 40.5}); + let area = s.execute("text.create", &json!({"x": 100, "y": 150, "text": "area", "area": frame})).unwrap()["id"].as_u64().unwrap(); + let on_path = path(&mut s, "M10.5 300 C100 250 200 350 300.5 300", 1.0); + let on_path = s.execute("text.createInPath", &json!({"path": on_path, "mode": "onPath", "text": "on a path"})).unwrap()["id"].as_u64().unwrap(); + let in_path = path(&mut s, "M250.5 20.5 L350 20.5 L350 90 L250.5 90 Z", 1.0); + let in_path = s.execute("text.createInPath", &json!({"path": in_path, "mode": "area", "text": "in a path"})).unwrap()["id"].as_u64().unwrap(); + assert!(matches!(kind(&s, image), NodeKind::Image(_))); + for id in [made, placed] { + assert!(matches!(kind(&s, id), NodeKind::SymbolInstance { .. }), "{id}"); + } + for id in [point, area, on_path, in_path] { + assert!(matches!(kind(&s, id), NodeKind::Text(_)), "{id}"); + } + assert!(s.doc().unwrap().doc.node(NodeId(art)).is_none()); + assert_eq!(all_ids(&mut s), sorted(vec![p, image, made, placed, point, area, on_path, in_path])); + assert_eq!(not_aligned(&mut s, &[image, made, point]), vec![p]); + // With the path aligned, nothing is left to select. + s.execute("object.setPixelAligned", &json!({"ids": [p]})).unwrap(); + assert_eq!(not_aligned(&mut s, &[placed]), Vec::::new()); +} + +/// Paths on a template layer, in a hidden or locked sublayer or on a locked artboard are left out, +/// as Select All leaves them out; paths in an unlocked visible sublayer are selected. +#[test] +fn not_aligned_to_pixel_grid_leaves_out_template_layers_locked_sublayers_and_locked_artboards() { + let mut s = new_doc(); + s.execute("artboard.new", &json!({"x": 500, "y": 0, "width": 100, "height": 100})).unwrap(); + let main = s.doc().unwrap().doc.layers[0].id.0; + let free = path(&mut s, "M10 10 L20 10 L20 20 L10 20 Z", 1.0); + let on_locked_board = path(&mut s, "M510 10 L520 10 L520 20 L510 20 Z", 1.0); + let template = s.execute("layer.new", &json!({"name": "Trace"})).unwrap()["id"].as_u64().unwrap(); + let traced = path(&mut s, "M30 10 L40 10 L40 20 L30 20 Z", 1.0); + s.execute("layer.setProps", &json!({"id": template, "template": true})).unwrap(); + let sub = |s: &mut Session, name: &str, d: &str| { + let l = s.execute("layer.newSublayer", &json!({"parent": main, "name": name})).unwrap()["id"].as_u64().unwrap(); + s.execute("layer.setCurrent", &json!({"id": l})).unwrap(); + (l, path(s, d, 1.0)) + }; + let (_, in_sublayer) = sub(&mut s, "Open", "M50 10 L60 10 L60 20 L50 20 Z"); + let (hidden, in_hidden) = sub(&mut s, "Hidden", "M70 10 L80 10 L80 20 L70 20 Z"); + let (locked, in_locked) = sub(&mut s, "Locked", "M90 10 L100 10 L100 20 L90 20 Z"); + s.execute("layer.setProps", &json!({"id": hidden, "visible": false})).unwrap(); + s.execute("layer.setProps", &json!({"id": locked, "locked": true})).unwrap(); + s.execute("artboard.setProps", &json!({"index": 1, "locked": true})).unwrap(); + let all = all_ids(&mut s); + for id in [traced, in_hidden, in_locked, on_locked_board] { + assert!(!all.contains(&id), "{id}"); + } + assert_eq!(not_aligned(&mut s, &[free]), sorted(vec![free, in_sublayer])); + // Unlocked, the artboard's path is selected again. + s.execute("artboard.setProps", &json!({"index": 1, "locked": false})).unwrap(); + assert_eq!(not_aligned(&mut s, &[free]), sorted(vec![free, in_sublayer, on_locked_board])); +} + +/// A clipping path and the paths it clips are selected, not their clip group; a path with a drop +/// shadow or a Gaussian Blur is selected as the path it is, and its effects are not objects. +#[test] +fn not_aligned_to_pixel_grid_selects_clipping_paths_and_paths_with_effects() { + let mut s = new_doc(); + let clipped = path(&mut s, "M10.5 10 L60 10 L60 60 L10.5 60 Z", 1.0); + let clip = path(&mut s, "M20.5 20 L50 20 L50 50 L20.5 50 Z", 1.0); + s.execute("select.set", &json!({"ids": [clipped, clip]})).unwrap(); + let group = s.execute("object.clippingMask.make", &json!({})).unwrap()["id"].as_u64().unwrap(); + let shadowed = path(&mut s, "M100.5 10 L150 10 L150 60 L100.5 60 Z", 1.0); + s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [shadowed]})).unwrap(); + let blurred = path(&mut s, "M200.5 10 L250 10 L250 60 L200.5 60 Z", 1.0); + s.execute("effect.apply", &json!({"effect": "blur.gaussian", "params": {"radius": 3}, "ids": [blurred]})).unwrap(); + assert!(matches!(kind(&s, group), NodeKind::Group { .. })); + for id in [shadowed, blurred] { + assert!(!s.doc().unwrap().doc.node(NodeId(id)).unwrap().appearance.effects.is_empty(), "{id}"); + } + let want = sorted(vec![clipped, clip, shadowed, blurred]); + assert_eq!(not_aligned(&mut s, &[group]), want); + // Object › Align to Pixel Grid takes each object selected; then nothing is left to select. + s.execute("object.setPixelAligned", &json!({"ids": want})).unwrap(); + assert_eq!(not_aligned(&mut s, &[group]), Vec::::new()); +} + +/// The filter looks into blends, envelopes, repeats and Intertwine groups and selects the paths +/// they are made of, not the objects that hold them; Align to Pixel Grid takes that selection, and +/// then nothing is left to select. +#[test] +fn not_aligned_to_pixel_grid_selects_the_paths_of_blends_envelopes_repeats_and_intertwines() { + let mut s = new_doc(); + let b1 = path(&mut s, "M10.5 10 L30 10 L30 30 L10.5 30 Z", 1.0); + let b2 = path(&mut s, "M80.5 10 L100 10 L100 30 L80.5 30 Z", 1.0); + s.execute("select.set", &json!({"ids": [b1, b2]})).unwrap(); + let blend = s.execute("object.blend.make", &json!({})).unwrap()["id"].as_u64().unwrap(); + let warped = path(&mut s, "M10.5 100 L100 100 L100 150 L10.5 150 Z", 1.0); + let envelope = s.execute("object.envelope.makeWithWarp", &json!({"ids": [warped]})).unwrap()["id"].as_u64().unwrap(); + let tile = path(&mut s, "M200.5 10 L220 10 L220 30 L200.5 30 Z", 1.0); + let repeat = s.execute("object.repeat.grid", &json!({"ids": [tile], "rows": 2, "cols": 2})).unwrap()["id"].as_u64().unwrap(); + let i1 = path(&mut s, "M200.5 200 L260 200 L260 230 L200.5 230 Z", 1.0); + let i2 = path(&mut s, "M220.5 180 L240 180 L240 250 L220.5 250 Z", 1.0); + let intertwine = s.execute("object.intertwine.make", &json!({"ids": [i1, i2]})).unwrap()["id"].as_u64().unwrap(); + assert!(matches!(kind(&s, blend), NodeKind::Blend { .. })); + assert!(matches!(kind(&s, envelope), NodeKind::Envelope { .. })); + assert!(matches!(kind(&s, repeat), NodeKind::Repeat(_))); + assert!(matches!(kind(&s, intertwine), NodeKind::Intertwine { .. })); + let want = sorted(vec![b1, b2, warped, tile, i1, i2]); + assert_eq!(not_aligned(&mut s, &[blend, envelope]), want); + s.execute("object.setPixelAligned", &json!({})).unwrap(); + for id in &want { + assert!(aligned(&s, *id), "{id}"); + } + assert_eq!(not_aligned(&mut s, &[repeat, intertwine]), Vec::::new()); +} + +/// Gradient meshes, placed documents and ruler guides are left out; a live shape is a path and is +/// selected. +#[test] +fn not_aligned_to_pixel_grid_leaves_out_meshes_placed_documents_and_guides_but_takes_live_shapes() { + let mut s = new_doc(); + let filled = path(&mut s, "M10.5 10 L60 10 L60 60 L10.5 60 Z", 1.0); + s.execute("paint.setFill", &json!({"color": "#3366cc"})).unwrap(); + let mesh = s.execute("object.mesh.create", &json!({"ids": [filled], "rows": 2, "cols": 2})).unwrap()["ids"][0].as_u64().unwrap(); + let dir = crate::tests_links::Folder::new("pixelalign-placed"); + let file = dir.file("placed.vectorcraft"); + let mut d = vectorcraft_doc::Document::new(50.0, 50.0); + let layer = d.layers[0].id; + let id = d.alloc_id(); + let rect = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.5, 0.5, 40.0, 40.0)); + let paint = vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb8(20, 20, 230)); + let look = vectorcraft_doc::Appearance::basic(paint, vectorcraft_color::Paint::None, 0.0); + d.insert(Some(layer), 0, vectorcraft_doc::Node::path(id, rect, look)).unwrap(); + crate::tests_links::write(&file, &vectorcraft_format::save_file(&d)); + let placed = s.execute("file.place", &json!({"path": file, "at": [200, 200]})).unwrap()["ids"][0].as_u64().unwrap(); + let guide = path(&mut s, "M0 300.5 L400 300.5", 1.0); + s.execute("view.guides.make", &json!({})).unwrap(); + let live = s.execute("shape.rectangle", &json!({"x": 100.3, "y": 20.6, "width": 30.4, "height": 9.7})).unwrap()["id"].as_u64().unwrap(); + assert!(matches!(kind(&s, mesh), NodeKind::Mesh(_))); + assert!(matches!(kind(&s, placed), NodeKind::PlacedDocument(_))); + assert!(matches!(kind(&s, guide), NodeKind::Path { guide: true, .. })); + assert!(s.doc().unwrap().doc.node(NodeId(live)).unwrap().live_shape().is_some()); + let all = all_ids(&mut s); + assert!(all.contains(&mesh) && all.contains(&placed), "{all:?}"); + assert_eq!(not_aligned(&mut s, &[mesh, placed]), vec![live]); +} + +/// A compound path goes by its own property, whatever the property of the paths in it (a file can +/// hold paths with the property on inside a compound path): off, it is selected as one object and +/// the paths in it are not; on, it is not selected, though a path in it has the property off. +#[test] +fn not_aligned_to_pixel_grid_takes_a_compound_path_by_its_own_property() { + let mut s = new_doc(); + let on = path(&mut s, "M10.5 10 L30 10 L30 30 L10.5 30 Z", 1.0); + let off = path(&mut s, "M50.5 10 L70 10 L70 30 L50.5 30 Z", 1.0); + s.execute("select.set", &json!({"ids": [on, off]})).unwrap(); + let compound = s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap(); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(NodeId(on)).unwrap().extras_mut().pixel_aligned = true; + assert!(aligned(&s, on) && !aligned(&s, off) && !aligned(&s, compound)); + let other = path(&mut s, "M100 100 L110 100 L110 110 L100 110 Z", 1.0); + assert_eq!(not_aligned(&mut s, &[other]), sorted(vec![compound, other])); + s.execute("object.setPixelAligned", &json!({"ids": [compound]})).unwrap(); + assert!(!aligned(&s, off)); + assert_eq!(not_aligned(&mut s, &[compound]), vec![other]); +} diff --git a/docs/mcp.md b/docs/mcp.md index 288e86ae3e..5693acd159 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2330,9 +2330,12 @@ everything in it, and `select.all`, `select.allOnArtboard` and `select.inverse` `brush.list`), whatever the brush is named; a brush of another type with "Bristle" in its name does not count. `select.object.notAlignedToPixelGrid` (Select › Object › Not Aligned to Pixel Grid) selects the paths and compound paths whose pixel-aligned property (`object.setPixelAligned`) is off and returns `{count}`. It goes by the property, -not by where the anchors are: a path on whole points with the property off is selected. Guide paths, type, images, -groups and other objects are not, nor are the paths inside a compound path, which takes the property as one object. -It looks into groups, replaces the selection, and with no such object clears it; it records no undo step. +not by where the anchors are: a path on whole points with the property off is selected. Guide paths, type (point, +area and on a path), images, symbol instances, gradient meshes, placed documents, groups and other objects are not, +nor are the paths inside a compound path, which takes the property as one object. A path with effects is selected +as the path it is. It looks into groups, clip groups, blends, envelopes, repeats and Intertwine groups and selects the +paths they hold, not the objects that hold them. It replaces the selection, and with no such object clears it; it +records no undo step. `select.nextAbove` and `select.nextBelow` select the nearest such object above or below the first selected object among the objects beside it in its group or layer, passing over hidden and locked objects and sublayers. When there is no such object, the selection stays as it is. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 351ade4468..4cc4b01cda 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -946,7 +946,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Agents read the key object: `select.key` returns it, and `document.inspect` and the `ui.pointer` and `pointer_gesture` replies report it as `key`. `select.key` with an id that isn't a selected top-level object returns an error and leaves the key unchanged. The Select menu's Same commands, its Object commands that select art, Next Object Above and Below, saved selections and the Magic Wand (#2655) select only objects that Select All selects and objects inside them: nothing hidden or locked, nothing in a hidden or locked group, layer or sublayer, nothing on a template layer, and in isolation mode nothing outside the isolated group or layer. In isolation mode Select All, All on Active Artboard and Inverse select nothing while the isolated group or layer, or a group or layer it is in, is hidden, locked or a template layer. Select ▸ Object ▸ Bristle Brush Strokes selects the objects whose topmost stroke has a bristle brush, whatever the brush is named, and leaves out objects whose brush is of another type, also when its name has "Bristle" in it. - Select ▸ Object ▸ Not Aligned to Pixel Grid (`select.object.notAlignedToPixelGrid`) selects the paths and compound paths whose pixel-aligned property is off, whatever their coordinates, and leaves out guides, type, images, groups and the paths inside a compound path ([help page](https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf#page=185); `tests::pixelalign::not_aligned_to_pixel_grid_selects_paths_without_the_property`, `not_aligned_to_pixel_grid_leaves_out_what_cant_be_selected`). + Select ▸ Object ▸ Not Aligned to Pixel Grid (`select.object.notAlignedToPixelGrid`) selects the paths and compound paths whose pixel-aligned property is off, whatever their coordinates, and leaves out guides, type, images, groups and the paths inside a compound path ([help page](https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf#page=185); `tests::pixelalign::not_aligned_to_pixel_grid_selects_paths_without_the_property`, `not_aligned_to_pixel_grid_leaves_out_what_cant_be_selected`). It also leaves out symbol instances, area type, type on a path, gradient meshes and placed documents, and the paths on template layers, in hidden or locked sublayers and on locked artboards; it selects the paths in clip groups, blends, envelopes, repeats and Intertwine groups and paths with effects (`not_aligned_to_pixel_grid_leaves_out_images_symbol_instances_and_type`, `not_aligned_to_pixel_grid_leaves_out_template_layers_locked_sublayers_and_locked_artboards`, `not_aligned_to_pixel_grid_selects_clipping_paths_and_paths_with_effects`, `not_aligned_to_pixel_grid_selects_the_paths_of_blends_envelopes_repeats_and_intertwines`, `not_aligned_to_pixel_grid_leaves_out_meshes_placed_documents_and_guides_but_takes_live_shapes`, `not_aligned_to_pixel_grid_takes_a_compound_path_by_its_own_property`). Align and Distribute Spacing keep a compound path or selected group that contains the key object in place, as they keep the key object itself. Align to Glyph Bounds (#4231): the Align panel menu's Align to Glyph Bounds submenu has Point Text and Area Text, each checked while on (`alignGlyphBoundsPoint`, `alignGlyphBoundsArea`, off by default). With one on, Align, Distribute Objects and Distribute Spacing measure that kind of type by the ink of its glyphs instead of its em box (point type) or frame (area type), so lowercase type aligned to a rectangle's top lines up the tops of its letters; the other kind, type on a path and type in a selected group are measured as before, and Use Preview Bounds adds the characters' strokes ([Align text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/align-text.html); `tests::glyphbounds`, `panels::align::tests::align_to_glyph_bounds_toggles_point_and_area_text`). The Character and Properties panels' controls for the same setting are not done. Anchor Align moves the selected points to their bounds or the artboard (#1180). Repeating it, or moving by at most 1e-9 pt on both axes as whole-object Align does, leaves the document, Undo, Redo and saved state unchanged; actual moves still take one undo step. `crates/engine/src/tests.rs` covers repeated alignment, rounding-only alignment, unchanged handles, saved state, command recording and Undo/Redo. From 2ee2ac6245a55a0ffe830681594fb6484e2dc89e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 01:41:55 -0800 Subject: [PATCH 10/26] Objects on Path: the Select/Move widget chooses an object and drags it to another place along the path With one Objects on Path group selected, the Selection and Objects on Path tools show a ring on each object's attachment point. A click chooses that object while the group stays selected; dragging a ring along the path moves its object to the nearest object's place, and the objects between move one place toward where it was, in one undo step. Hidden and locked objects have no ring and keep their places. On a closed path the drag goes on round across the first anchor, and Escape cancels it. - object.objectsOnPath.selectMember {member} chooses an object. - object.objectsOnPath.moveMember {member, target, group?} moves an object to another object's place; only the order along the path changes. Docs: docs/mcp.md, docs/target-app-parity.md. Not yet: Detach. --- crates/doc/src/objects_on_path.rs | 48 +++ crates/doc/src/selection.rs | 31 ++ crates/doc/src/selection/tests.rs | 43 ++ crates/engine/src/cmd/objects_on_path.rs | 91 +++- crates/engine/src/tests/objects_on_path.rs | 133 ++++++ crates/tools/src/objects_on_path.rs | 458 ++++++++++++++++++++- crates/tools/src/select.rs | 31 +- crates/ui-egui/src/canvas/overlays.rs | 24 +- crates/ui-egui/src/canvas/tests.rs | 32 ++ docs/mcp.md | 24 ++ docs/target-app-parity.md | 3 +- 11 files changed, 899 insertions(+), 19 deletions(-) diff --git a/crates/doc/src/objects_on_path.rs b/crates/doc/src/objects_on_path.rs index cace68ce66..26a11cf733 100644 --- a/crates/doc/src/objects_on_path.rs +++ b/crates/doc/src/objects_on_path.rs @@ -180,6 +180,24 @@ impl ObjectsOnPath { }); Some((spacing, start)) } + /// Move the object `member` to the place on the path of the object `target`: the objects + /// between them each move one place toward where `member` was, except the objects for which + /// `fixed` is true, which keep their places and are passed over. Only the order changes, not the + /// Space or the start. Returns `member`'s new index, or `None` when either one is not an object + /// of the group. + pub fn move_member(&mut self, member: NodeId, target: NodeId, fixed: impl Fn(NodeId) -> bool) -> Option { + let moves = |id: NodeId| id == member || id == target || !fixed(id); + let slots: Vec = (0..self.members.len()).filter(|&i| moves(self.members[i].id)).collect(); + let from = slots.iter().position(|&i| self.members[i].id == member)?; + let to = slots.iter().position(|&i| self.members[i].id == target)?; + let mut moving: Vec<_> = slots.iter().map(|&i| self.members[i]).collect(); + let m = moving.remove(from); + moving.insert(to, m); + for (&i, m) in slots.iter().zip(moving) { + self.members[i] = m; + } + Some(slots[to]) + } /// The children given new ids (a copy of the group): the path and the objects by their new ids. pub fn rename(&mut self, ids: &HashMap) { let to = |id: NodeId| ids.get(&id).copied().unwrap_or(id); @@ -487,6 +505,36 @@ mod tests { assert_eq!(layout(400.0, true, 4, Some(300.0), 400.0), (0.0, 100.0)); } + #[test] + fn moving_an_object_shifts_the_ones_between_it_and_its_new_place() { + let (a, b, c) = + (rect(2, Rect::new(0.0, 0.0, 10.0, 10.0)), rect(3, Rect::new(20.0, 0.0, 30.0, 10.0)), rect(4, Rect::new(40.0, 0.0, 50.0, 10.0))); + let rec = ObjectsOnPath { spacing: Some(30.0), start: 5.0, ..ObjectsOnPath::new(NodeId(1), &[&a, &b, &c], CENTER, 15.0) }; + let order = |r: &ObjectsOnPath| r.members.iter().map(|m| m.id.0).collect::>(); + let free = |_: NodeId| false; + let mut fwd = rec.clone(); + assert_eq!(fwd.move_member(NodeId(2), NodeId(4), free), Some(2)); + assert_eq!(order(&fwd), [3, 4, 2], "the first to the last place"); + assert_eq!((fwd.spacing, fwd.start, fwd.pivot, fwd.rotate), (rec.spacing, rec.start, rec.pivot, rec.rotate)); + let mut back = rec.clone(); + assert_eq!(back.move_member(NodeId(4), NodeId(2), free), Some(0)); + assert_eq!(order(&back), [4, 2, 3], "the last to the first place"); + let mut same = rec.clone(); + assert_eq!(same.move_member(NodeId(3), NodeId(3), free), Some(1)); + assert_eq!(same, rec, "onto its own place"); + let mut other = rec.clone(); + assert_eq!(other.move_member(NodeId(1), NodeId(3), free), None, "the path is not one of the objects"); + assert_eq!(other.move_member(NodeId(3), NodeId(9), free), None); + assert_eq!(other, rec); + // A fixed object between them keeps its place; the others pass over it. + let mut over = rec.clone(); + assert_eq!(over.move_member(NodeId(2), NodeId(4), |id| id == NodeId(3)), Some(2)); + assert_eq!(order(&over), [4, 3, 2], "the first and last trade places round the fixed one"); + let mut back_over = rec.clone(); + assert_eq!(back_over.move_member(NodeId(4), NodeId(2), |id| id == NodeId(3)), Some(0)); + assert_eq!(order(&back_over), [4, 3, 2]); + } + #[test] fn a_group_saved_without_space_reads_as_auto() { let a = rect(2, Rect::new(0.0, 0.0, 10.0, 10.0)); diff --git a/crates/doc/src/selection.rs b/crates/doc/src/selection.rs index c514aab996..6f0db556e0 100644 --- a/crates/doc/src/selection.rs +++ b/crates/doc/src/selection.rs @@ -59,6 +59,11 @@ pub struct Selection { /// that object ([`crate::symbol_overrides`]). Any other selection change clears it. #[serde(default, skip_serializing_if = "Option::is_none")] pub symbol_part: Option, + /// An object of an Objects on Path group, chosen with its Select/Move widget: the group is the + /// one selected object, and the object is one of its objects on the path + /// ([`crate::objects_on_path::ObjectsOnPath::members`]). Any other selection change clears it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub on_path_member: Option, } /// See [`Selection::symbol_part`]. @@ -94,6 +99,13 @@ impl Selection { self.guides.clear(); self.key_guide = None; self.symbol_part = None; + self.on_path_member = None; + } + /// Select only the Objects on Path group `group`, with its object `member` (see + /// [`Selection::on_path_member`]). + pub fn set_on_path_member(&mut self, group: NodeId, member: NodeId) { + self.set([group]); + self.on_path_member = Some(member); } /// Select only instance `instance`, with the object at `path` of its symbol's art (see /// [`Selection::symbol_part`]). @@ -115,6 +127,7 @@ impl Selection { self.target = None; self.slices.clear(); self.symbol_part = None; + self.on_path_member = None; if !self.objects.contains(&id) { self.objects.push(id); } @@ -123,6 +136,7 @@ impl Selection { self.target = None; self.slices.clear(); self.symbol_part = None; + self.on_path_member = None; self.objects.retain(|x| *x != id); self.anchors.remove(&id); self.segments.remove(&id); @@ -152,6 +166,7 @@ impl Selection { /// Shift-click on a guide). pub fn toggle_guide(&mut self, i: NodeId) { self.symbol_part = None; + self.on_path_member = None; if let Some(k) = self.guides.iter().position(|g| *g == i) { self.guides.remove(k); if self.key_guide == Some(i) { @@ -258,6 +273,22 @@ impl Selection { if !part_kept { self.symbol_part = None; } + // A chosen object on a path needs its Objects on Path group selected alone, with no anchors + // or guides, and still one of the group's objects, neither hidden nor locked. + let member_kept = self.on_path_member.is_some_and(|m| { + let [g] = self.objects.as_slice() else { return false }; + self.anchors.is_empty() + && self.guides.is_empty() + && doc.is_editable(m) + && found + .get(g) + .and_then(|p| doc.node_at(p)) + .and_then(|n| n.extras().objects_on_path.as_deref().filter(|_| crate::objects_on_path::is_objects_on_path(n))) + .is_some_and(|rec| rec.members.iter().any(|x| x.id == m)) + }); + if !member_kept { + self.on_path_member = None; + } } /// Deselect the objects that are hidden or locked, themselves or through a group or layer /// around them (and so the key object when it is one): hidden or locked art can't stay diff --git a/crates/doc/src/selection/tests.rs b/crates/doc/src/selection/tests.rs index d0d81faee6..d9df845129 100644 --- a/crates/doc/src/selection/tests.rs +++ b/crates/doc/src/selection/tests.rs @@ -179,6 +179,49 @@ fn selection_changes_clear_the_target() { assert_eq!(s.target, None); } +#[test] +fn a_chosen_object_on_a_path_needs_its_group_selected_alone() { + use vectorcraft_geom::{Point, Rect, shapes}; + let mut d = Document::new(200.0, 200.0); + let l = d.layers[0].id; + let (line, a, b, g, other) = (d.alloc_id(), d.alloc_id(), d.alloc_id(), d.alloc_id(), d.alloc_id()); + let line = Node::path(line, shapes::line(Point::new(0.0, 100.0), Point::new(200.0, 100.0)), Default::default()); + let a = Node::path(a, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Default::default()); + let b = Node::path(b, shapes::rectangle(Rect::new(20.0, 0.0, 30.0, 10.0)), Default::default()); + let rec = crate::objects_on_path::ObjectsOnPath::new(line.id, &[&a, &b], crate::objects_on_path::CENTER, 0.0); + let (line_id, a_id) = (line.id, a.id); + let mut group = Node::new(g, NodeKind::Group { children: vec![Arc::new(line), Arc::new(a), Arc::new(b)], clip: false }); + group.extras_mut().objects_on_path = Some(Box::new(rec)); + d.insert(Some(l), 0, group).unwrap(); + d.insert(Some(l), 1, Node::path(other, shapes::rectangle(Rect::new(150.0, 0.0, 160.0, 10.0)), Default::default())).unwrap(); + let mut s = Selection::default(); + s.set_on_path_member(g, a_id); + s.prune(&d); + assert_eq!((s.objects.as_slice(), s.on_path_member), (&[g][..], Some(a_id))); + s.add(other); + assert_eq!(s.on_path_member, None, "another object joins the selection"); + s.set_on_path_member(g, a_id); + s.set([g]); + assert_eq!(s.on_path_member, None, "a new selection"); + // The path is not one of the objects, nor is an object outside the group. + for m in [line_id, other] { + s.set_on_path_member(g, m); + s.prune(&d); + assert_eq!(s.on_path_member, None, "{m:?}"); + } + // A locked object is not chosen. + s.set_on_path_member(g, a_id); + d.node_mut(a_id).unwrap().locked = true; + s.prune(&d); + assert_eq!((s.objects.as_slice(), s.on_path_member), (&[g][..], None), "locked"); + d.node_mut(a_id).unwrap().locked = false; + // Once the group is a plain group, no object of it is chosen. + s.set_on_path_member(g, a_id); + d.node_mut(g).unwrap().extras_mut().objects_on_path = None; + s.prune(&d); + assert_eq!((s.objects.as_slice(), s.on_path_member), (&[g][..], None)); +} + #[test] fn a_key_object_needs_two_or_more_selected_objects() { let mut s = Selection::default(); diff --git a/crates/engine/src/cmd/objects_on_path.rs b/crates/engine/src/cmd/objects_on_path.rs index 1a36a4e29f..296cef4dc2 100644 --- a/crates/engine/src/cmd/objects_on_path.rs +++ b/crates/engine/src/cmd/objects_on_path.rs @@ -9,6 +9,10 @@ //! from the tool's options (Objects on Path Tool Options). `object.objectsOnPath.options` sets the //! group's Pivot, Rotate and Space (the distance between the objects along the path, and where the //! first one attaches), and `object.objectsOnPath.expand` makes it a plain group. +//! `object.objectsOnPath.selectMember` chooses one object of a selected group (its Select/Move +//! widget), and `object.objectsOnPath.moveMember` moves one object to another's place along the +//! path, the objects between it and that place each moving one place toward where it was; hidden +//! and locked objects keep their places. //! //! After every edit, [`refresh`] puts the objects of each Objects on Path group that changed back //! on its path: editing the path moves them, an object moved on its own goes back to its place, @@ -19,7 +23,7 @@ //! Object › Objects on Path › Attach; the order on the path, the attachment point, the distribution, //! the objects squared to the path; Pivot and Rotate; path edits realign the objects; Expand). -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use serde_json::{Value, json}; @@ -28,7 +32,7 @@ use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; use vectorcraft_geom::Rect; use super::edit::{roots_of, selected_roots}; -use super::{CommandSpec, EngineError, Result, Session, bad, check_numbers, checked_id, checked_ids_param, cmd, has_selection}; +use super::{CommandSpec, EngineError, Result, Session, bad, check_numbers, checked_id, checked_ids_param, cmd, has_doc, has_selection}; /// The id of the Objects on Path tool. pub const TOOL: &str = "objectsOnPath"; @@ -53,6 +57,24 @@ pub fn specs() -> Vec { has_objects_on_path, options ), + cmd!( + "object.objectsOnPath.selectMember", + "Select Object on Path", + [], + None, + "{member: id of an object of an Objects on Path group} select the group alone with that object chosen, as a click on the object's Select/Move widget does; any other selection change clears the choice → {group, member}", + has_doc, + select_member + ), + cmd!( + "object.objectsOnPath.moveMember", + "Move Object on Path", + [], + None, + "{member: id of an object of an Objects on Path group, target: id of another object of the same group, group?: the group's id (when given, it must be the group of both); neither object nor the group may be hidden or locked} move the object to the target's place along the path; the objects between them each move one place toward where it was, except hidden and locked objects, which keep their places. Only the order along the path changes: Space, the start, Pivot, Rotate and the order in the Layers panel stay. A target that is the object itself changes nothing → {group, member, index}", + has_doc, + move_member + ), cmd!( "object.objectsOnPath.expand", "Expand", @@ -296,6 +318,71 @@ fn options(s: &mut Session, p: &Value) -> Result { Ok(json!({ "ids": groups.iter().map(|g| g.0).collect::>() })) } +// ---------- Select/Move ---------- + +/// The Objects on Path group whose object `key` of `p` names, checked: an object listed in the +/// group's record (not its path), in a group that is not hidden or locked. +fn member_param(s: &Session, p: &Value, key: &str, cmd: &str) -> Result<(NodeId, NodeId)> { + let v = p.get(key).filter(|v| !v.is_null()).ok_or_else(|| bad(cmd, format!("missing `{key}`")))?; + let id = checked_id(s, v, cmd)?; + let d = &s.doc()?.doc; + let not_on_path = || bad(cmd, format!("`{key}` must name an object attached to the path of an Objects on Path group")); + let g = + d.ancestry(id).unwrap_or_default().into_iter().rev().nth(1).filter(|g| d.node(*g).is_some_and(is_objects_on_path)).ok_or_else(not_on_path)?; + let listed = d.node(g).and_then(|n| n.extras().objects_on_path.as_deref()).is_some_and(|rec| rec.members.iter().any(|m| m.id == id)); + if !listed { + return Err(not_on_path()); + } + if !d.is_editable(g) { + return Err(bad(cmd, "the Objects on Path group is hidden or locked")); + } + if !d.is_editable(id) { + return Err(bad(cmd, format!("`{key}` names an object that is hidden or locked"))); + } + Ok((g, id)) +} + +fn select_member(s: &mut Session, p: &Value) -> Result { + let (g, member) = member_param(s, p, "member", "object.objectsOnPath.selectMember")?; + s.select(|_, sel| sel.set_on_path_member(g, member))?; + Ok(json!({ "group": g.0, "member": member.0 })) +} + +fn move_member(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.objectsOnPath.moveMember"; + let (g, member) = member_param(s, p, "member", C)?; + let (target_group, target) = member_param(s, p, "target", C)?; + if target_group != g { + return Err(bad(C, "`target` must be an object of the same Objects on Path group as `member`")); + } + if let Some(v) = p.get("group").filter(|v| !v.is_null()) + && checked_id(s, v, C)? != g + { + return Err(bad(C, "`group` must be the Objects on Path group of `member` and `target`")); + } + let at = |s: &Session| -> Result { + let rec = s.doc()?.doc.node(g).and_then(|n| n.extras().objects_on_path.as_deref().cloned()).ok_or(EngineError::NoNode(g))?; + rec.members.iter().position(|m| m.id == member).ok_or(EngineError::NoNode(member)) + }; + // Onto its own place: nothing changes, and no undo step is recorded. + if target == member { + return Ok(json!({ "group": g.0, "member": member.0, "index": at(s)? })); + } + // Hidden and locked objects between them keep their places. + let index = s.edit("Move Object on Path", |d, _| { + let mut rec = d.node(g).and_then(|n| n.extras().objects_on_path.as_deref().cloned()).ok_or(EngineError::NoNode(g))?; + let fixed: HashSet = { + let editable = d.editable(); + rec.members.iter().map(|m| m.id).filter(|&id| !editable(id)).collect() + }; + let index = rec.move_member(member, target, |id| fixed.contains(&id)).ok_or(EngineError::NoNode(member))?; + let n = d.node_mut(g).ok_or(EngineError::NoNode(g))?; + n.extras_mut().objects_on_path = Some(Box::new(rec)); + Ok(index) + })?; + Ok(json!({ "group": g.0, "member": member.0, "index": index })) +} + // ---------- Expand ---------- fn expand(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/tests/objects_on_path.rs b/crates/engine/src/tests/objects_on_path.rs index 8c0f359df4..4ae8321167 100644 --- a/crates/engine/src/tests/objects_on_path.rs +++ b/crates/engine/src/tests/objects_on_path.rs @@ -531,6 +531,139 @@ fn a_space_that_is_not_usable_is_refused_and_one_too_large_is_kept_to_the_path() assert_eq!(space(&s, g), (Some(100.0), 0.0)); } +/// The ids of group `g`'s objects in their order along the path, and of its children in the +/// Layers panel's order. +fn orders(s: &Session, g: NodeId) -> (Vec, Vec) { + let n = s.doc().unwrap().doc.node(g).unwrap(); + let path = n.extras().objects_on_path.as_deref().unwrap().members.iter().map(|m| m.id).collect(); + (path, n.children().unwrap().iter().map(|c| c.id).collect()) +} + +/// Three objects of different widths (20, 40 and 10 pt) attached to a 300 pt line from (50, 300), +/// in that order, with a Space of 100 pt from 0, Pivot 4 and Rotate 0. +fn three_on_a_line(s: &mut Session) -> (NodeId, [NodeId; 3], NodeId) { + let a = rect(s, 0.0, 0.0, 20.0, 20.0); + let b = rect(s, 100.0, 0.0, 40.0, 20.0); + let c = rect(s, 200.0, 0.0, 10.0, 20.0); + let path = line(s, (50.0, 300.0), (350.0, 300.0)); + let g = attach(s, path, &[a, b, c], json!({})); + s.execute("object.objectsOnPath.options", &json!({"spacing": 100})).unwrap(); + (g, [a, b, c], path) +} + +#[test] +fn move_member_takes_the_target_s_place_and_shifts_the_objects_between() { + let mut s = session(); + let (g, [a, b, c], path) = three_on_a_line(&mut s); + let (_, layers) = orders(&s, g); + let before = s.doc().unwrap().doc.node(g).unwrap().extras().objects_on_path.as_deref().cloned().unwrap(); + let steps = s.doc().unwrap().history.undo.len(); + let v = s.execute("object.objectsOnPath.moveMember", &json!({"member": a.0, "target": c.0})).unwrap(); + assert_eq!(v, json!({"group": g.0, "member": a.0, "index": 2})); + assert_eq!(orders(&s, g), (vec![b, c, a], layers.clone()), "the order along the path changes, the Layers order does not"); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "one undo step"); + assert_eq!(center(&s, b), Point::new(50.0, 300.0)); + assert_eq!(center(&s, c), Point::new(150.0, 300.0)); + assert_eq!(center(&s, a), Point::new(250.0, 300.0)); + let after = s.doc().unwrap().doc.node(g).unwrap().extras().objects_on_path.as_deref().cloned().unwrap(); + assert_eq!( + (after.path, after.pivot, after.rotate, after.spacing, after.start), + (path, before.pivot, before.rotate, before.spacing, before.start) + ); + let w = bounds(&s, a); + assert!((w.width() - 20.0).abs() < 1e-9, "the object keeps its art: {w:?}"); + // The last one back to the first place, with its group named. + let v = s.execute("object.objectsOnPath.moveMember", &json!({"group": g.0, "member": a.0, "target": b.0})).unwrap(); + assert_eq!(v["index"], 0); + assert_eq!(orders(&s, g).0, [a, b, c]); + assert_eq!(center(&s, a), Point::new(50.0, 300.0)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(orders(&s, g).0, [b, c, a]); + assert_eq!(center(&s, a), Point::new(250.0, 300.0)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(orders(&s, g).0, [a, b, c]); + assert_eq!(center(&s, c), Point::new(250.0, 300.0)); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(orders(&s, g).0, [b, c, a]); + // Onto its own place: nothing changes and no step is recorded. + let steps = s.doc().unwrap().history.undo.len(); + let v = s.execute("object.objectsOnPath.moveMember", &json!({"member": c.0, "target": c.0})).unwrap(); + assert_eq!(v["index"], 1); + assert_eq!(s.doc().unwrap().history.undo.len(), steps); + // The order saves and reopens. + let data = s.execute("document.export", &json!({"format": "vectorcraft"})).unwrap()["dataBase64"].clone(); + let mut o = Session::new(); + o.execute("document.open", &json!({"name": "objects.vectorcraft", "dataBase64": data})).unwrap(); + assert_eq!(orders(&o, g), (vec![b, c, a], layers)); + assert_eq!(center(&o, a), Point::new(250.0, 300.0)); +} + +#[test] +fn move_member_refuses_what_is_not_an_object_on_the_same_path() { + let mut s = session(); + let (g, [a, b, c], path) = three_on_a_line(&mut s); + let loose = rect(&mut s, 300.0, 20.0, 10.0, 10.0); + let x = rect(&mut s, 0.0, 200.0, 10.0, 10.0); + let other_line = line(&mut s, (50.0, 380.0), (350.0, 380.0)); + let h = attach(&mut s, other_line, &[x], json!({})); + let err = |s: &mut Session, p: Value| s.execute("object.objectsOnPath.moveMember", &p).unwrap_err().to_string(); + assert!(err(&mut s, json!({"member": path.0, "target": a.0})).contains("`member` must name an object attached"), "the path"); + assert!(err(&mut s, json!({"member": a.0, "target": path.0})).contains("`target` must name"), "the path as the target"); + assert!(err(&mut s, json!({"member": loose.0, "target": a.0})).contains("`member` must name"), "an object on no path"); + assert!(err(&mut s, json!({"member": g.0, "target": a.0})).contains("`member` must name"), "the group itself"); + assert!(err(&mut s, json!({"member": a.0, "target": x.0})).contains("same Objects on Path group"), "another group's object"); + assert!(err(&mut s, json!({"member": a.0, "target": b.0, "group": h.0})).contains("`group` must be"), "another group named"); + assert!(err(&mut s, json!({"target": b.0})).contains("missing `member`")); + assert!(err(&mut s, json!({"member": a.0})).contains("missing `target`")); + assert!(err(&mut s, json!({"member": 99999, "target": b.0})).contains("no such object")); + assert!(err(&mut s, json!({"member": "a", "target": b.0})).contains("invalid object id")); + // A locked object neither moves nor gives its place, and is not chosen. + select(&mut s, &[c]); + s.execute("object.lock", &json!({})).unwrap(); + assert!(err(&mut s, json!({"member": c.0, "target": a.0})).contains("`member` names an object that is hidden or locked")); + assert!(err(&mut s, json!({"member": a.0, "target": c.0})).contains("`target` names an object that is hidden or locked")); + assert!(s.execute("object.objectsOnPath.selectMember", &json!({"member": c.0})).is_err()); + s.execute("object.unlockAll", &json!({})).unwrap(); + // A locked object between the two keeps its place, and the others pass over it. + select(&mut s, &[b]); + s.execute("object.lock", &json!({})).unwrap(); + let v = s.execute("object.objectsOnPath.moveMember", &json!({"member": a.0, "target": c.0})).unwrap(); + assert_eq!(v["index"], 2); + assert_eq!(orders(&s, g).0, [c, b, a]); + assert_eq!(center(&s, b), Point::new(150.0, 300.0), "the locked object stays where it was"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("object.unlockAll", &json!({})).unwrap(); + select(&mut s, &[g]); + s.execute("object.lock", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.node(g).unwrap().locked); + let e = err(&mut s, json!({"member": a.0, "target": b.0})); + assert!(e.contains("hidden or locked"), "a locked group: {e}"); + assert!(s.execute("object.objectsOnPath.selectMember", &json!({"member": a.0})).is_err(), "a locked group's object is not chosen"); + assert_eq!(orders(&s, g).0, [a, b, c], "nothing refused changed the order"); +} + +#[test] +fn select_member_selects_the_group_with_the_object_chosen() { + let mut s = session(); + let (g, [a, b, _], path) = three_on_a_line(&mut s); + let v = s.execute("object.objectsOnPath.selectMember", &json!({"member": b.0})).unwrap(); + assert_eq!(v, json!({"group": g.0, "member": b.0})); + let sel = &s.doc().unwrap().selection; + assert_eq!((sel.objects.as_slice(), sel.on_path_member), (&[g][..], Some(b))); + // Moving the chosen object keeps it chosen. + s.execute("object.objectsOnPath.moveMember", &json!({"member": b.0, "target": a.0})).unwrap(); + assert_eq!(s.doc().unwrap().selection.on_path_member, Some(b)); + assert!(s.execute("object.objectsOnPath.selectMember", &json!({"member": path.0})).is_err()); + select(&mut s, &[g]); + assert_eq!(s.doc().unwrap().selection.on_path_member, None, "another selection clears it"); + // Both take their objects without a selection. + select(&mut s, &[]); + s.execute("object.objectsOnPath.moveMember", &json!({"member": a.0, "target": b.0})).unwrap(); + assert_eq!(orders(&s, g).0[..2], [a, b]); + s.execute("object.objectsOnPath.selectMember", &json!({"member": a.0})).unwrap(); + assert_eq!(s.doc().unwrap().selection.on_path_member, Some(a)); +} + #[test] fn the_space_saves_and_reopens_and_goes_with_a_copy() { let mut s = session(); diff --git a/crates/tools/src/objects_on_path.rs b/crates/tools/src/objects_on_path.rs index c2f90d0bef..a60c9fb15a 100644 --- a/crates/tools/src/objects_on_path.rs +++ b/crates/tools/src/objects_on_path.rs @@ -29,12 +29,24 @@ //! distance along the path shows next to the pointer in the document's units, the drag is //! previewed and kept as one undo step on release, and Escape cancels it. //! +//! The Select/Move widgets: with one Objects on Path group selected, the same tools show a small +//! ring on each object's attachment point, highlighted under the pointer. A click on one chooses +//! that object (`object.objectsOnPath.selectMember`), and its ring is filled; the group stays +//! selected, and a click on the object's art still selects the group. Dragging a ring along the +//! path moves its object to the place of the object nearest the pointer, and the objects between +//! move one place toward where it was (`object.objectsOnPath.moveMember`): only their order along +//! the path changes, and hidden and locked objects keep their places. On an open path a pointer beyond an end takes the place at that end; on a +//! closed path the drag goes on round across the first anchor. The drag is previewed and kept as +//! one undo step on release, and Escape cancels it. +//! //! Behavior: , //! . use serde_json::{Value, json}; use vectorcraft_doc::blend::Spine; -use vectorcraft_doc::objects_on_path::{CENTER, Member, ObjectsOnPath, attachable_path, is_objects_on_path, layout, nth_length, path_length, stop}; +use vectorcraft_doc::objects_on_path::{ + CENTER, Member, ObjectsOnPath, attachable_path, is_objects_on_path, layout, nth_length, path_length, stop, stops, +}; use vectorcraft_doc::{Document, Node, NodeId, Selection}; use vectorcraft_geom::{BezPath, Point, Vec2}; @@ -68,6 +80,11 @@ pub const MOVE_ALL_HALF_PX: f32 = 4.5; /// How near (screen px) the pointer must be to hit the Move All widget. const MOVE_ALL_HIT_PX: f64 = 6.0; +/// The radius (screen px) of the Select/Move widget's ring, as the canvas draws it. +pub const MEMBER_RADIUS_PX: f32 = 4.5; +/// How near (screen px) the pointer must be to hit a Select/Move widget. +const MEMBER_HIT_PX: f64 = 6.0; + /// The Objects on Path group the widgets show for at `zoom` (screen px per document point), with /// its record, its children and its path: only when the selection is that one editable group, on /// a path with length, and no anchors are Direct-Selected. @@ -567,6 +584,180 @@ impl MoveAllDrag { } } +/// The Select/Move widget of one object of a selected Objects on Path group: a small ring on the +/// object's attachment point. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct MemberWidget { + pub group: NodeId, + /// The object. + pub member: NodeId, + /// The object's place along the path (0: the first). + pub index: usize, + /// The object's attachment point, in the document: the widget's center. + pub at: Point, + /// Is the object the chosen one ([`Selection::on_path_member`])? + pub chosen: bool, +} + +impl MemberWidget { + /// The widgets of the selection at `zoom` (screen px per document point), one per object in + /// its order along the path that is neither hidden nor locked: only when the selection is one + /// editable Objects on Path group on a path with length, and no anchors are Direct-Selected. + pub fn all(doc: &Document, selection: &Selection, zoom: f64) -> Vec { + let Some((group, rec, _, path)) = one_group(doc, selection, zoom) else { return vec![] }; + let Some(at) = stops(path, rec.members.len(), rec.spacing, rec.start) else { return vec![] }; + let editable = doc.editable(); + rec.members + .iter() + .zip(at) + .enumerate() + .filter(|(_, (m, (p, _)))| p.x.is_finite() && p.y.is_finite() && editable(m.id)) + .map(|(index, (m, (at, _)))| Self { group, member: m.id, index, at, chosen: selection.on_path_member == Some(m.id) }) + .collect() + } + + /// The widget of `cx` nearest `p`, when `p` is within reach of it; of widgets as near, the + /// chosen one, else the first along the path. + fn at_point(cx: &ToolContext, p: Point) -> Option { + let reach = cx.tol(MEMBER_HIT_PX); + // Every attachment point lies on the path: a pointer beyond reach of its bounds is over none. + let (_, _, _, path) = one_group(cx.doc, cx.selection, cx.zoom)?; + if !path.control_bounds()?.inflate(reach, reach).contains(p) { + return None; + } + let mut best: Option<(f64, Self)> = None; + for w in Self::all(cx.doc, cx.selection, cx.zoom) { + let d = w.at.distance(p); + let nearer = best.is_none_or(|(b, bw)| d < b - 1e-9 || (d <= b + 1e-9 && w.chosen && !bw.chosen)); + if d <= reach && nearer { + best = Some((d, w)); + } + } + best.map(|(_, w)| w) + } +} + +/// Is `p` over a Select/Move widget? +pub fn over_member(cx: &ToolContext, p: Point) -> bool { + MemberWidget::at_point(cx, p).is_some() +} + +/// The highlight of the Select/Move widget under `p`, if any: a ring around it. +pub fn member_hover(cx: &ToolContext, p: Point) -> Option { + let w = MemberWidget::at_point(cx, p)?; + let r = cx.tol(f64::from(MEMBER_RADIUS_PX) + 2.0); + let ring = vectorcraft_geom::shapes::ellipse(vectorcraft_geom::Rect::new(w.at.x - r, w.at.y - r, w.at.x + r, w.at.y + r)); + Some(Overlay::Path { path: ring.to_bezpath(), color: HIGHLIGHT, width: 1.5, dashed: false }) +} + +/// Dragging a Select/Move widget: the object goes to the place along the path nearest the +/// pointer, and the objects between it and its place move one place toward where it was, except +/// hidden and locked ones (`object.objectsOnPath.moveMember`). On an open path a pointer beyond +/// an end takes the place at that end; on a closed path the pointer goes on round across the first +/// anchor. Where two places are as near, the one the drag is at stays. A click without a drag, and +/// the release of a drag, choose the object (`object.objectsOnPath.selectMember`). +#[derive(Clone, Debug)] +pub struct MemberDrag { + widget: MemberWidget, + /// The objects in their order along the path when the press began. + order: Vec, + /// The distance along the path to each place (points), in that order. + places: Vec, + /// Can the drag take each place? Not the place of a hidden or locked object. + open: Vec, + /// The length of the path (points). + len: f64, + /// Is the path closed? + closed: bool, + start: Point, + began: bool, + /// The distance along the path to the point nearest the pointer at the last drag (points). + last: f64, + /// The place the drag puts the object at. + target: usize, +} + +impl MemberDrag { + /// The drag has left the click: the object is moving. + pub fn adjusting(&self) -> bool { + self.began + } + + /// Start a drag when the press `ev` is on a Select/Move widget. + pub fn hit(cx: &ToolContext, ev: &PointerEvent) -> Option { + let widget = MemberWidget::at_point(cx, ev.pos)?; + let n = cx.doc.node(widget.group)?; + let rec = n.extras().objects_on_path.as_deref()?; + let (len, closed) = path_length(attachable_path(n.children()?.iter().find(|c| c.id == rec.path)?)?)?; + let count = rec.members.len(); + let (s0, gap) = layout(len, closed, count, rec.spacing, rec.start); + let places: Vec = (0..count).map(|i| nth_length(len, closed, s0, gap, i)).collect(); + let last = *places.get(widget.index)?; + let editable = cx.doc.editable(); + let open = rec.members.iter().map(|m| editable(m.id)).collect(); + let order = rec.members.iter().map(|m| m.id).collect(); + Some(Self { widget, order, places, open, len, closed, start: ev.pos, began: false, last, target: widget.index }) + } + + /// How far apart along the path the distances `a` and `b` are: the shorter way round a closed + /// path. + fn apart(&self, a: f64, b: f64) -> f64 { + let d = (a - b).abs(); + if self.closed { d.rem_euclid(self.len).min(self.len - d.rem_euclid(self.len)) } else { d } + } + + /// The place nearest the distance `t` along the path, of the places the drag can take; the + /// current one where another is as near. + fn nearest_place(&self, t: f64) -> usize { + let t = if self.closed { t } else { t.clamp(0.0, self.len) }; + let now = self.places.get(self.target).map_or(f64::INFINITY, |s| self.apart(t, *s)); + let mut best = (self.target, now); + for (i, s) in self.places.iter().enumerate().filter(|(i, _)| self.open.get(*i).copied().unwrap_or(false)) { + let d = self.apart(t, *s); + if d.is_finite() && d < best.1 - 1e-9 { + best = (i, d); + } + } + best.0 + } + + /// The pointer moved to `p`. + pub fn drag(&mut self, cx: &ToolContext, p: Point) -> Vec { + let mut out = vec![]; + if !self.began { + if p.distance(self.start) < cx.tol(3.0) { + return out; + } + self.began = true; + out.push(Action::Begin("Move Object on Path".into())); + } + let w = &self.widget; + let Some((_, t)) = group_spine(cx.doc, w.group).and_then(|s| s.nearest(p, Some(self.last))) else { return out }; + if !t.is_finite() { + return out; + } + self.last = t; + let target = self.nearest_place(t); + if target != self.target { + self.target = target; + let to = self.order.get(target).copied().unwrap_or(w.member); + out.push(Action::Preview("object.objectsOnPath.moveMember".into(), json!({ "group": w.group.0, "member": w.member.0, "target": to.0 }))); + } + out + } + + /// The release: a drag that moved the object keeps it as one undo step, one that ends where it + /// began keeps nothing, and either way the object is chosen. + pub fn finish(self) -> Vec { + let choose = Action::Exec("object.objectsOnPath.selectMember".into(), json!({ "member": self.widget.member.0 })); + match (self.began, self.target == self.widget.index) { + (false, _) => vec![choose], + (true, true) => vec![Action::Cancel, choose], + (true, false) => vec![Action::Commit, choose], + } + } +} + /// The path of the Objects on Path group `g` of `doc`, to measure along. fn group_spine(doc: &Document, g: NodeId) -> Option { let n = doc.node(g)?; @@ -602,6 +793,10 @@ pub struct ObjectsOnPathTool { spacing: Option, /// The Move All widget drag, while the button is down after a press on the widget. moving: Option, + /// The Select/Move widget drag, while the button is down after a press on a widget. + member: Option, + /// Where the pointer is while no button is down, for the Select/Move widget it is over. + pointer: Option, /// The reference point of each object attached next (0-8, row by row from the top left). pivot: u8, /// The angle of each object attached next, in degrees counter-clockwise from upright on the path. @@ -610,7 +805,7 @@ pub struct ObjectsOnPathTool { impl Default for ObjectsOnPathTool { fn default() -> Self { - Self { hover: None, press: None, rotating: None, spacing: None, moving: None, pivot: CENTER, rotate: 0.0 } + Self { hover: None, press: None, rotating: None, spacing: None, moving: None, member: None, pointer: None, pivot: CENTER, rotate: 0.0 } } } @@ -642,9 +837,18 @@ impl Tool for ObjectsOnPathTool { _ => vec![], }; } + if let Some(d) = &mut self.member { + return match ev.kind { + PointerKind::Drag => d.drag(cx, p), + PointerKind::Up => self.member.take().map(MemberDrag::finish).unwrap_or_default(), + _ => vec![], + }; + } + self.pointer = (ev.kind == PointerKind::Move).then_some(p); match ev.kind { PointerKind::Move => { - self.hover = if over_rotate_all(cx, p) || over_space(cx, p) || over_move_all(cx, p) { None } else { target(cx, p) }; + let widget = over_rotate_all(cx, p) || over_space(cx, p) || over_move_all(cx, p) || over_member(cx, p); + self.hover = if widget { None } else { target(cx, p) }; vec![] } PointerKind::Down => { @@ -663,6 +867,11 @@ impl Tool for ObjectsOnPathTool { self.hover = None; return vec![]; } + if let Some(d) = MemberDrag::hit(cx, ev) { + self.member = Some(d); + self.hover = None; + return vec![]; + } self.press = Some(p); vec![] } @@ -689,16 +898,22 @@ impl Tool for ObjectsOnPathTool { if let Some(d) = &self.moving { o.extend(d.overlays(cx)); } + if let Some(p) = self.pointer.filter(|_| !self.busy()) { + o.extend(member_hover(cx, p)); + } o } fn cursor(&self, cx: &ToolContext, p: Point, _mods: Mods) -> Cursor { + if self.member.is_some() { + return Cursor::Move; + } let idle = self.spacing.is_none() && self.moving.is_none(); if self.rotating.is_some() || (idle && over_rotate_all(cx, p)) { Cursor::Rotate } else if self.spacing.is_some() || (self.moving.is_none() && over_space(cx, p)) { Cursor::ArrowHollow - } else if self.moving.is_some() || over_move_all(cx, p) { + } else if self.moving.is_some() || over_move_all(cx, p) || over_member(cx, p) { Cursor::Move } else if target(cx, p).is_some() { Cursor::ObjectsOnPath @@ -708,8 +923,8 @@ impl Tool for ObjectsOnPathTool { } /// Escape while the button is down cancels the click, so the release attaches nothing, or the - /// Rotate All, Space or Move All drag, so the objects go back to the Rotate, Space or place - /// they had. + /// Rotate All, Space, Move All or Select/Move drag, so the objects go back to the Rotate, Space, + /// place or order they had. fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { if key == ToolKey::Escape { self.press = None; @@ -717,7 +932,8 @@ impl Tool for ObjectsOnPathTool { let rotated = self.rotating.take().is_some_and(|d| d.adjusting()); let spaced = self.spacing.take().is_some_and(|d| d.adjusting()); let moved = self.moving.take().is_some_and(|d| d.adjusting()); - if rotated || spaced || moved { + let ordered = self.member.take().is_some_and(|d| d.adjusting()); + if rotated || spaced || moved || ordered { return vec![Action::Cancel]; } } @@ -747,13 +963,14 @@ impl Tool for ObjectsOnPathTool { } fn busy(&self) -> bool { - self.press.is_some() || self.rotating.is_some() || self.spacing.is_some() || self.moving.is_some() + self.press.is_some() || self.rotating.is_some() || self.spacing.is_some() || self.moving.is_some() || self.member.is_some() } fn transforming(&self) -> bool { self.rotating.as_ref().is_some_and(RotateAllDrag::adjusting) || self.spacing.as_ref().is_some_and(SpaceDrag::adjusting) || self.moving.as_ref().is_some_and(MoveAllDrag::adjusting) + || self.member.as_ref().is_some_and(MemberDrag::adjusting) } } @@ -1469,4 +1686,229 @@ mod tests { assert!(t.pointer(&c, &ev(PointerKind::Up, at.x, at.y)).is_empty(), "{}: the release keeps nothing", t.id()); } } + + /// A document holding an Objects on Path group of `n` 20 × 20 squares attached by their centers + /// to `path`, with the group's Space `spacing`, a selection of the group, and the squares' ids + /// in their order along the path. + fn members_doc(path: vectorcraft_geom::PathData, n: usize, spacing: Option) -> (Document, NodeId, Selection, Vec) { + let mut d = Document::new(500.0, 500.0); + let l = d.layers[0].id; + let (line, g) = (d.alloc_id(), d.alloc_id()); + let line = Node::path(line, path, Appearance::default_art()); + let squares: Vec = (0..n) + .map(|i| { + let x = 50.0 * i as f64; + Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, 50.0, x + 20.0, 70.0)), Appearance::default_art()) + }) + .collect(); + let ids: Vec = squares.iter().map(|s| s.id).collect(); + let rec = vectorcraft_doc::objects_on_path::ObjectsOnPath { + spacing, + ..vectorcraft_doc::objects_on_path::ObjectsOnPath::new(line.id, &squares.iter().collect::>(), CENTER, 0.0) + }; + let mut children = vec![Arc::new(line)]; + children.extend(squares.into_iter().map(Arc::new)); + let mut group = Node::new(g, NodeKind::Group { children, clip: false }); + group.extras_mut().objects_on_path = Some(Box::new(rec)); + vectorcraft_doc::objects_on_path::place(&mut group, &Document::new(1.0, 1.0).symbol_boxes()); + d.insert(Some(l), 0, group).unwrap(); + let mut sel = Selection::default(); + sel.set([g]); + (d, g, sel, ids) + } + + /// Three squares on the line from (0, 300) to (400, 300): at 0, 200 and 400. + fn three_doc() -> (Document, NodeId, Selection, Vec) { + members_doc(shapes::line(Point::new(0.0, 300.0), Point::new(400.0, 300.0)), 3, None) + } + + /// The `target` a drag's actions preview for `object.objectsOnPath.moveMember`, if any. + fn previewed_target(acts: &[Action]) -> Option { + acts.iter().rev().find_map(|a| match a { + Action::Preview(cmd, p) if cmd == "object.objectsOnPath.moveMember" => p["target"].as_u64(), + _ => None, + }) + } + + fn choose(id: NodeId) -> Action { + Action::Exec("object.objectsOnPath.selectMember".into(), json!({ "member": id.0 })) + } + + #[test] + fn a_select_move_widget_sits_on_each_attachment_point() { + let (d, g, sel, ids) = three_doc(); + let ws = MemberWidget::all(&d, &sel, 1.0); + let at: Vec<(NodeId, usize, Point, bool)> = ws.iter().map(|w| (w.member, w.index, w.at, w.chosen)).collect(); + assert!(ws.iter().all(|w| w.group == g)); + assert_eq!(at.len(), 3); + for (i, x) in [0.0, 200.0, 400.0].into_iter().enumerate() { + assert!(at[i].0 == ids[i] && at[i].1 == i && near(at[i].2, Point::new(x, 300.0)) && !at[i].3, "{at:?}"); + } + let mut chosen = sel.clone(); + chosen.on_path_member = Some(ids[1]); + assert_eq!(MemberWidget::all(&d, &chosen, 1.0).iter().map(|w| w.chosen).collect::>(), [false, true, false]); + // None for an object in the group, with anchors Direct-Selected, or for a locked group. + let mut one = Selection::default(); + one.set([ids[0]]); + assert!(MemberWidget::all(&d, &one, 1.0).is_empty()); + let mut anchors = sel.clone(); + anchors.anchors.insert(ids[0], [(0, 0)].into()); + assert!(MemberWidget::all(&d, &anchors, 1.0).is_empty()); + let (mut locked, lg, lsel, _) = three_doc(); + locked.node_mut(lg).unwrap().locked = true; + assert!(MemberWidget::all(&locked, &lsel, 1.0).is_empty()); + } + + /// A click on a widget chooses its object without moving it, with the Selection tool and the + /// Objects on Path tool; a click on the object's art away from the widget does not. + #[test] + fn a_click_on_a_select_move_widget_chooses_its_object() { + let (d, _, sel, ids) = three_doc(); + let paint = paint(); + let c = cx(&d, &sel, &paint); + let tools: [Box; 2] = [Box::new(crate::select::SelectionTool::default()), Box::new(ObjectsOnPathTool::default())]; + for mut t in tools { + t.pointer(&c, &ev(PointerKind::Move, 202.0, 301.0)); + assert!(t.overlays(&c).iter().any(|o| matches!(o, Overlay::Path { color, .. } if *color == HIGHLIGHT)), "{}: highlighted", t.id()); + assert_eq!(t.cursor(&c, Point::new(202.0, 301.0), Mods::default()), Cursor::Move, "{}", t.id()); + assert!(t.pointer(&c, &ev(PointerKind::Down, 202.0, 301.0)).is_empty()); + assert!(t.pointer(&c, &ev(PointerKind::Drag, 203.0, 301.0)).is_empty(), "{}: within the click", t.id()); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 203.0, 301.0)), vec![choose(ids[1])], "{}", t.id()); + assert!(!t.busy()); + // On the art, 8 px from the attachment point: not the widget. + t.pointer(&c, &ev(PointerKind::Down, 208.0, 300.0)); + let acts = t.pointer(&c, &ev(PointerKind::Up, 208.0, 300.0)); + assert!(!acts.contains(&choose(ids[1])), "{}: {acts:?}", t.id()); + } + } + + /// A widget at an end of the path is within reach from beyond the path's bounds too, up to + /// 6 px away. + #[test] + fn a_select_move_widget_at_an_end_is_hit_from_beyond_the_path() { + let (d, _, sel, _) = three_doc(); + let paint = paint(); + let c = cx(&d, &sel, &paint); + assert!(over_member(&c, Point::new(-5.0, 300.0)), "before the start"); + assert!(over_member(&c, Point::new(405.0, 300.0)), "after the end"); + assert!(over_member(&c, Point::new(0.0, 305.5)), "beside the line"); + assert!(!over_member(&c, Point::new(0.0, 306.5)), "out of reach"); + assert!(!over_member(&c, Point::new(-6.5, 300.0)), "out of reach before the start"); + } + + /// Dragging the middle object's widget past the last one moves it to the last place; dragging + /// the last one's beyond the start of the line moves it to the first place. Each drag is one + /// undo step, and the release chooses the object. + #[test] + fn dragging_a_select_move_widget_moves_its_object_to_the_nearest_place() { + let (d, g, sel, ids) = three_doc(); + let paint = paint(); + let c = cx(&d, &sel, &paint); + let tools: [Box; 2] = [Box::new(crate::select::SelectionTool::default()), Box::new(ObjectsOnPathTool::default())]; + for mut t in tools { + t.pointer(&c, &ev(PointerKind::Down, 200.0, 300.0)); + let a = t.pointer(&c, &ev(PointerKind::Drag, 280.0, 310.0)); + assert_eq!(a, vec![Action::Begin("Move Object on Path".into())], "{}: still nearest its own place", t.id()); + assert!(t.transforming() && t.cursor(&c, Point::new(280.0, 310.0), Mods::default()) == Cursor::Move); + let a = t.pointer(&c, &ev(PointerKind::Drag, 330.0, 300.0)); + assert!( + matches!(a.as_slice(), [Action::Preview(cmd, p)] if cmd == "object.objectsOnPath.moveMember" && *p == json!({ "group": g.0, "member": ids[1].0, "target": ids[2].0 })), + "{}: {a:?}", + t.id() + ); + assert!(t.pointer(&c, &ev(PointerKind::Drag, 600.0, 300.0)).is_empty(), "{}: beyond the end, the last place", t.id()); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 600.0, 300.0)), vec![Action::Commit, choose(ids[1])], "{}", t.id()); + assert!(!t.busy() && !t.transforming()); + // The last one, back before the start of the line. + t.pointer(&c, &ev(PointerKind::Down, 400.0, 300.0)); + let a = t.pointer(&c, &ev(PointerKind::Drag, 250.0, 300.0)); + assert_eq!(previewed_target(&a), Some(ids[1].0), "{}: {a:?}", t.id()); + let a = t.pointer(&c, &ev(PointerKind::Drag, -80.0, 300.0)); + assert_eq!(previewed_target(&a), Some(ids[0].0), "{}: {a:?}", t.id()); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, -80.0, 300.0)), vec![Action::Commit, choose(ids[2])], "{}", t.id()); + } + } + + /// A drag that comes back to its own place previews the object back there and keeps nothing on + /// release; Escape cancels a drag. + #[test] + fn a_select_move_drag_back_to_its_place_or_escaped_keeps_nothing() { + let (d, _, sel, ids) = three_doc(); + let paint = paint(); + let c = cx(&d, &sel, &paint); + let tools: [Box; 2] = [Box::new(crate::select::SelectionTool::default()), Box::new(ObjectsOnPathTool::default())]; + for mut t in tools { + t.pointer(&c, &ev(PointerKind::Down, 0.0, 300.0)); + assert_eq!(previewed_target(&t.pointer(&c, &ev(PointerKind::Drag, 190.0, 300.0))), Some(ids[1].0)); + assert_eq!(previewed_target(&t.pointer(&c, &ev(PointerKind::Drag, 20.0, 300.0))), Some(ids[0].0), "{}: back onto itself", t.id()); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 20.0, 300.0)), vec![Action::Cancel, choose(ids[0])], "{}", t.id()); + t.pointer(&c, &ev(PointerKind::Down, 0.0, 300.0)); + assert!(previewed_target(&t.pointer(&c, &ev(PointerKind::Drag, 390.0, 300.0))).is_some()); + assert_eq!(t.key(&c, ToolKey::Escape, Mods::default()), vec![Action::Cancel], "{}", t.id()); + assert!(!t.busy() && !t.transforming(), "{}", t.id()); + assert!(t.pointer(&c, &ev(PointerKind::Drag, 0.0, 300.0)).is_empty(), "{}", t.id()); + assert!(t.pointer(&c, &ev(PointerKind::Up, 0.0, 300.0)).is_empty(), "{}: the release keeps nothing", t.id()); + } + } + + /// Round a closed path the drag goes on across the first anchor: the first object, dragged back + /// past it, takes the last place. + #[test] + fn a_select_move_drag_goes_round_a_closed_path_across_its_first_anchor() { + // The square's outline is 400 pt round, from (100, 100) to the right and on clockwise: the + // four objects sit on its corners. + let (d, _, sel, ids) = members_doc(shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0)), 4, None); + let paint = paint(); + let c = cx(&d, &sel, &paint); + let mut t = ObjectsOnPathTool::default(); + t.pointer(&c, &ev(PointerKind::Down, 100.0, 100.0)); + assert!(previewed_target(&t.pointer(&c, &ev(PointerKind::Drag, 100.0, 130.0))).is_none(), "nearer its own place"); + let a = t.pointer(&c, &ev(PointerKind::Drag, 100.0, 180.0)); + assert_eq!(previewed_target(&a), Some(ids[3].0), "{a:?}"); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 100.0, 180.0)), vec![Action::Commit, choose(ids[0])]); + // Forward from the last one across the first anchor: the first place. + t.pointer(&c, &ev(PointerKind::Down, 100.0, 200.0)); + let a = t.pointer(&c, &ev(PointerKind::Drag, 100.0, 120.0)); + assert_eq!(previewed_target(&a), Some(ids[0].0), "{a:?}"); + } + + /// A hidden or locked object has no widget, and a drag does not take its place: the first + /// object, dragged onto the locked last one's place, stays at the nearest place it can take. + #[test] + fn a_locked_object_has_no_select_move_widget_and_keeps_its_place() { + let (mut d, _, sel, ids) = three_doc(); + d.node_mut(ids[2]).unwrap().locked = true; + assert_eq!(MemberWidget::all(&d, &sel, 1.0).iter().map(|w| w.member).collect::>(), ids[..2]); + let paint = paint(); + let c = cx(&d, &sel, &paint); + let mut t = ObjectsOnPathTool::default(); + t.pointer(&c, &ev(PointerKind::Down, 400.0, 300.0)); + assert!(t.pointer(&c, &ev(PointerKind::Up, 400.0, 300.0)).iter().all(|a| *a != choose(ids[2])), "no widget to press"); + t.pointer(&c, &ev(PointerKind::Down, 0.0, 300.0)); + assert_eq!(previewed_target(&t.pointer(&c, &ev(PointerKind::Drag, 390.0, 300.0))), Some(ids[1].0)); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 390.0, 300.0)), vec![Action::Commit, choose(ids[0])]); + } + + /// Of widgets on the same point (no Space between the objects), a press takes the chosen + /// object's, else the first one's; between places as near, a drag keeps the place it is at. + #[test] + fn stacked_select_move_widgets_take_the_chosen_one_and_ties_keep_the_place() { + let (d, _, mut sel, ids) = members_doc(shapes::line(Point::new(0.0, 300.0), Point::new(400.0, 300.0)), 3, Some(0.0)); + let paint = paint(); + { + let c = cx(&d, &sel, &paint); + let mut t = ObjectsOnPathTool::default(); + t.pointer(&c, &ev(PointerKind::Down, 0.0, 300.0)); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 0.0, 300.0)), vec![choose(ids[0])]); + // Every place is as far as the others: the drag stays where it is. + t.pointer(&c, &ev(PointerKind::Down, 0.0, 300.0)); + assert!(previewed_target(&t.pointer(&c, &ev(PointerKind::Drag, 200.0, 300.0))).is_none()); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 200.0, 300.0)), vec![Action::Cancel, choose(ids[0])]); + } + sel.on_path_member = Some(ids[2]); + let c = cx(&d, &sel, &paint); + let mut t = ObjectsOnPathTool::default(); + t.pointer(&c, &ev(PointerKind::Down, 0.0, 300.0)); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 0.0, 300.0)), vec![choose(ids[2])], "the chosen one"); + } } diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 1658346127..3af4a84ea7 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -47,7 +47,9 @@ use vectorcraft_geom::{Affine, Point, Rect, Vec2}; use crate::bbox::{Handle, hit_handle, in_rotate_zone, move_delta, rotate_for_drag, scale_for_drag}; use crate::corners::{self, CornerDrag, over_widget}; use crate::guides::{PointSnap, Targets, distance_guides, hold_on_path}; -use crate::objects_on_path::{MoveAllDrag, RotateAllDrag, SpaceDrag, over_move_all, over_rotate_all, over_space}; +use crate::objects_on_path::{ + MemberDrag, MoveAllDrag, RotateAllDrag, SpaceDrag, member_hover, over_member, over_move_all, over_rotate_all, over_space, +}; use crate::pathtype::{self, BracketDrag, over_bracket}; use crate::pie::{self, PieDrag}; use crate::rulerguide::GuideEdit; @@ -135,6 +137,8 @@ enum State { Space(SpaceDrag), /// Dragging an Objects on Path group's Move All widget. MoveAll(MoveAllDrag), + /// Pressing or dragging the Select/Move widget of an object of an Objects on Path group. + Member(MemberDrag), /// Move or rotate a live mirror axis. Mirror(crate::mirror::MirrorDrag), /// Drag a grid repeat's row, column or corner handle or a spacing slider. @@ -753,7 +757,7 @@ impl Tool for SelectionTool { /// E during a marquee drag switches the marquee between the objects it touches and only the /// objects inside it. Escape during a drag (a move or an Alt-drag copy, a bounding-box scale or - /// rotation, a marquee, a corner widget, a pie widget, the Rotate All, Space or Move All widget, the brackets of type on a path, a mirror + /// rotation, a marquee, a corner widget, a pie widget, the Rotate All, Space, Move All or Select/Move widget, the brackets of type on a path, a mirror /// axis, a grid repeat's handles, a ruler guide) cancels it: what it changed goes back, no copy /// is left, and the tool waits for the next press. With the pointer loaded with text, Escape /// drops the text. @@ -845,6 +849,7 @@ impl Tool for SelectionTool { State::RotateAll(d) => d.adjusting(), State::Space(d) => d.adjusting(), State::MoveAll(d) => d.adjusting(), + State::Member(d) => d.adjusting(), State::Bracket(b) => b.adjusting(), State::Mirror(d) => d.adjusting(), State::GridRepeat(d) => d.adjusting(), @@ -1042,6 +1047,15 @@ impl Tool for SelectionTool { self.state = State::Idle; d.finish() } + (PointerKind::Drag, State::Member(mut d)) => { + let out = d.drag(cx, p); + self.state = State::Member(d); + out + } + (PointerKind::Up, State::Member(d)) => { + self.state = State::Idle; + d.finish() + } (PointerKind::Drag, State::Bracket(mut b)) => { let out = b.drag(cx, p, m.cmd); self.state = State::Bracket(b); @@ -1110,6 +1124,9 @@ impl Tool for SelectionTool { State::Loaded { press: Some((start, cur)), .. } => o.push(Overlay::Marquee(Rect::from_points(*start, *cur))), _ => {} } + if let (Some(p), State::Idle) = (self.bracket_hover, &self.state) { + o.extend(member_hover(cx, p)); + } o.extend(self.guides.iter().cloned()); o.extend(self.guide.overlays(cx)); if let (Some((q, label)), State::Idle, true) = (self.hover, &self.state, cx.smart_guides) { @@ -1158,7 +1175,7 @@ impl Tool for SelectionTool { State::Pie(_) => return Cursor::ArrowHollow, State::RotateAll(_) => return Cursor::Rotate, State::Space(_) => return Cursor::ArrowHollow, - State::MoveAll(_) => return Cursor::Move, + State::MoveAll(_) | State::Member(_) => return Cursor::Move, State::Bracket(_) => return Cursor::PathBracket, State::Loaded { .. } => return Cursor::LoadedText, _ => {} @@ -1184,7 +1201,7 @@ impl Tool for SelectionTool { if over_space(cx, p) { return Cursor::ArrowHollow; } - if over_move_all(cx, p) { + if over_move_all(cx, p) || over_member(cx, p) { return Cursor::Move; } if over_bracket(cx, p) { @@ -1536,7 +1553,7 @@ impl SelectionTool { self.state = State::Loaded { from, before: port == crate::text::Port::In, press: None }; return Some(vec![]); } - // 1. Live Corners widgets, pie widgets, the Rotate All, Space and Move All widgets and type on a path's brackets, then the + // 1. Live Corners widgets, pie widgets, the Rotate All, Space, Move All and Select/Move widgets and type on a path's brackets, then the // bounding-box handles of the current selection. if let Some(c) = CornerDrag::hit(cx, ev, false) { self.state = State::Corner(c); @@ -1558,6 +1575,10 @@ impl SelectionTool { self.state = State::MoveAll(d); return Some(vec![]); } + if let Some(d) = MemberDrag::hit(cx, ev) { + self.state = State::Member(d); + return Some(vec![]); + } if let Some(b) = BracketDrag::hit(cx, ev) { self.state = State::Bracket(b); return Some(vec![]); diff --git a/crates/ui-egui/src/canvas/overlays.rs b/crates/ui-egui/src/canvas/overlays.rs index 8ff6faaf79..88c573c5e4 100644 --- a/crates/ui-egui/src/canvas/overlays.rs +++ b/crates/ui-egui/src/canvas/overlays.rs @@ -238,14 +238,15 @@ pub(super) fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: rotate_all_widget(p, xf, tool, &st.doc, &st.selection); } -/// The Rotate All, Space and Move All widgets (Selection and Objects on Path tools, one Objects on Path -/// group selected). The Rotate All widget is joined by a line to the first object's attachment -/// point. It stays while dragged. +/// The Select/Move, Rotate All, Space and Move All widgets (Selection and Objects on Path tools, one +/// Objects on Path group selected). The Rotate All widget is joined by a line to the first object's +/// attachment point. It stays while dragged. fn rotate_all_widget(p: &egui::Painter, xf: &Xf, tool: &str, doc: &vectorcraft_doc::Document, selection: &vectorcraft_doc::Selection) { use vectorcraft_tools::objects_on_path::{ROTATE_ALL_RADIUS_PX, RotateAllWidget}; if !matches!(tool, "selection" | "objectsOnPath") { return; } + member_widgets(p, xf, doc, selection); if let Some(w) = RotateAllWidget::of(doc, selection, xf.zoom) { let (on, c, color) = (xf.to_screen(w.pivot), xf.to_screen(w.at), c32(doc.layer_color(w.group))); p.line_segment([on, c], Stroke::new(1.0, color)); @@ -256,6 +257,23 @@ fn rotate_all_widget(p: &egui::Painter, xf: &Xf, tool: &str, doc: &vectorcraft_d move_all_widget(p, xf, doc, selection); } +/// The Select/Move widgets: a ring on each object's attachment point, filled blue for the chosen +/// object. They are drawn under the other widgets, which a press takes first. +fn member_widgets(p: &egui::Painter, xf: &Xf, doc: &vectorcraft_doc::Document, selection: &vectorcraft_doc::Selection) { + use vectorcraft_tools::objects_on_path::{MEMBER_RADIUS_PX, MemberWidget}; + for w in MemberWidget::all(doc, selection, xf.zoom) { + let c = xf.to_screen(w.at); + if w.chosen { + p.circle(c, MEMBER_RADIUS_PX, MEMBER_CHOSEN, Stroke::new(1.0, Color32::WHITE)); + } else { + p.circle(c, MEMBER_RADIUS_PX, Color32::TRANSPARENT, Stroke::new(1.5, c32(doc.layer_color(w.group)))); + } + } +} + +/// The fill of the chosen object's Select/Move widget. +pub(super) const MEMBER_CHOSEN: Color32 = Color32::from_rgb(0x1f, 0x6f, 0xeb); + /// The Space widget (beside the Rotate All widget, with two or more objects on the path): a square /// joined by a line to the last object's attachment point. It stays while dragged. fn space_widget(p: &egui::Painter, xf: &Xf, doc: &vectorcraft_doc::Document, selection: &vectorcraft_doc::Selection) { diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index e84d5cd076..6b9f5f1d22 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -4898,6 +4898,38 @@ fn a_selected_objects_on_path_group_shows_its_move_all_widget_when_it_has_room() assert_eq!(widgets(&mut app), 1, "one object"); } +/// A selected Objects on Path group shows a Select/Move widget on each object's attachment point +/// with the Selection and Objects on Path tools, the chosen object's filled blue. +#[test] +fn a_selected_objects_on_path_group_shows_a_select_move_widget_per_object() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let id = |v: serde_json::Value| v["id"].as_u64().unwrap(); + let line = id(app.session.execute("shape.line", &json!({"x1": 20, "y1": 200, "x2": 380, "y2": 200})).unwrap()); + let a = id(app.session.execute("shape.rectangle", &json!({"x": 40, "y": 40, "width": 20, "height": 20})).unwrap()); + let b = id(app.session.execute("shape.rectangle", &json!({"x": 80, "y": 40, "width": 20, "height": 20})).unwrap()); + let c = id(app.session.execute("shape.rectangle", &json!({"x": 120, "y": 40, "width": 20, "height": 20})).unwrap()); + app.run("select.set", json!({"ids": [a, b, c]})).unwrap(); + app.session.execute("object.objectsOnPath.attach", &json!({"path": line})).unwrap(); + let ctx = egui::Context::default(); + let r = vectorcraft_tools::objects_on_path::MEMBER_RADIUS_PX; + let widgets = |app: &mut VectorcraftApp, fill: Color32| { + shapes(app, &ctx).iter().filter(|s| matches!(s, Shape::Circle(k) if k.radius == r && k.fill == fill)).count() + }; + let (ring, chosen) = (Color32::TRANSPARENT, super::overlays::MEMBER_CHOSEN); + app.select_tool("selection"); + assert_eq!((widgets(&mut app, ring), widgets(&mut app, chosen)), (3, 0)); + app.run("object.objectsOnPath.selectMember", json!({"member": b})).unwrap(); + assert_eq!((widgets(&mut app, ring), widgets(&mut app, chosen)), (2, 1), "the chosen one is filled"); + app.select_tool("objectsOnPath"); + assert_eq!((widgets(&mut app, ring), widgets(&mut app, chosen)), (2, 1)); + app.select_tool("directSelection"); + assert_eq!(widgets(&mut app, ring) + widgets(&mut app, chosen), 0); + app.select_tool("selection"); + app.run("select.set", json!({"ids": [a]})).unwrap(); + assert_eq!(widgets(&mut app, ring) + widgets(&mut app, chosen), 0, "an object in the group, not the group"); +} + /// The shapes Type → Show Hidden Characters draws in one frame, seen through `xf`. fn hidden_chars(app: &mut VectorcraftApp, ctx: &egui::Context, xf: &Xf) -> Vec { let raw = egui::RawInput { screen_rect: Some(xf.rect), ..Default::default() }; diff --git a/docs/mcp.md b/docs/mcp.md index 288e86ae3e..53a2d03091 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -8873,6 +8873,30 @@ value the open path is too short for sets the distance, the drag also sets `spac objects stay that far apart. Shift and Alt do not change the drag. With measurement labels on, the first object's distance along the path shows next to the pointer in the document's units. Escape cancels the drag. +The same tools show a Select/Move widget, a small ring, on each object's attachment point; the ring under the pointer +is highlighted. A click on one chooses its object: it sends `object.objectsOnPath.selectMember {member}`, which +selects the group alone with the object chosen, and the chosen object's ring is filled blue. Any other selection +change clears the choice. A click on the object's art away from the ring selects the group as before. Dragging a ring +along the path moves its +object to the place of the object whose attachment point is nearest the pointer along the path, and previews +`object.objectsOnPath.moveMember {group, member, target}`; the release keeps it as one undo step and chooses the +object. A drag that ends on the object's own place, and Escape, keep nothing. On an open path a pointer beyond an end +takes the place at that end; on a closed path the drag goes on round across the first anchor. Where two places are as +near, the drag stays at the place it is at. A hidden or locked object has no widget, and a drag does not take or +shift its place. Shift and Alt do not change the drag. The widgets for Rotate All, Space and +Move All take a press before a Select/Move widget, and of Select/Move widgets on the same point a press takes the +chosen one, else the first along the path. + +`object.objectsOnPath.moveMember {member, target, group?}` moves `member` to `target`'s place along the path: the +objects between them each move one place toward where `member` was, except hidden and locked objects, which keep +their places and are passed over. Both must be objects of the same Objects on Path +group (not its path), and neither they nor the group may be hidden or locked; `group`, when given, must be that +group. Only the +order along the path changes: Space, the start, Pivot, Rotate and the children's order in the Layers panel stay. A +`target` that is `member` changes nothing and records no undo step. It returns `{group, member, index}`, the object's +new place along the path counted from 0. `object.objectsOnPath.selectMember {member}` takes the same kind of `member` +and returns `{group, member}`. + ```json {"name":"run_command","arguments":{"command":"tool.setOption","params":{"tool":"objectsOnPath","values":{"pivot":7,"rotate":30}}}} {"name":"run_command","arguments":{"command":"object.objectsOnPath.attach","params":{"path":30,"ids":[12,15,18]}}} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 351ade4468..4b3dd3bb7d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1289,9 +1289,10 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Window › Blend opens the Blend panel: Make, the selected blend's Spacing and Orientation (with no blend selected, the options new blends start with), Reverse Front to Back, Reverse Spine, Replace Spine, Expand and Release, each running its Object › Blend command. Make is dimmed until two or more objects are selected. Spacing and Orientation are dimmed until a blend or two or more objects are selected, and the other buttons are dimmed until a blend is selected. The Blend panel's Create Spine menu (Auto, Always, Never; `object.blend.createSpine`) sets whether new blends get a spine, from the Blend tool, Make and `object.blend.make` alike ([Blend panel overview](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blend-panel-overview.html)). Auto and Always give every new blend a straight spine, as before; Never makes blends without one, which keep their steps on the straight lines between the keys, have Type and Ramp dimmed, refuse Reverse Spine and spine edits, release without a spine path, and get a spine from Replace Spine. The setting is kept with the preferences and leaves blends already made as they are; Auto as the starting value and keeping the setting across sessions are VectorCraft's own rules. Builds on #5177 by adambelis. Evidence: `tests::blendopts::{create_spine_sets_whether_new_blends_get_a_spine, a_blend_without_a_spine_has_none_to_edit_reverse_ease_or_release, whether_a_blend_has_a_spine_is_saved_and_old_blends_have_one}` and `panels::tests_blend::create_spine_sets_what_new_blends_get_whatever_is_selected`. With the Blend tool, Escape drops the object clicked first and stops the next click adding to the blend just made, so the next click picks a new first object; the document and the selection stay as they are. Regression tests: `escape_drops_the_blend_tools_first_pick` in `meshblend.rs` and `tests/blendopts.rs`. - Objects on Path (#2545): a click with the Objects on Path tool (in Edit Toolbar's Modify section and the Blend tool's flyout; Object › Objects on Path › Attach and the context menu with two or more objects selected choose it) attaches the selected objects to the path clicked, open or closed, in an Objects on Path group with the path at its bottom. The objects go along the path column by column from left to right, spread evenly from its first anchor (the last one sits on the end of an open path), each turned to the path at its attachment point; Pivot and Rotate set where each one attaches and how far it tilts (`object.objectsOnPath.attach`, `object.objectsOnPath.options`), and with Objects on Path groups selected the Properties panel's Objects on Path Options section shows them as a nine-point Pivot grid and a Rotate field with common angles, each change one undo step for every selected group. Editing the path moves the objects, an object moved on its own goes back, objects moved into or out of the group in the Layers panel join or leave the path, and Object › Objects on Path › Expand leaves a plain group. The group saves and reopens live and exports as plain art. With one Objects on Path group selected, the Selection and Objects on Path tools show the Rotate All widget beside its first object: dragging it turns every object about its attachment point by the angle the pointer turns, adding it to the group's Rotate in one undo step, in 15° steps with Shift ([Objects on Path](https://helpx.adobe.com/illustrator/using/objects-on-path.html)). The Select/Move widget and Detach are missing. + Objects on Path (#2545): a click with the Objects on Path tool (in Edit Toolbar's Modify section and the Blend tool's flyout; Object › Objects on Path › Attach and the context menu with two or more objects selected choose it) attaches the selected objects to the path clicked, open or closed, in an Objects on Path group with the path at its bottom. The objects go along the path column by column from left to right, spread evenly from its first anchor (the last one sits on the end of an open path), each turned to the path at its attachment point; Pivot and Rotate set where each one attaches and how far it tilts (`object.objectsOnPath.attach`, `object.objectsOnPath.options`), and with Objects on Path groups selected the Properties panel's Objects on Path Options section shows them as a nine-point Pivot grid and a Rotate field with common angles, each change one undo step for every selected group. Editing the path moves the objects, an object moved on its own goes back, objects moved into or out of the group in the Layers panel join or leave the path, and Object › Objects on Path › Expand leaves a plain group. The group saves and reopens live and exports as plain art. With one Objects on Path group selected, the Selection and Objects on Path tools show the Rotate All widget beside its first object: dragging it turns every object about its attachment point by the angle the pointer turns, adding it to the group's Rotate in one undo step, in 15° steps with Shift ([Objects on Path](https://helpx.adobe.com/illustrator/using/objects-on-path.html)). Detach is missing. Objects on Path Space widget: with one Objects on Path group of two or more objects selected, the Selection and Objects on Path tools show a square handle beyond its last object; dragging it along the path, round its bends, sets the distance between the objects' attachment points in one undo step, the first object staying where it is, and with Alt (Option on macOS) the objects spread from both ends toward the center; the distance shows next to the pointer during the drag, in the document's units ([Rearrange and edit objects on a path](https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/rearrange-and-edit-objects-on-a-path.html), [Objects on Path](https://helpx.adobe.com/illustrator/using/objects-on-path.html)). The group keeps the distance (`object.objectsOnPath.options {spacing, start}`) through Pivot and Rotate changes, path edits, copies, saving and reopening, and scales it with the group; by default (`"auto"`) the objects are spread evenly as before. Evidence: `objects_on_path::tests::{space_sets_the_distance_between_the_stops, space_on_a_closed_path_wraps_at_the_first_anchor_and_stops_short_of_the_first_object}` in `crates/doc`, `tests::objects_on_path::space_sets_the_distance_between_the_objects_in_one_undo_step` in `crates/engine`, `objects_on_path::tests::dragging_the_space_widget_spaces_the_objects_from_the_first_one` in `crates/tools`. Objects on Path Move All widget: with one Objects on Path group selected whose objects have room to move, the Selection and Objects on Path tools show a diamond below its first object; dragging it along the path, round its bends, moves every object along the path together in one undo step, keeping the distances between them (`object.objectsOnPath.options {start}`). On an open path the widget shows only when the distance from the first object to the last is shorter than the path, so not for objects spread evenly from end to end, and the objects stop at the path's ends; on a closed path they go on round across its first anchor. The first object's distance along the path shows next to the pointer during the drag, in the document's units ([Rearrange and edit objects on a path](https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/rearrange-and-edit-objects-on-a-path.html), [Objects on Path](https://helpx.adobe.com/illustrator/using/objects-on-path.html)). Evidence: `objects_on_path::tests::{dragging_the_move_all_widget_moves_every_object_along_the_path, a_move_all_drag_on_a_closed_path_goes_round_across_the_first_anchor}` in `crates/tools`, `canvas::tests::a_selected_objects_on_path_group_shows_its_move_all_widget_when_it_has_room` in `crates/ui-egui`. + Objects on Path Select/Move widget: with one Objects on Path group selected, the Selection and Objects on Path tools show a small ring on each object's attachment point, highlighted under the pointer. A click on one chooses that object and fills its ring blue while the group stays selected (`object.objectsOnPath.selectMember`); a click on the object's art still selects the group. Dragging a ring along the path moves its object to the object's place nearest the pointer, and the objects between move one place toward where it was, in one undo step (`object.objectsOnPath.moveMember`); hidden and locked objects keep their places; only the order along the path changes, not the Layers panel's order or the Space. On an open path the drag stops at the ends, on a closed path it goes on round across the first anchor, and Escape cancels it ([Rearrange and edit objects on a path](https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/rearrange-and-edit-objects-on-a-path.html), [Objects on Path](https://helpx.adobe.com/illustrator/using/objects-on-path.html)). Evidence: `objects_on_path::tests::moving_an_object_shifts_the_ones_between_it_and_its_new_place` in `crates/doc`, `tests::objects_on_path::move_member_takes_the_target_s_place_and_shifts_the_objects_between` in `crates/engine`, `objects_on_path::tests::{dragging_a_select_move_widget_moves_its_object_to_the_nearest_place, a_select_move_drag_goes_round_a_closed_path_across_its_first_anchor}` in `crates/tools`, `canvas::tests::a_selected_objects_on_path_group_shows_a_select_move_widget_per_object` in `crates/ui-egui`. 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. From 59bc3677fe251ca0594cfa46f78afa815b35452b Mon Sep 17 00:00:00 2001 From: Taha-Mahmoodi <85902429+Taha-Mahmoodi@users.noreply.github.com> Date: Thu, 12 Nov 2026 16:26:48 +0430 Subject: [PATCH 11/26] 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 28dfd66b65b30240b14e8f6257c3094d9d9a6eda Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 22:51:42 -0800 Subject: [PATCH 12/26] Images: a quarter turn by the orientation swaps the resolution, and every TIFF follows the tag Opening or placing an image whose orientation turns it a quarter (EXIF or TIFF Orientation 5 to 8) stored the pixels turned but kept the resolution as stored, so a photo at 300 x 150 ppi came in at the wrong width and height. XResolution counts pixels in the ImageWidth direction of the stored image (TIFF 6.0, https://www.itu.int/itudoc/itu-t/com16/tiff-fx/docs/tiff6.pdf; EXIF uses the same tag, CIPA DC-008-2012, https://www.cipa.jp/std/documents/e/DC-008-2012_E.pdf), so a quarter turn now swaps the axes, in the size Open and Place use and in the stored image, with jxlread::oriented_resolution as JPEG XL does. Three kinds of TIFF did not follow their Orientation tag: - grayscale TIFFs with extra samples and 1-bit CCITT Group 3 TIFFs were not turned; - a CMYK TIFF with its inks together was turned but stored as RGB, losing its inks, and one with a plane per ink did not open ("Planar TIFF with CMYK color type is not supported"). They now turn as the other images do, and a CMYK TIFF keeps its inks, 8 bits each, written again with the turned resolution. --- crates/doc/src/cmyk.rs | 9 +- crates/engine/src/cmd/fileio/load.rs | 52 +++++-- crates/engine/src/cmd/fileio/tests.rs | 207 ++++++++++++++++++++++++++ 3 files changed, 255 insertions(+), 13 deletions(-) diff --git a/crates/doc/src/cmyk.rs b/crates/doc/src/cmyk.rs index e487226d4f..a3a6f7b016 100644 --- a/crates/doc/src/cmyk.rs +++ b/crates/doc/src/cmyk.rs @@ -133,6 +133,13 @@ pub(crate) fn tiff(bytes: &[u8]) -> Option<(tiff::ColorType, tiff::decoder::Deco Some((d.colortype().ok()?, d)) } +/// The Orientation tag of TIFF `bytes`' first image, of any unsigned type; `None` for other images +/// and TIFFs without one. Reads only its header. +pub fn tiff_orientation(bytes: &[u8]) -> Option { + let (_, mut d) = tiff(bytes)?; + d.find_tag_unsigned::(tiff::tags::Tag::Orientation).ok().flatten() +} + /// Does the TIFF `d` reads store one plane per channel (PlanarConfiguration 2)? fn planar(d: &mut tiff::decoder::Decoder>) -> bool { d.find_tag_unsigned::(tiff::tags::Tag::PlanarConfiguration).ok().flatten().is_some_and(|p| p != 1) @@ -299,7 +306,7 @@ fn jpeg_inks(bytes: &[u8]) -> Option { /// The ink amounts of a CMYK TIFF (16-bit samples to 8 bits), its inks stored together or one plane /// per ink. -fn tiff_inks(bytes: &[u8]) -> Option { +pub fn tiff_inks(bytes: &[u8]) -> Option { let (kind, mut d) = tiff(bytes)?; let (w, h) = d.dimensions().ok()?; if !matches!(kind, tiff::ColorType::CMYK(8 | 16)) { diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index fde1ed273f..7df402724a 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -814,6 +814,9 @@ pub(crate) fn size_note(bytes: &[u8]) -> Option { /// A TIFF with more strips or tiles than the decoder can count /// ([`vectorcraft_doc::cmyk::oversized_tiff`]) is refused as too large to draw. JPEG XL images are /// read with [`jxlread`] and stored as PNG, with the ICC profile their colors are left in. +/// A PNG, JPEG, GIF, WebP or TIFF whose orientation ([`exif_orientation`]) turns or flips it is +/// stored as it is shown, with its resolution's axes swapped when it turns a quarter; a CMYK TIFF +/// keeps its inks, 8 bits each ([`turned_cmyk_tiff`]). pub fn raster_image(bytes: &[u8]) -> Result { let ppi = super::ppi::resolution(bytes); if let Some((w, h)) = vectorcraft_doc::cmyk::oversized_tiff(bytes) { @@ -852,7 +855,15 @@ pub fn raster_image(bytes: &[u8]) -> Result { let f = image_format(kind).ok_or_else(|| err(format!("{kind:?} images can't be opened (see document.formats)")))?; // A photo whose EXIF orientation turns or flips it (a camera held upright) is stored as it is // seen, so it places the way it was taken (#4284); the others keep their own bytes. - let turned = matches!(f.id, "png" | "jpg" | "gif" | "webp" | "tiff") && exif_orientation(bytes).is_some(); + let orientation = matches!(f.id, "png" | "jpg" | "gif" | "webp" | "tiff").then(|| exif_orientation(bytes)).flatten(); + let turned = orientation.is_some(); + // The resolution describes the stored pixels; a quarter turn swaps its axes. + let shown_ppi = jxlread::oriented_resolution(ppi, orientation.map_or(1, |o| u32::from(o.to_exif()))); + if let Some(orientation) = orientation.filter(|_| f.id == "tiff") + && let Some(stored) = turned_cmyk_tiff(bytes, orientation, shown_ppi) + { + return stored; + } let kept = match f.id { _ if turned => None, "png" | "jpg" | "gif" | "webp" => Some(bytes.to_vec()), @@ -868,36 +879,53 @@ pub fn raster_image(bytes: &[u8]) -> Result { vectorcraft_doc::cmyk::extra_samples_tiff_rgba(bytes, |c| cms.cmyk_to_srgb(c, false)) }; let mut img = match (f.id == "tiff").then(extra_samples).flatten() { - Some(img) => img, + Some(img) => image::DynamicImage::ImageRgba8(img), None => match (f.id == "tiff").then(|| vectorcraft_doc::cmyk::group3_tiff_rgba(bytes)).flatten() { - Some(read) => read.map_err(err)?, + Some(read) => image::DynamicImage::ImageRgba8(read.map_err(err)?), None => { use image::ImageDecoder as _; - let mut decoder = reader.into_decoder().map_err(err)?; + let decoder = reader.into_decoder().map_err(err)?; // The allowance counts the decoded samples and the RGBA pixels they become (#1717). let (w, h) = decoder.dimensions(); if decoder.total_bytes() > MAX_RASTER_ALLOC || u64::from(w) * u64::from(h) * 4 > MAX_RASTER_ALLOC { return Err(err(format!("an image of {w} × {h} pixels is too large to draw"))); } - let orientation = decoder.orientation().unwrap_or(image::metadata::Orientation::NoTransforms); - let mut img = image::DynamicImage::from_decoder(decoder).map_err(err)?; - img.apply_orientation(orientation); - img.to_rgba8() + image::DynamicImage::from_decoder(decoder).map_err(err)? } }, }; + img.apply_orientation(orientation.unwrap_or(image::metadata::Orientation::NoTransforms)); + let mut img = img.into_rgba8(); if f.id == "tga" { super::tgaread::fix_alpha(bytes, &mut img); } - as_png(img, None, ppi) + as_png(img, None, shown_ppi) } /// The EXIF orientation of encoded image `bytes` when it turns or flips the pixels; `None` when -/// it leaves them as stored, or the image has none. +/// it leaves them as stored, or the image has none. A TIFF the image decoder can't open (one with +/// extra samples) has its Orientation tag read from its first image +/// ([`vectorcraft_doc::cmyk::tiff_orientation`]). pub(crate) fn exif_orientation(bytes: &[u8]) -> Option { use image::ImageDecoder as _; - let mut decoder = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?.into_decoder().ok()?; - decoder.orientation().ok().filter(|o| *o != image::metadata::Orientation::NoTransforms) + use image::metadata::Orientation; + let decoded = || image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?.into_decoder().ok()?.orientation().ok(); + let tagged = || vectorcraft_doc::cmyk::tiff_orientation(bytes).and_then(|v| Orientation::from_exif(u8::try_from(v).ok()?)); + decoded().or_else(tagged).filter(|o| *o != Orientation::NoTransforms) +} + +/// CMYK TIFF `bytes` turned or flipped by their `orientation`, as [`raster_image`] stores them: +/// their ink amounts ([`vectorcraft_doc::cmyk::tiff_inks`], 8 bits each) turned and written again +/// by [`ImageBlob::cmyk_tiff_at`] with the resolution `ppi` of the turned pixels. `None` for other +/// images and for CMYK TIFFs whose inks it doesn't read. +fn turned_cmyk_tiff(bytes: &[u8], orientation: image::metadata::Orientation, ppi: Option<(f64, f64)>) -> Option> { + let inks = vectorcraft_doc::cmyk::tiff_inks(bytes)?; + // Four 8-bit inks to a pixel, turned as four 8-bit channels are. + let mut px = image::DynamicImage::ImageRgba8(image::RgbaImage::from_raw(inks.width, inks.height, inks.data)?); + px.apply_orientation(orientation); + let inks = vectorcraft_doc::cmyk::Inks::new(px.width(), px.height(), px.into_bytes())?; + let tiff = ImageBlob::cmyk_tiff_at(&inks, ppi)?; + Some(stored(std::sync::Arc::unwrap_or_clone(tiff.bytes), "image/tiff", (inks.width, inks.height), ppi)) } /// `img` stored as a PNG that keeps the file's resolution and carries `icc`, the ICC profile its diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index c90e077eb4..5f508bd6ec 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -907,6 +907,213 @@ fn a_photo_places_the_way_its_exif_orientation_says() { assert!(red(px.get_pixel(4, 3)) && !red(px.get_pixel(4, 12)), "the left half is on top"); } +/// Where the first stored pixel shows once EXIF (or TIFF) `orientation` turns the image, as +/// (right, bottom): orientations 1 and 5 keep it at the top left, 2 and 6 put it at the top right, +/// 3 and 7 at the bottom right, 4 and 8 at the bottom left. Values outside 1 to 8 leave it. +fn first_pixel_at(orientation: u16) -> (bool, bool) { + match orientation { + 2 | 6 => (true, false), + 3 | 7 => (true, true), + 4 | 8 => (false, true), + _ => (false, false), + } +} + +/// A little-endian EXIF block (a TIFF header and directory, as a PNG `eXIf` chunk or a WebP +/// `EXIF` chunk holds it) with an Orientation of `orientation` and an XResolution and YResolution +/// of `x` and `y` pixels per inch. +fn exif_block(orientation: u16, (x, y): (u32, u32)) -> Vec { + let entry = |tag: u16, kind: u16, value: u32| [&tag.to_le_bytes()[..], &kind.to_le_bytes(), &1u32.to_le_bytes(), &value.to_le_bytes()].concat(); + // Orientation, XResolution, YResolution, ResolutionUnit inches; the two rationals follow. + [ + b"II".as_slice(), + &[0x2a, 0, 8, 0, 0, 0], + &4u16.to_le_bytes(), + &entry(0x0112, 3, u32::from(orientation)), + &entry(0x011a, 5, 62), + &entry(0x011b, 5, 70), + &entry(0x0128, 3, 2), + &[0, 0, 0, 0], + &[x.to_le_bytes(), 1u32.to_le_bytes(), y.to_le_bytes(), 1u32.to_le_bytes()].concat(), + ] + .concat() +} + +/// A photo turned a quarter by its EXIF orientation keeps its physical size: the resolution's +/// axes swap with the pixels', in what Place reports and in the stored image. Every orientation +/// puts the first stored pixel in its corner once, and the stored image, read again, is not turned +/// a second time. Values outside 1 to 8 leave the photo as stored. +#[test] +fn a_quarter_turn_swaps_the_resolution_axes() { + // 48 × 32: the top-left 16 × 16 (one whole JPEG MCU) red, the rest white. + let rgb = image::RgbImage::from_fn(48, 32, |x, y| if x < 16 && y < 16 { image::Rgb([220, 0, 0]) } else { image::Rgb([255, 255, 255]) }); + let mut jpg = Vec::new(); + image::DynamicImage::ImageRgb8(rgb).write_to(&mut Cursor::new(&mut jpg), image::ImageFormat::Jpeg).unwrap(); + for orientation in [0u16, 1, 2, 3, 4, 5, 6, 7, 8, 9] { + let exif = [b"Exif\0\0".as_slice(), &exif_block(orientation, (300, 150))].concat(); + // Without a JFIF density the resolution comes from the EXIF block. + let mut turned = jpg[..2].to_vec(); + turned.extend([0xff, 0xe1]); + turned.extend(u16::try_from(exif.len() + 2).unwrap().to_be_bytes()); + turned.extend(&exif); + turned.extend(&jpg[2..]); + let what = format!("orientation {orientation}"); + assert_eq!(super::ppi::resolution(&turned), Some((300.0, 150.0)), "{what}: as stored"); + let quarter = (5..=8).contains(&orientation); + let (size, ppi) = if quarter { ((32, 48), (150.0, 300.0)) } else { ((48, 32), (300.0, 150.0)) }; + let img = raster_image(&turned).unwrap(); + assert_eq!(((img.width, img.height), img.ppi), (size, Some(ppi)), "{what}"); + assert_eq!(super::ppi::resolution(&img.blob.bytes), Some(ppi), "{what}: the stored image"); + let px = image::load_from_memory(&img.blob.bytes).unwrap().to_rgb8(); + let red = |x: u32, y: u32| px.get_pixel(x, y)[1] < 80; + let (w, h) = size; + let corners = [(false, false), (true, false), (true, true), (false, true)]; + let reds: Vec<_> = + corners.into_iter().filter(|&(right, bottom)| red(if right { w - 4 } else { 4 }, if bottom { h - 4 } else { 4 })).collect(); + assert_eq!(reds, [first_pixel_at(orientation)], "{what}: the first stored pixel's corner"); + let again = raster_image(&img.blob.bytes).unwrap(); + assert_eq!(((again.width, again.height), again.ppi), (size, Some(ppi)), "{what}: read again, it isn't turned again"); + } +} + +/// `tiff` (little-endian) with an Orientation tag (274) of `value`, a SHORT or, when `long`, a +/// LONG, and, when `y_ppi` is given, a YResolution (283, which `tiff` must have) of that many +/// pixels per inch: its first directory copied to the end of the file with the tag added in order, +/// and the header pointing there, so that every other offset in the file still holds. +fn tiff_oriented(tiff: &[u8], value: u16, long: bool, y_ppi: Option) -> Vec { + let at = usize::try_from(u32::from_le_bytes(tiff[4..8].try_into().unwrap())).unwrap(); + let n = usize::from(u16::from_le_bytes([tiff[at], tiff[at + 1]])); + let mut entries: Vec> = tiff[at + 2..at + 2 + 12 * n].chunks(12).map(<[u8]>::to_vec).collect(); + let i = entries.iter().position(|e| u16::from_le_bytes([e[0], e[1]]) > 274).unwrap_or(n); + let (kind, v) = if long { (4u16, u32::from(value).to_le_bytes()) } else { (3, [value.to_le_bytes(), [0, 0]].concat().try_into().unwrap()) }; + entries.insert(i, [&274u16.to_le_bytes()[..], &kind.to_le_bytes(), &1u32.to_le_bytes(), &v].concat()); + let mut out = tiff.to_vec(); + if out.len() % 2 == 1 { + out.push(0); + } + if let Some(y) = y_ppi { + let rational = u32::try_from(out.len()).unwrap(); + out.extend([y.to_le_bytes(), 1u32.to_le_bytes()].concat()); + let e = entries.iter_mut().find(|e| u16::from_le_bytes([e[0], e[1]]) == 283).unwrap(); + e[8..12].copy_from_slice(&rational.to_le_bytes()); + } + let ifd = u32::try_from(out.len()).unwrap(); + out[4..8].copy_from_slice(&ifd.to_le_bytes()); + out.extend(u16::try_from(n + 1).unwrap().to_le_bytes()); + out.extend(entries.concat()); + out.extend([0; 4]); + out +} + +/// TIFFs the image decoder can't read, grayscale with an alpha channel and 1-bit ones compressed +/// with CCITT Group 3, turn and flip as their Orientation tag says, as the others do (#4284), +/// whether the tag is a SHORT or a LONG. A quarter turn swaps the resolution's axes. +#[test] +fn tiffs_read_without_the_image_decoder_follow_their_orientation() { + // 3 × 2 at 150 × 300 ppi, the first pixel black and the rest white, opaque. + let gray = hand_tiff(1, 8, &[2], false, &[vec![0, 255, 255, 255, 255, 255], vec![255; 6]]); + let rows: Vec> = (0..2).map(|y| (0..3).map(|x| x == 0 && y == 0).collect()).collect(); + let g = vectorcraft_testkit::tiff::Group3 { + rows: &rows, + compression: 2, + t4_options: 0, + photometric: 0, + fill_order: 1, + rows_per_strip: 2, + tile: None, + rtc: false, + }; + let fax = vectorcraft_testkit::tiff::group3(&g); + // The Group 3 file declares no resolution. + for (kind, file, y_ppi) in [("Group 3", fax, None), ("gray with alpha", gray, Some(300))] { + assert!(image::load_from_memory(&file).is_err(), "{kind}: the image decoder reads it now"); + for (orientation, long) in (1..=8).flat_map(|o| [(o, false), (o, true)]) { + let what = format!("{kind}, orientation {orientation}, LONG {long}"); + let img = raster_image(&tiff_oriented(&file, orientation, long, y_ppi)).unwrap_or_else(|e| panic!("{what}: {e}")); + let quarter = orientation >= 5; + let size = if quarter { (2, 3) } else { (3, 2) }; + let ppi = y_ppi.map(|y| if quarter { (f64::from(y), 150.0) } else { (150.0, f64::from(y)) }); + assert_eq!(((img.width, img.height), img.ppi), (size, ppi), "{what}"); + assert_eq!(super::ppi::resolution(&img.blob.bytes), ppi, "{what}: the stored image"); + let px = image::load_from_memory(&img.blob.bytes).unwrap().to_luma8(); + let (right, bottom) = first_pixel_at(orientation); + let (x, y) = (if right { size.0 - 1 } else { 0 }, if bottom { size.1 - 1 } else { 0 }); + let black: Vec<_> = px.enumerate_pixels().filter(|p| p.2[0] < 128).map(|p| (p.0, p.1)).collect(); + assert_eq!(black, [(x, y)], "{what}: the first stored pixel's corner"); + } + } +} + +/// A CMYK TIFF that its Orientation tag turns or flips keeps its inks, turned, whether it stores +/// them together or one plane per ink, at 8 or 16 bits; a quarter turn swaps the resolution's axes. +/// Read again, the stored image is not turned a second time. +#[test] +fn cmyk_tiffs_turned_by_their_orientation_keep_their_inks() { + // 3 × 2 at 150 × 300 ppi: the first pixel full cyan, the others 30% black. + let first: [u8; 4] = [255, 0, 0, 0]; + let other: [u8; 4] = [0, 0, 0, 77]; + let channel = |i: usize| -> Vec { (0..6).map(|p| u16::from(if p == 0 { first[i] } else { other[i] })).collect() }; + for (bits, planar) in [(8, false), (8, true), (16, false), (16, true)] { + let channels: Vec> = (0..4).map(|i| channel(i).iter().map(|v| if bits == 16 { v * 257 } else { *v }).collect()).collect(); + let file = hand_tiff(5, bits, &[], planar, &channels); + for orientation in 1..=8 { + let what = format!("{bits} bits, planar {planar}, orientation {orientation}"); + let img = raster_image(&tiff_oriented(&file, orientation, false, Some(300))).unwrap_or_else(|e| panic!("{what}: {e}")); + let quarter = orientation >= 5; + let size = if quarter { (2, 3) } else { (3, 2) }; + let ppi = if quarter { (300.0, 150.0) } else { (150.0, 300.0) }; + assert_eq!(((img.width, img.height), img.ppi, img.blob.mime.as_str()), (size, Some(ppi), "image/tiff"), "{what}"); + assert_eq!(super::ppi::resolution(&img.blob.bytes), Some(ppi), "{what}: the stored image"); + let inks = img.blob.cmyk().unwrap_or_else(|| panic!("{what}: the inks are lost")); + assert_eq!((inks.width, inks.height), size, "{what}: the inks' size"); + let (right, bottom) = first_pixel_at(orientation); + let at = (if right { size.0 - 1 } else { 0 }, if bottom { size.1 - 1 } else { 0 }); + for (i, px) in inks.data.chunks(4).enumerate() { + let (x, y) = (u32::try_from(i).unwrap() % size.0, u32::try_from(i).unwrap() / size.0); + assert_eq!(px, if (x, y) == at { first } else { other }, "{what}: the inks of pixel ({x}, {y})"); + } + let again = raster_image(&img.blob.bytes).unwrap(); + assert_eq!(((again.width, again.height), again.blob.cmyk()), (size, Some(inks)), "{what}: read again, it isn't turned again"); + } + } +} + +/// A PNG (its `eXIf` chunk and `pHYs` resolution), a WebP (its `EXIF` chunk) and an RGB TIFF read +/// by the image decoder, turned a quarter by their orientation, swap their resolution's axes; a +/// half turn keeps them. +#[test] +fn png_webp_and_rgb_tiff_turned_a_quarter_swap_their_resolution() { + use image::ImageEncoder as _; + let rgb = image::RgbImage::from_fn(3, 2, |x, y| image::Rgb(if x == 0 && y == 0 { [0, 0, 0] } else { [255, 255, 255] })); + for orientation in [3u16, 6, 8] { + let mut png = Vec::new(); + let mut enc = image::codecs::png::PngEncoder::new(&mut png); + enc.set_exif_metadata(exif_block(orientation, (150, 300))).unwrap(); + enc.write_image(rgb.as_raw(), 3, 2, image::ExtendedColorType::Rgb8).unwrap(); + let png = super::ppi::with_png_resolution(&png, (150.0, 300.0)); + let mut webp = Vec::new(); + let mut enc = image::codecs::webp::WebPEncoder::new_lossless(&mut webp); + enc.set_exif_metadata(exif_block(orientation, (150, 300))).unwrap(); + enc.write_image(rgb.as_raw(), 3, 2, image::ExtendedColorType::Rgb8).unwrap(); + let channel = |c: usize| -> Vec { rgb.pixels().map(|p| u16::from(p[c])).collect() }; + let tiff = tiff_oriented(&hand_tiff(2, 8, &[], false, &[channel(0), channel(1), channel(2)]), orientation, false, Some(300)); + for (kind, file) in [("PNG", png), ("WebP", webp), ("TIFF", tiff)] { + let what = format!("{kind}, orientation {orientation}"); + assert_eq!(super::ppi::resolution(&file), Some((150.0, 300.0)), "{what}: as stored"); + let quarter = orientation >= 5; + let (size, ppi) = if quarter { ((2, 3), (300.0, 150.0)) } else { ((3, 2), (150.0, 300.0)) }; + let img = raster_image(&file).unwrap_or_else(|e| panic!("{what}: {e}")); + assert_eq!(((img.width, img.height), img.ppi), (size, Some(ppi)), "{what}"); + assert_eq!(super::ppi::resolution(&img.blob.bytes), Some(ppi), "{what}: the stored image"); + let px = image::load_from_memory(&img.blob.bytes).unwrap().to_luma8(); + let (right, bottom) = first_pixel_at(orientation); + let at = (if right { size.0 - 1 } else { 0 }, if bottom { size.1 - 1 } else { 0 }); + let black: Vec<_> = px.enumerate_pixels().filter(|p| p.2[0] < 128).map(|p| (p.0, p.1)).collect(); + assert_eq!(black, [at], "{what}: the first stored pixel's corner"); + } + } +} + /// With `importer` named, a file up to the importer's limit is read whole and a larger one is an /// error, whatever its content. #[test] From a5d0fe7963b726908daf72d9485cb510fac21cab Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 04:58:20 -0800 Subject: [PATCH 13/26] 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 14/26] 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 a1cbb852b38861278869df67c24992bd7550eb0a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 02:05:08 -0800 Subject: [PATCH 15/26] Objects on Path: a Select/Move drag takes the place nearest the pointer on a path that turns back The drag chose its place from the point of the path nearest the pointer, but counted each point as farther by half its distance along the path from the last one. On an open path that turns back on itself, the object then stayed at its place while the pointer was over another object's ring 10 pt away. The drag now uses the point nearest the pointer. Where the path passes as near the pointer more than once, as where it runs back over itself, the drag follows the part nearest along the path to where it was. --- crates/tools/src/objects_on_path.rs | 53 +++++++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 3 deletions(-) diff --git a/crates/tools/src/objects_on_path.rs b/crates/tools/src/objects_on_path.rs index a60c9fb15a..8f19259126 100644 --- a/crates/tools/src/objects_on_path.rs +++ b/crates/tools/src/objects_on_path.rs @@ -654,7 +654,9 @@ pub fn member_hover(cx: &ToolContext, p: Point) -> Option { /// pointer, and the objects between it and its place move one place toward where it was, except /// hidden and locked ones (`object.objectsOnPath.moveMember`). On an open path a pointer beyond /// an end takes the place at that end; on a closed path the pointer goes on round across the first -/// anchor. Where two places are as near, the one the drag is at stays. A click without a drag, and +/// anchor. Where the path passes as near the pointer more than once, as where it runs back over +/// itself, the drag follows the part nearest along the path to where it was. Where two places are +/// as near, the one the drag is at stays. A click without a drag, and /// the release of a drag, choose the object (`object.objectsOnPath.selectMember`). #[derive(Clone, Debug)] pub struct MemberDrag { @@ -671,7 +673,7 @@ pub struct MemberDrag { closed: bool, start: Point, began: bool, - /// The distance along the path to the point nearest the pointer at the last drag (points). + /// The distance along the path the drag was last at (points). last: f64, /// The place the drag puts the object at. target: usize, @@ -732,7 +734,14 @@ impl MemberDrag { out.push(Action::Begin("Move Object on Path".into())); } let w = &self.widget; - let Some((_, t)) = group_spine(cx.doc, w.group).and_then(|s| s.nearest(p, Some(self.last))) else { return out }; + let Some(spine) = group_spine(cx.doc, w.group) else { return out }; + let Some((near, t)) = spine.nearest(p, None) else { return out }; + // Where the path passes as near the pointer more than once, the point nearest along the path + // to where the drag was counts. + let t = match spine.nearest(p, Some(self.last)) { + Some((d, s)) if d - spine.apart(s, self.last) / 2.0 - near <= 1e-9 * (1.0 + near) => s, + _ => t, + }; if !t.is_finite() { return out; } @@ -1872,6 +1881,44 @@ mod tests { assert_eq!(previewed_target(&a), Some(ids[0].0), "{a:?}"); } + /// On an open path that turns back on itself, a drag takes the place nearest the pointer even + /// when that place is far along the path from where the drag is. + #[test] + fn a_select_move_drag_takes_the_place_under_the_pointer_on_a_path_that_turns_back() { + // The path runs 210 pt from (0, 0) right, down and back left to (0, 10): the four objects + // sit at 0, 70, 140 and 210 pt along it, the last one 10 pt below the first. + let corners = [Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 10.0), Point::new(0.0, 10.0)]; + let path = vectorcraft_geom::PathData::new(vec![vectorcraft_geom::SubPath::polyline(&corners, false)]); + let (d, _, sel, ids) = members_doc(path, 4, None); + let paint = paint(); + let c = cx(&d, &sel, &paint); + let mut t = ObjectsOnPathTool::default(); + t.pointer(&c, &ev(PointerKind::Down, 0.0, 0.0)); + let a = t.pointer(&c, &ev(PointerKind::Drag, 0.0, 9.0)); + assert_eq!(previewed_target(&a), Some(ids[3].0), "{a:?}"); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 0.0, 9.0)), vec![Action::Commit, choose(ids[0])]); + } + + /// Where an open path runs back over itself, a drag along the part that runs back stays on that + /// part. + #[test] + fn a_select_move_drag_follows_the_part_it_is_on_where_a_path_runs_back_over_itself() { + // The path runs 540 pt from (0, 0) right to (300, 0) and back left to (60, 0): the four + // objects sit at 0, 180, 360 and 540 pt along it, at x 0, 180, 240 and 60. + let corners = [Point::new(0.0, 0.0), Point::new(300.0, 0.0), Point::new(60.0, 0.0)]; + let path = vectorcraft_geom::PathData::new(vec![vectorcraft_geom::SubPath::polyline(&corners, false)]); + let (d, _, sel, ids) = members_doc(path, 4, None); + let paint = paint(); + let c = cx(&d, &sel, &paint); + let mut t = ObjectsOnPathTool::default(); + t.pointer(&c, &ev(PointerKind::Down, 240.0, 0.0)); + let a = t.pointer(&c, &ev(PointerKind::Drag, 220.0, 0.0)); + assert!(previewed_target(&a).is_none(), "nearer its own place: {a:?}"); + let a = t.pointer(&c, &ev(PointerKind::Drag, 80.0, 0.0)); + assert_eq!(previewed_target(&a), Some(ids[3].0), "{a:?}"); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 80.0, 0.0)), vec![Action::Commit, choose(ids[2])]); + } + /// A hidden or locked object has no widget, and a drag does not take its place: the first /// object, dragged onto the locked last one's place, stays at the nearest place it can take. #[test] From 438a39fdf4577eb2af46675532851442fe8036e5 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 05:09:18 -0800 Subject: [PATCH 16/26] Render: blend steps' drop shadows draw under an opacity mask with Clip off An object with an opacity mask draws offscreen over the bounds the renderer gives it. For a live object (a blend, envelope or repeat) those bounds took in effects in symbol art and brush art inside it, but not a path's own effects, so with Clip off the drop shadows of a blend's steps were cut off at the steps' bounds and did not draw where the mask art leaves them uncovered. The bounds now also take in how far each path's own effects paint past it. --- crates/render/src/fx/bounds.rs | 20 ++++++++++++++--- crates/render/src/tests.rs | 39 ++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/crates/render/src/fx/bounds.rs b/crates/render/src/fx/bounds.rs index 9d5e9c0d1a..4b5055e67f 100644 --- a/crates/render/src/fx/bounds.rs +++ b/crates/render/src/fx/bounds.rs @@ -505,17 +505,31 @@ fn glyphs_box(t: &vectorcraft_doc::TextObject, outset: f64) -> Option { /// How far the content of live object `n` (a blend, envelope or repeat) can paint past its visual /// bounds: for each object in it, how far the effects in a symbol instance's art paint past that -/// art ([`art_effects_reach`]) and how far brush art can paint ([`crate::brush_fx::pad`]), on the -/// farthest side. 0 for any other kind of object. +/// art ([`art_effects_reach`]), how far brush art can paint ([`crate::brush_fx::pad`]) and how far +/// a path's own effects paint past it ([`path_effects_reach`]), on the farthest side. 0 for any +/// other kind of object. fn live_content_reach(symbols: &SymbolBoxes, n: &Node) -> f64 { if !vectorcraft_doc::live::is_live(n) { return 0.0; } let mut reach = 0.0f64; - n.walk(&mut |c| reach = reach.max(art_effects_reach(symbols, c) + crate::brush_fx::pad(symbols, c))); + n.walk(&mut |c| reach = reach.max(art_effects_reach(symbols, c).max(path_effects_reach(symbols, c)) + crate::brush_fx::pad(symbols, c))); reach } +/// How far the effects of path or compound path `n` (a drop shadow, a glow, a geometry effect) +/// paint past its visual bounds, on its farthest side ([`visual_bounds`]): 0 when it has no +/// visible effects, and for any other kind of object. +fn path_effects_reach(symbols: &SymbolBoxes, n: &Node) -> f64 { + if !has_fx(n) { + return 0.0; + } + match (visual_bounds(symbols, n), n.visual_bounds_with(symbols)) { + (Some(c), Some(v)) => [v.x0 - c.x0, v.y0 - c.y0, c.x1 - v.x1, c.y1 - v.y1].into_iter().fold(0.0, f64::max), + _ => 0.0, + } +} + /// How far the effects in the art of symbol instance `n` (and in the art of the symbols the /// instances in it show) paint past that art's visual bounds, on its farthest side, measured on the /// art as the instance places it before its effects run: 0 when the art has no visible effects, and diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 83f6131071..9cf4ac9ca0 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -1260,6 +1260,45 @@ fn undecodable_image_in_nested_opacity_mask_leaves_out_outer_object() { assert_eq!(img.pixel(70, 70), [255, 255, 255, 255], "the incomplete inner mask invalidates the outer mask"); } +/// The drop shadows of a blend's steps draw under an opacity mask with Clip off, where the mask +/// art leaves them uncovered, as they draw without the mask; with Clip on they are hidden. +#[test] +fn blend_step_shadows_draw_under_an_unclipped_opacity_mask() { + use vectorcraft_doc::live::{BlendSpacing, BlendSpec}; + use vectorcraft_doc::{NodeKind, OpacityMask}; + + let shadow = vectorcraft_doc::Effect { id: "stylize.dropShadow".into(), params: serde_json::json!({"x": 15, "y": 15, "blur": 0}), visible: true }; + let blend = |d: &mut Document, mask: Option| { + let end = |d: &mut Document, x: f64| { + let mut n = rect_node(d, Rect::new(x, 10.0, x + 10.0, 20.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); + n.appearance.effects.push(shadow.clone()); + Arc::new(n) + }; + let ends = vec![end(d, 10.0), end(d, 70.0)]; + let spec = BlendSpec { spacing: BlendSpacing::Steps(2), ..Default::default() }; + let mut n = Node::new(d.alloc_id(), NodeKind::Blend { children: ends, spec: Box::new(spec) }); + if let Some(clip) = mask { + let white = rect_node(d, Rect::new(0.0, 0.0, 5.0, 5.0), Paint::solid(Color::WHITE), Paint::None, 0.0); + n.extras_mut().mask = Some(Box::new(OpacityMask::new(white, clip))); + } + n + }; + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let draw = |mask: Option| { + let mut d = Document::new(120.0, 60.0); + let n = blend(&mut d, mask); + d.insert(Some(d.layers[0].id), 0, n).unwrap(); + Renderer::new().render(&d, 120, 60, Affine::IDENTITY, &opts) + }; + let (plain, unclipped, clipped) = (draw(None), draw(Some(false)), draw(Some(true))); + for (x, y) in [(30, 30), (90, 32)] { + assert!(plain.pixel(x, y)[0] < 128, "the shadow draws at ({x}, {y}) without a mask: {:?}", plain.pixel(x, y)); + assert_eq!(unclipped.pixel(x, y), plain.pixel(x, y), "the shadow draws at ({x}, {y}) under the unclipped mask"); + assert_eq!(clipped.pixel(x, y), [255, 255, 255, 255], "the clipped mask hides the shadow at ({x}, {y})"); + } + assert_eq!(unclipped.pixel(15, 15), plain.pixel(15, 15), "the first end draws under the unclipped mask"); +} + /// #3580: each opacity mask is rendered over its object's pixels, not over the whole frame. 300 /// masked 4 × 4 pt squares on a 1000 × 1000 page draw offscreen in proportion to their bounds, and /// still draw where their masks show them. From 366d09d28546d8250ec6dcd60b7b39cb75cd4972 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 04:58:11 -0800 Subject: [PATCH 17/26] Save As: a save goes ahead when the portal answers with a file type of another shape The save dialog read the portal's current_filter with zvariant's tuple conversion, which panics on a structure with fewer fields than the tuple. On Linux and the BSDs the dialog's thread then ended after the user picked a file, and nothing was saved. The label is now read only from a (sa(us)) structure, the shape the FileChooser portal defines. An answer of another shape keeps the path and names no file type, as an answer without current_filter does. A structure with more fields than (sa(us)) also no longer names a file type. --- apps/vectorcraft/src/portal_save.rs | 52 ++++++++++++++++++++++++----- 1 file changed, 44 insertions(+), 8 deletions(-) diff --git a/apps/vectorcraft/src/portal_save.rs b/apps/vectorcraft/src/portal_save.rs index 0357829aec..930938e03f 100644 --- a/apps/vectorcraft/src/portal_save.rs +++ b/apps/vectorcraft/src/portal_save.rs @@ -102,13 +102,22 @@ fn answer(code: u32, results: &HashMap) -> Result) = v.try_clone().ok()?.try_into().ok()?; - Some(label) - }); + let filter = results.get("current_filter").and_then(|v| filter_label(v)); Ok(Some(Saved { path, filter })) } +/// The label of a file type as the portal sends it, a `(sa(us))` structure; `None` for a value of +/// another shape. +fn filter_label(value: &Value<'_>) -> Option { + match value { + Value::Structure(s) => match s.fields() { + [Value::Str(label), Value::Array(globs)] if globs.element_signature() == "(us)" => Some(label.to_string()), + _ => None, + }, + _ => None, + } +} + /// `file:///home/me/A%20b.svg` → `/home/me/A b.svg` (its bytes); `None` for a file elsewhere /// (`file://host/`) or another scheme. fn file_uri_path(uri: &str) -> Option> { @@ -396,6 +405,36 @@ mod tests { assert_eq!(pick_answer(saved, &request), ["/home/me/Art/poster"]); } + /// A `current_filter` of another shape than `(sa(us))` (a structure with fewer, other or more + /// fields, a string) gives the path alone; `uris` of another shape is an error. + #[test] + fn an_answer_of_another_shape_is_not_read() { + let malformed = [ + Value::from(("SVG".to_string(),)), + Value::from(("SVG".to_string(), vec![(0u32,)])), + Value::from(("SVG".to_string(), vec![("*.svg".to_string(), 0u32)])), + Value::from((1u32, vec![(0u32, "*.svg".to_string())])), + Value::from(("SVG".to_string(), vec![(0u32, "*.svg".to_string())], 1u32)), + Value::from("SVG"), + ]; + for filter in malformed { + let mut r = results("file:///tmp/card", None); + r.insert("current_filter".to_string(), OwnedValue::try_from(filter).unwrap()); + assert_eq!(answer(0, &r), Ok(Some(Saved { path: "/tmp/card".into(), filter: None }))); + } + let malformed = [ + Value::from(vec![1u32]), + Value::from(vec![("file:///tmp/card".to_string(),)]), + Value::from(Vec::::new()), + Value::from("file:///tmp/card"), + ]; + for uris in malformed { + let mut r = HashMap::new(); + r.insert("uris".to_string(), OwnedValue::try_from(uris).unwrap()); + assert!(answer(0, &r).is_err()); + } + } + /// Cancel (1) and a dialog closed another way (2) answer no file; an answer without a local file /// is an error, and rfd shows the dialog instead. #[test] @@ -486,10 +525,7 @@ mod tests { } let Ok((_parent, _title, options)) = msg.body().deserialize::<(String, String, HashMap)>() else { break }; let text = |key: &str| options.get(key).and_then(|v| String::try_from(v.try_clone().ok()?).ok()); - let filter = options.get("current_filter").and_then(|v| { - let (label, _): (String, Vec<(u32, String)>) = v.try_clone().ok()?.try_into().ok()?; - Some(label) - }); + let filter = options.get("current_filter").and_then(|v| filter_label(v)); let calls = log.lock().map(|mut l| { l.push((text("current_name"), filter)); l.len() From 1bf09bf72e680ae7b0f3a278a37c8917dbb022b8 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 05:24:22 -0800 Subject: [PATCH 18/26] 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 19/26] 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 0b22997201519256aabf5de71c9c33aaf98a9a38 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 05:33:38 -0800 Subject: [PATCH 20/26] Docs: the Dynamic Text entries in docs/mcp.md name point type The Dynamic Text: Block entry says the Contextual Task Bar offers the item for point type too, and that turning it on makes point type area type with Block on. The Dynamic Text entry no longer says point type in the selection is left as it is. --- docs/mcp.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/mcp.md b/docs/mcp.md index 4a5170de26..4449f69897 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -7090,8 +7090,8 @@ chosen artboards draw all of them. A PDF leaves out the layers that don't print ### Dynamic Text: Block `type.dynamicText {ids?|id?, on?: bool, wordGap?, lineGap?}` (Type › Dynamic Text, and the Contextual Task Bar for -area type outside a thread) turns Block on or off for the -selected area type, as it turns Dynamic Text on or off for type on a path ([Dynamic Text](#dynamic-text)). Area Type +area type outside a thread and for point type) turns Block on or off for the +selected area type, and turning it on makes point type area type with Block on (below), as it turns Dynamic Text on or off for type on a path ([Dynamic Text](#dynamic-text)). Area Type Options › Block is the same setting, and the Properties panel's Paragraph section has a Dynamic Text checkbox, which shows Word Gap and Line Gap while it is on. The [Dynamic Text overview](https://helpx.adobe.com/photoshop/desktop/text-typography/work-with-dynamic-text/dynamic-text-overview.html) describes Block as text that scales to fill a rectangular box, with settings for the spacing between words and between @@ -7970,8 +7970,8 @@ and tate-chu-yoko offsets by one factor, so runs keep their proportions; Type on scaled. The first character's size, in document points, moves in steps of 0.1 pt within 0.1 to 1,296 pt; text that still runs past the end bracket at 0.1 pt overflows. Reshaping or scaling the path fits the text again. The stored sizes do not change: turning Dynamic Text off writes the sizes the text was drawn at into its characters, so it keeps -its look, in one undo step. Point type in the selection is left as it is, and area type in it takes Block -([Dynamic Text: Block](#dynamic-text-block)). Type on a path in a +its look, in one undo step. Area type in the selection takes Block, and turning it on makes point type in the +selection area type with Block on ([Dynamic Text: Block](#dynamic-text-block)). Type on a path in a thread is refused with an error that names it, and the menu item and the task bar item are not offered for it; `text.thread.create` and `text.thread.add` turn Dynamic Text off on the frames they thread, at their drawn sizes, and list them in `dynamicTextOff`; `addon.type.releasePath` turns it off at the drawn sizes on the point type it makes. When every object already has the setting asked for, nothing changes and no undo step From 909f0ff301802754737138f3442d8cd2502e7974 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 05:43:42 -0800 Subject: [PATCH 21/26] 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. From 8187f7ae00cb2731e8d9ced6918b3a8c04f3b0bc Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 05:45:26 -0800 Subject: [PATCH 22/26] Images: a CMYK JPEG turned by its EXIF orientation keeps its inks A four-component JPEG whose EXIF orientation turns or flips it went through the image decoder, which reads CMYK JPEGs as RGB, and was stored as an RGB PNG without its inks. It is now stored the way a turned CMYK TIFF is: its inks, 8 bits each, turned and written as a CMYK TIFF with the turned resolution. - vectorcraft_doc::cmyk::turned_cmyk turns the inks of a CMYK JPEG or TIFF, next to stored_cmyk_tiff; tiff_inks is private again. - raster_image swaps the resolution axes inline, as its HEIF branch does. - The links comment and the docs/target-app-parity.md line on EXIF orientation now describe the turned CMYK TIFF. --- crates/doc/src/cmyk.rs | 25 ++++++++++++---- crates/engine/src/cmd/fileio/load.rs | 27 +++++------------- crates/engine/src/cmd/fileio/tests.rs | 41 +++++++++++++++++++++++++++ crates/engine/src/cmd/links.rs | 2 +- docs/target-app-parity.md | 2 +- 5 files changed, 70 insertions(+), 27 deletions(-) diff --git a/crates/doc/src/cmyk.rs b/crates/doc/src/cmyk.rs index a3a6f7b016..f94f0ec4a8 100644 --- a/crates/doc/src/cmyk.rs +++ b/crates/doc/src/cmyk.rs @@ -306,7 +306,7 @@ fn jpeg_inks(bytes: &[u8]) -> Option { /// The ink amounts of a CMYK TIFF (16-bit samples to 8 bits), its inks stored together or one plane /// per ink. -pub fn tiff_inks(bytes: &[u8]) -> Option { +fn tiff_inks(bytes: &[u8]) -> Option { let (kind, mut d) = tiff(bytes)?; let (w, h) = d.dimensions().ok()?; if !matches!(kind, tiff::ColorType::CMYK(8 | 16)) { @@ -316,10 +316,10 @@ pub fn tiff_inks(bytes: &[u8]) -> Option { Inks::new(w, h, read_samples(&mut d, n, 4)?) } -/// CMYK TIFF `bytes` as Open and Place store them: as they are, or, when they store one plane per -/// ink, which the image decoder doesn't read, their ink amounts written again by -/// [`ImageBlob::cmyk_tiff_at`] with the resolution `ppi`. `None` for other images and for CMYK -/// TIFFs whose inks [`ImageBlob::cmyk`] doesn't read. +/// CMYK TIFF `bytes` that no orientation turns ([`turned_cmyk`] stores the others) as Open and +/// Place store them: as they are, or, when they store one plane per ink, which the image decoder +/// doesn't read, their ink amounts written again by [`ImageBlob::cmyk_tiff_at`] with the resolution +/// `ppi`. `None` for other images and for CMYK TIFFs whose inks [`ImageBlob::cmyk`] doesn't read. pub fn stored_cmyk_tiff(bytes: &[u8], ppi: Option<(f64, f64)>) -> Option> { let inks = tiff_inks(bytes)?; let (_, mut d) = tiff(bytes)?; @@ -329,6 +329,21 @@ pub fn stored_cmyk_tiff(bytes: &[u8], ppi: Option<(f64, f64)>) -> Option ImageBlob::cmyk_tiff_at(&inks, ppi).map(|b| Arc::unwrap_or_clone(b.bytes)) } +/// CMYK JPEG or TIFF `bytes` that `orientation` turns or flips, as Open and Place store them: their +/// ink amounts ([`ImageBlob::cmyk`], 8 bits each) turned and written again by +/// [`ImageBlob::cmyk_tiff_at`] with the resolution `ppi` of the turned pixels, with the turned +/// width and height. `None` for other images and for CMYK images whose inks [`ImageBlob::cmyk`] +/// doesn't read. +pub fn turned_cmyk(bytes: &[u8], orientation: image::metadata::Orientation, ppi: Option<(f64, f64)>) -> Option<(Vec, (u32, u32))> { + let inks = if bytes.starts_with(&[0xFF, 0xD8]) { jpeg_inks(bytes) } else { tiff_inks(bytes) }?; + // Four 8-bit inks to a pixel, turned as four 8-bit channels are. + let mut px = image::DynamicImage::ImageRgba8(image::RgbaImage::from_raw(inks.width, inks.height, inks.data)?); + px.apply_orientation(orientation); + let inks = Inks::new(px.width(), px.height(), px.into_bytes())?; + let tiff = ImageBlob::cmyk_tiff_at(&inks, ppi)?; + Some((Arc::unwrap_or_clone(tiff.bytes), (inks.width, inks.height))) +} + /// A TIFF whose pixels have samples after their colour, which the image decoder doesn't read at /// all, as RGBA pixels: CMYK with an alpha channel (5 samples), each colour converted by `rgb` (ink /// amounts 0..1 → sRGB 0..1; the inks can't be kept with the transparency, so they show as RGB), diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 7df402724a..7cb5d370bb 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -815,8 +815,8 @@ pub(crate) fn size_note(bytes: &[u8]) -> Option { /// ([`vectorcraft_doc::cmyk::oversized_tiff`]) is refused as too large to draw. JPEG XL images are /// read with [`jxlread`] and stored as PNG, with the ICC profile their colors are left in. /// A PNG, JPEG, GIF, WebP or TIFF whose orientation ([`exif_orientation`]) turns or flips it is -/// stored as it is shown, with its resolution's axes swapped when it turns a quarter; a CMYK TIFF -/// keeps its inks, 8 bits each ([`turned_cmyk_tiff`]). +/// stored as it is shown, with its resolution's axes swapped when it turns a quarter; a CMYK JPEG +/// or TIFF keeps its inks, 8 bits each, as a CMYK TIFF ([`vectorcraft_doc::cmyk::turned_cmyk`]). pub fn raster_image(bytes: &[u8]) -> Result { let ppi = super::ppi::resolution(bytes); if let Some((w, h)) = vectorcraft_doc::cmyk::oversized_tiff(bytes) { @@ -858,11 +858,12 @@ pub fn raster_image(bytes: &[u8]) -> Result { let orientation = matches!(f.id, "png" | "jpg" | "gif" | "webp" | "tiff").then(|| exif_orientation(bytes)).flatten(); let turned = orientation.is_some(); // The resolution describes the stored pixels; a quarter turn swaps its axes. - let shown_ppi = jxlread::oriented_resolution(ppi, orientation.map_or(1, |o| u32::from(o.to_exif()))); - if let Some(orientation) = orientation.filter(|_| f.id == "tiff") - && let Some(stored) = turned_cmyk_tiff(bytes, orientation, shown_ppi) + let quarter = orientation.is_some_and(|o| o.to_exif() >= 5); + let shown_ppi = ppi.map(|(x, y)| if quarter { (y, x) } else { (x, y) }); + if let Some(orientation) = orientation.filter(|_| matches!(f.id, "jpg" | "tiff")) + && let Some((tiff, size)) = vectorcraft_doc::cmyk::turned_cmyk(bytes, orientation, shown_ppi) { - return stored; + return stored(tiff, "image/tiff", size, shown_ppi); } let kept = match f.id { _ if turned => None, @@ -914,20 +915,6 @@ pub(crate) fn exif_orientation(bytes: &[u8]) -> Option) -> Option> { - let inks = vectorcraft_doc::cmyk::tiff_inks(bytes)?; - // Four 8-bit inks to a pixel, turned as four 8-bit channels are. - let mut px = image::DynamicImage::ImageRgba8(image::RgbaImage::from_raw(inks.width, inks.height, inks.data)?); - px.apply_orientation(orientation); - let inks = vectorcraft_doc::cmyk::Inks::new(px.width(), px.height(), px.into_bytes())?; - let tiff = ImageBlob::cmyk_tiff_at(&inks, ppi)?; - Some(stored(std::sync::Arc::unwrap_or_clone(tiff.bytes), "image/tiff", (inks.width, inks.height), ppi)) -} - /// `img` stored as a PNG that keeps the file's resolution and carries `icc`, the ICC profile its /// colors are in. pub(super) fn as_png(img: image::RgbaImage, icc: Option>, ppi: Option<(f64, f64)>) -> Result { diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index 5f508bd6ec..5ea57858cc 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -1078,6 +1078,47 @@ fn cmyk_tiffs_turned_by_their_orientation_keep_their_inks() { } } +/// A CMYK JPEG that its EXIF orientation turns or flips keeps its inks, turned, stored as a CMYK +/// TIFF; a quarter turn swaps the resolution's axes. Orientation 1 keeps the JPEG as it is, and +/// the stored image, read again, is not turned a second time. +#[test] +fn cmyk_jpegs_turned_by_their_orientation_keep_their_inks() { + use vectorcraft_render::encode::jpeg::{self, ColorModel, JpegOptions}; + // 48 × 32: the top-left 16 × 16 (one whole JPEG MCU) full cyan, the rest 30% black. + let inks: Vec = + (0..32u32).flat_map(|y| (0..48u32).flat_map(move |x| if x < 16 && y < 16 { [255, 0, 0, 0] } else { [0, 0, 0, 77] })).collect(); + let o = JpegOptions { color_model: ColorModel::Cmyk, embed_icc: false, ..Default::default() }; + let jpg = jpeg::encode(&inks, 48, 32, 100, None, &o).unwrap(); + for orientation in 1..=8u16 { + let exif = [b"Exif\0\0".as_slice(), &exif_block(orientation, (300, 150))].concat(); + let mut file = jpg[..2].to_vec(); + file.extend([0xff, 0xe1]); + file.extend(u16::try_from(exif.len() + 2).unwrap().to_be_bytes()); + file.extend(&exif); + file.extend(&jpg[2..]); + let what = format!("orientation {orientation}"); + let quarter = orientation >= 5; + let (size, ppi) = if quarter { ((32, 48), (150.0, 300.0)) } else { ((48, 32), (300.0, 150.0)) }; + let mime = if orientation == 1 { "image/jpeg" } else { "image/tiff" }; + let img = raster_image(&file).unwrap_or_else(|e| panic!("{what}: {e}")); + assert_eq!(((img.width, img.height), img.ppi, img.blob.mime.as_str()), (size, Some(ppi), mime), "{what}"); + assert_eq!(super::ppi::resolution(&img.blob.bytes), Some(ppi), "{what}: the stored image"); + let inks = img.blob.cmyk().unwrap_or_else(|| panic!("{what}: the inks are lost")); + assert_eq!((inks.width, inks.height), size, "{what}: the inks' size"); + let (w, h) = size; + let at = |x: u32, y: u32| inks.data[usize::try_from((y * w + x) * 4).unwrap()..][..4].to_vec(); + let cyan = |p: &[u8]| p[0] > 200 && p[3] < 40; + let corners = [(false, false), (true, false), (true, true), (false, true)]; + let cyans: Vec<_> = + corners.into_iter().filter(|&(right, bottom)| cyan(&at(if right { w - 4 } else { 4 }, if bottom { h - 4 } else { 4 }))).collect(); + assert_eq!(cyans, [first_pixel_at(orientation)], "{what}: the first stored pixel's corner"); + let black = at(w / 2, h / 2); + assert!(black[0] < 40 && (60..=95).contains(&black[3]), "{what}: the other inks, {black:?}"); + let again = raster_image(&img.blob.bytes).unwrap(); + assert_eq!(((again.width, again.height), again.blob.cmyk()), (size, Some(inks)), "{what}: read again, it isn't turned again"); + } +} + /// A PNG (its `eXIf` chunk and `pHYs` resolution), a WebP (its `EXIF` chunk) and an RGB TIFF read /// by the image decoder, turned a quarter by their orientation, swap their resolution's axes; a /// half turn keeps them. diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index a134eb1c55..41417a4433 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -436,7 +436,7 @@ pub(crate) fn full_linked_images<'a>(d: &'a Document, base: LinkBase) -> (Cow<'a continue; }; let preview = d.images.get(&key).map(|b| b.bytes.clone()); - // A file read as it is keeps its own type; one turned upright (EXIF orientation) is the PNG it became. + // A file read as it is keeps its own type; one turned upright by its orientation is the PNG, or CMYK TIFF, it became. let mime = match fileio::format_for_name(g.link.name()) { Some(f) if img.blob.bytes.as_slice() == bytes.as_slice() => f.mime.to_string(), _ => img.blob.mime.clone(), diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 3cc8df2a9e..49037bd59f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1553,7 +1553,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - On the desktop, File › Place reads and decodes files off the UI thread, one at a time, and places each once it is read, with "Placing" in the status bar and the busy spinner until then: the Place dialog's OK, a file dropped on the canvas, a click or drag of the place cursor, and the OK of the PDF, DXF and text import options. The files and thumbnails the place cursor is loaded with are read the same way, and the Place dialog's lines about its files are read on a thread of their own. Files are placed in the order they were started; a file is not placed while a dialog is open or a drag is under way, and a file whose document was closed meanwhile is not placed. `file.place` and `file.place.queue` over MCP and scripts reply as before. - On the desktop, Paste from a menu or shortcut captures another app's bitmap or reads a copied image file on the UI thread, then decodes the image on the file-reading worker. The status bar shows "Pasting" until the image is pasted. Windows device-independent bitmaps and Linux clipboard images are encoded as PNG on that worker. A Copy or Cut made while the image is read keeps its internal clipboard. `edit.paste*` over MCP and scripts reads the bitmap at once. - The pixels of a PNG, JPEG, GIF or WebP file that Place decodes to check them are kept in the image store the renderers share, so the canvas's first frame with the placed image draws them without decoding the file again on the UI thread: File › Place, a file dropped on the canvas, a click or drag of the place cursor, `file.place`, and Paste of such an image. A CMYK JPEG is still decoded when it is first drawn (`crates/ui-egui/src/placing.rs`, `crates/render/src/tests.rs`). - - A photo whose EXIF orientation turns or flips it (a camera held upright or upside down) is placed and opened upright, as it was taken; the image is stored as it is seen (#4284). + - A photo whose EXIF orientation turns or flips it (a camera held upright or upside down) is placed and opened upright, as it was taken; the image is stored as it is seen (#4284). A quarter turn swaps the resolution's axes, so the image keeps its physical size. TIFFs read without the image decoder (grayscale with extra channels, CCITT Group 3, CMYK with one plane per ink) follow their Orientation tag too, and a turned CMYK JPEG or TIFF keeps its inks, 8 bits each, as a CMYK TIFF. - File › Place and a file dropped on the canvas refuse a PNG, JPEG, GIF or WebP file whose pixels can't be read, such as one cut short by a download or a sync still running, and the status bar shows "Couldn't place : the image's pixels can't be read". Paste and Relink refuse such an image too, and Update Links, or Open when it reads the file again, counts it as missing. A JPEG cut short still places when its pixels can be decoded (`file.place`, `crates/engine/src/tests_place.rs`). - On the desktop, Copy and Cut make the system clipboard's formats (text, SVG, PDF, PNG) from a copy of the clipboard on the thread that writes background saves and exports, and write them when they are ready; while that thread has a save, an export, a print or a recovery copy to finish, they are made and written at once. Until they are written, Paste in VectorCraft takes the internal clipboard, and while a text field or the Type tool has the keyboard the app waits for them. Another Copy or Cut, or a copy of text in VectorCraft, drops them. - On the desktop with Enable Background Export on (Preferences › File Handling), File › Package… collects the linked files and fonts and writes the package from the document as it was at OK on the thread that writes background saves and exports, with "Packaging" in the status bar and the busy spinner until it is written; a save of unsaved changes that Package starts is written first. `file.package` over MCP and scripts replies as before. From 705503da6d6ce0c54d20713f19611728a8f362c2 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 06:02:04 -0800 Subject: [PATCH 23/26] Objects on Path: plug-ins may call Select and Move Object on Path object.objectsOnPath.selectMember and object.objectsOnPath.moveMember change only the document and its selection, as the other Objects on Path commands do, so both join the commands a plug-in may call when its manifest lists them. --- crates/engine/src/cmd/plugin_call/reviewed.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/engine/src/cmd/plugin_call/reviewed.rs b/crates/engine/src/cmd/plugin_call/reviewed.rs index 4c9abdb357..355179c4fc 100644 --- a/crates/engine/src/cmd/plugin_call/reviewed.rs +++ b/crates/engine/src/cmd/plugin_call/reviewed.rs @@ -307,7 +307,9 @@ pub(super) const ALLOWED: &[&str] = &[ "object.nudge", "object.objectsOnPath.attach", "object.objectsOnPath.expand", + "object.objectsOnPath.moveMember", "object.objectsOnPath.options", + "object.objectsOnPath.selectMember", "object.path.addAnchorPoints", "object.path.cleanUp", "object.path.divideObjectsBelow", From 2212739c5425e12430ef5632c3e14559557f0730 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 00:46:02 -0800 Subject: [PATCH 24/26] MCP: plug-in, recovery and script report replies leave out paths outside the read roots A client confined by --automation-read-root saw paths outside its read roots that the person or an earlier request had put in the session: - plugin.list: the plug-in folder last loaded and its failed files; plugin.list and plugin.info: a plug-in's source file; plugin.list, plugin.safeMode and plugin.setEnabled: the file of a plug-in turned off for the next launch. - file.recovery.list and file.recovery.restore: a document's original file; file.recovery.list and file.recovery.save: the recovery folder. - ui.inspect's scriptReport: the files a run wrote and the included file a failed run stopped in. A folder outside the read roots is now null, and a file outside them is named by its file name. plugin.setEnabled {file} takes that name. The restored documents keep their files, and unconfined clients and the app's own UI see the same replies as before. --- crates/engine/src/cmd/plugin.rs | 94 ++++++++++++++++++++--- crates/engine/src/cmd/recovery.rs | 39 ++++++++-- crates/engine/src/cmd/scripts.rs | 44 +++++++++++ crates/engine/src/script/mod.rs | 30 ++++++-- crates/engine/src/tests/plugins.rs | 86 +++++++++++++++++++++ crates/engine/src/tests/recovery.rs | 65 ++++++++++++++++ crates/engine/src/tests/script_console.rs | 28 +++++++ crates/ui-egui/src/control.rs | 2 +- crates/ui-egui/src/tests/automation.rs | 44 +++++++++++ docs/mcp.md | 11 +++ docs/target-app-parity.md | 1 + 11 files changed, 418 insertions(+), 26 deletions(-) diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index 2d16bbc683..dfa44a1436 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -62,13 +62,28 @@ fn err(e: vectorcraft_plugins::Error) -> EngineError { } } +/// Under automation read roots, a reply names a file or folder outside them by its name alone. +fn shown_file(path: &str) -> String { + super::fileio::shown_path(path).into_owned() +} + +/// A plug-in turned off for the next launch, or the one a launch stopped at, as a reply shows it: a +/// file outside the automation read roots is named by its file name. +fn shown_item(item: &safe_mode::Item) -> Value { + let mut v = item.to_json(); + if let Some(file) = item.file.as_deref() { + v["file"] = json!(shown_file(file)); + } + v +} + /// JSON description of an installed plug-in. pub fn describe(p: &Plugin) -> Value { let m = p.manifest(); let mut v = json!({ "id": m.id, "name": m.name, "version": m.version, "kind": m.kind.name(), "author": m.author, "description": m.description, "params": m.params_doc(), "paramsSchema": serde_json::to_value(m).ok().and_then(|v| v.get("params").cloned()), - "size": p.size(), "source": p.source(), + "size": p.size(), "source": p.source().map(shown_file), "permissions": permissions::names(&m.permissions()), "hash": p.hash(), "api": m.api, }); match m.kind { @@ -932,9 +947,46 @@ fn describe_installed(p: &Plugin) -> Value { v } +/// The folder load `report` as a reply shows it: under automation read roots, a folder outside them +/// is `null` and a failed file outside them is named by its file name, in the errors too. +fn shown_report(mut report: Value) -> Value { + let mut hidden: Vec<(String, String)> = vec![]; + if let Some(failed) = report.get_mut("failed").and_then(Value::as_array_mut) { + for row in failed { + let Some(file) = row.get("file").and_then(Value::as_str).map(str::to_string) else { continue }; + let shown = shown_file(&file); + if shown != file { + row["file"] = json!(shown); + hidden.push((file, shown)); + } + } + } + if let Some(folder) = report.get("folder").and_then(Value::as_str).map(str::to_string) { + let shown = shown_file(&folder); + if shown != folder { + report["folder"] = Value::Null; + hidden.push((folder, shown)); + } + } + // A failed file's path holds its folder's: the longer paths are put back first. + hidden.sort_by_key(|(path, _)| std::cmp::Reverse(path.len())); + let unnamed = |text: &str| hidden.iter().fold(text.to_string(), |t, (path, name)| t.replace(path.as_str(), name)); + if let Some(e) = report.get("error").and_then(Value::as_str).map(unnamed) { + report["error"] = json!(e); + } + if let Some(failed) = report.get_mut("failed").and_then(Value::as_array_mut) { + for row in failed { + if let Some(e) = row.get("error").and_then(Value::as_str).map(unnamed) { + row["error"] = json!(e); + } + } + } + report +} + fn list(_: &mut Session, _: &Value) -> Result { - let folder = FOLDER.lock().unwrap_or_else(|e| e.into_inner()).as_ref().map(|(_, _, r)| r.clone()); - let disabled: Vec = safe_mode::disabled().iter().map(safe_mode::Item::to_json).collect(); + let folder = FOLDER.lock().unwrap_or_else(|e| e.into_inner()).as_ref().map(|(_, _, r)| shown_report(r.clone())); + let disabled: Vec = safe_mode::disabled().iter().map(shown_item).collect(); Ok( json!({"plugins": registry::list().iter().map(|p| describe_installed(p)).collect::>(), "folder": folder, "safeMode": safe_mode::active(), "disabled": disabled}), ) @@ -946,11 +998,33 @@ fn safe_mode_state(_: &mut Session, _: &Value) -> Result { Ok(json!({ "active": safe_mode::active(), "source": safe_mode::source().map(safe_mode::Source::name), - "disabled": safe_mode::disabled().iter().map(safe_mode::Item::to_json).collect::>(), - "pendingItem": safe_mode::previous().map(|i| i.to_json()), + "disabled": safe_mode::disabled().iter().map(shown_item).collect::>(), + "pendingItem": safe_mode::previous().map(|i| shown_item(&i)), })) } +/// The file of a plug-in `plugin.safeMode` lists as `file`, or `plugin.list` as `source`: one they name +/// by its file name (outside the automation read roots) is found by that name, when no other listed +/// file has it. +fn listed_file(file: String) -> String { + let mut listed: Vec = safe_mode::disabled() + .into_iter() + .chain(safe_mode::previous()) + .filter_map(|i| i.file) + .chain(registry::list().iter().filter_map(|p| p.source().map(str::to_string))) + .collect(); + listed.sort(); + listed.dedup(); + if listed.contains(&file) { + return file; + } + let mut named = listed.into_iter().filter(|f| shown_file(f) == file); + match (named.next(), named.next()) { + (Some(f), None) => f, + _ => file, + } +} + /// `plugin.setEnabled`: turns a plug-in on or off for the next launch, by `id` or by `file` (a /// plug-in that stopped a launch while loading may be known by its file alone). It neither loads /// nor unloads anything now, and uninstalls nothing. @@ -972,7 +1046,7 @@ fn set_enabled(s: &mut Session, p: &Value) -> Result { let stored = if enabled { s.stored_plugin_file(&id).map(safe_mode::Item::file) } else { None }; (registry::get(&id).map(|p| safe_mode::Item::of(&p)).unwrap_or(safe_mode::Item { id: Some(id), ..Default::default() }), stored) } - (None, Some(file)) => (safe_mode::Item::file(file), None), + (None, Some(file)) => (safe_mode::Item::file(listed_file(file)), None), (None, None) => return Err(bad(C, "give `id` or `file` (see plugin.safeMode)")), }; let items: Vec<&safe_mode::Item> = std::iter::once(&item).chain(stored.as_ref()).collect(); @@ -986,7 +1060,7 @@ fn set_enabled(s: &mut Session, p: &Value) -> Result { // Without a plug-in folder (a session confined to automation roots), or when the list there // can't take the change, the change stays in this process and goes with the next change that saves. let saved = !changed || s.store_disabled_plugins(); - Ok(json!({"changed": changed, "saved": saved, "disabled": safe_mode::disabled().iter().map(safe_mode::Item::to_json).collect::>()})) + Ok(json!({"changed": changed, "saved": saved, "disabled": safe_mode::disabled().iter().map(shown_item).collect::>()})) } fn info(_: &mut Session, p: &Value) -> Result { @@ -1055,7 +1129,7 @@ pub fn specs() -> Vec { "List Plug-ins", [], None, - "{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\"|\"importer\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.), format? and extensions? (an exporter's or importer's format id, plugin., for document.export or document.open's importer, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), inputs?: [\"type\"|\"image\"|\"symbolInstance\"] (the objects besides paths a filter reads, see plugin.run), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies; progress: it may report its progress, see Cancel and write log lines; settings: it may keep settings of its own; cache: it may keep a cache of its own; commands: a filter may run the commands its commandAllowlist names, see docs/plugins.md), commandAllowlist?: [command id] (the commands a filter with the commands permission may run while it runs, with vc_call), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2), paintOutput?: true (a live effect whose pieces have paints of their own, see docs/plugins.md), cacheBytes (the bytes its cache holds; see plugin.clearCache), commands?: [{id, command (its command id, plugin.., which runs it), label, params (doc), paramsSchema}] (the menu commands a filter or live effect adds)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null, safeMode: bool (no plug-in loads or runs, see plugin.safeMode), disabled: [{id, file, name, label}] (turned off for the next launch, see plugin.setEnabled)}", + "{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\"|\"importer\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.), format? and extensions? (an exporter's or importer's format id, plugin., for document.export or document.open's importer, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), inputs?: [\"type\"|\"image\"|\"symbolInstance\"] (the objects besides paths a filter reads, see plugin.run), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies; progress: it may report its progress, see Cancel and write log lines; settings: it may keep settings of its own; cache: it may keep a cache of its own; commands: a filter may run the commands its commandAllowlist names, see docs/plugins.md), commandAllowlist?: [command id] (the commands a filter with the commands permission may run while it runs, with vc_call), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2), paintOutput?: true (a live effect whose pieces have paints of their own, see docs/plugins.md), cacheBytes (the bytes its cache holds; see plugin.clearCache), commands?: [{id, command (its command id, plugin.., which runs it), label, params (doc), paramsSchema}] (the menu commands a filter or live effect adds)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null (under automation read roots, a folder outside them is null, and a failed file and a plug-in's source outside them are named by their file name), safeMode: bool (no plug-in loads or runs, see plugin.safeMode), disabled: [{id, file, name, label}] (turned off for the next launch, see plugin.setEnabled)}", always, list ), @@ -1127,7 +1201,7 @@ pub fn specs() -> Vec { "Safe Mode", [], None, - "{} → {active: bool (no plug-in loads or runs: plugin.install, .run, .reload and exporters fail, live effects draw their object's geometry unchanged and the objects keep their effects), source: \"flag\" (started with --safe-mode) | \"startup\" (the user chose Safe Mode, or hasn't chosen yet, after a launch that stopped while a plug-in was loading) | null, disabled: [{id, file, name, label}] (turned off for the next launch; id is null for one known only by its file), pendingItem: {id, file, name, label} | null (the plug-in the last launch was loading when it stopped, until the user chooses; it may not be what stopped it)}. Safe Mode is chosen at launch, never by a command", + "{} → {active: bool (no plug-in loads or runs: plugin.install, .run, .reload and exporters fail, live effects draw their object's geometry unchanged and the objects keep their effects), source: \"flag\" (started with --safe-mode) | \"startup\" (the user chose Safe Mode, or hasn't chosen yet, after a launch that stopped while a plug-in was loading) | null, disabled: [{id, file, name, label}] (turned off for the next launch; id is null for one known only by its file; under automation read roots, a file outside them is named by its file name here and in pendingItem, and plugin.setEnabled takes that name), pendingItem: {id, file, name, label} | null (the plug-in the last launch was loading when it stopped, until the user chooses; it may not be what stopped it)}. Safe Mode is chosen at launch, never by a command", always, safe_mode_state ), @@ -1136,7 +1210,7 @@ pub fn specs() -> Vec { "Enable on Re-launch", [], None, - "{id | file: a plug-in as plugin.safeMode's disabled lists it, enabled: bool} turn a plug-in on or off for the next launch (Help › Safe Mode's Enable on Re-launch); nothing loads, unloads or is uninstalled now. Turning on by id also turns on the file the plug-in is stored in. Turning on one that isn't disabled is an error. plugin.install of a disabled plug-in turns it on → {changed, saved: false when the change isn't in the plug-in folder for the next launch (a session without one, or the list there can't be read or written or is full; it is saved with the next change that saves), disabled}", + "{id | file: a plug-in as plugin.safeMode's disabled lists it or as plugin.list's source (under automation read roots, a file outside them by the file name shown, when no other listed file has it), enabled: bool} turn a plug-in on or off for the next launch (Help › Safe Mode's Enable on Re-launch); nothing loads, unloads or is uninstalled now. Turning on by id also turns on the file the plug-in is stored in. Turning on one that isn't disabled is an error. plugin.install of a disabled plug-in turns it on → {changed, saved: false when the change isn't in the plug-in folder for the next launch (a session without one, or the list there can't be read or written or is full; it is saved with the next change that saves), disabled}", always, set_enabled ), diff --git a/crates/engine/src/cmd/recovery.rs b/crates/engine/src/cmd/recovery.rs index fce76bcc27..f0087595dd 100644 --- a/crates/engine/src/cmd/recovery.rs +++ b/crates/engine/src/cmd/recovery.rs @@ -97,6 +97,10 @@ pub trait RecoveryStore: Send + Sync { fn remove(&self, name: &str) -> std::result::Result<(), String>; /// Where the entries are, as the user knows it (a folder, "browser storage"). fn location(&self) -> String; + /// Is [`RecoveryStore::location`] a folder on this computer (default: no)? + fn in_folder(&self) -> bool { + false + } /// The areas in the store (default: those with entries). fn areas(&self) -> std::result::Result, String> { let names = self.list()?; @@ -477,6 +481,9 @@ impl RecoveryStore for FolderStore { fn location(&self) -> String { self.0.to_string_lossy().into_owned() } + fn in_folder(&self) -> bool { + true + } fn areas(&self) -> std::result::Result, String> { Self::names(&self.0, true) } @@ -938,6 +945,21 @@ fn sweep(store: &dyn RecoveryStore, area: &str) { } } +/// The document's file `path` as a reply names it: under automation read roots, only inside them. +fn shown_path(path: Option) -> Option { + path.filter(|p| crate::file_access::check_read(p).is_ok()) +} + +/// Where `store` keeps its copies, as a reply names it: a recovery folder outside the automation +/// read roots is `null`. +fn shown_location(store: &dyn RecoveryStore) -> Value { + let location = store.location(); + if store.in_folder() && crate::file_access::check_read(&location).is_err() { + return Value::Null; + } + json!(location) +} + fn read_meta(store: &dyn RecoveryStore, file: &str) -> Meta { store.read(&format!("{file}{META_EXT}")).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default() } @@ -1235,7 +1257,7 @@ fn save(s: &mut Session, _: &Value) -> Result { } forget_clean(s); heartbeat(s); - Ok(json!({ "saved": saved, "skipped": skipped, "location": store.location() })) + Ok(json!({ "saved": saved, "skipped": skipped, "location": shown_location(store.as_ref()) })) } /// Who an area of the store belongs to. @@ -1361,7 +1383,7 @@ fn list(s: &mut Session, _: &Value) -> Result { let meta = read_meta(store.as_ref(), f); let title = if meta.title.is_empty() { f.rsplit('/').next().unwrap_or(f).to_string() } else { meta.title }; rows.push(json!({ - "file": f, "title": title, "path": meta.path, "format": meta.format, "saved": meta.saved, + "file": f, "title": title, "path": shown_path(meta.path), "format": meta.format, "saved": meta.saved, "open": (who == Owner::Mine && open.contains(f)) || left.contains(f), "running": who == Owner::Running, })); } @@ -1370,7 +1392,7 @@ fn list(s: &mut Session, _: &Value) -> Result { tidy(store.as_ref(), &area, hold); } } - Ok(json!({ "copies": rows, "location": store.location() })) + Ok(json!({ "copies": rows, "location": shown_location(store.as_ref()) })) } /// A gone app's area to act on: its name, the copies to act on, and its lock. @@ -1483,7 +1505,10 @@ fn restore_one(s: &mut Session, store: &Arc, file: &str) -> R // The copy stays until the document is saved or closed. st.recovery = Some(RecoveryCopy { file: mine.clone(), doc: st.doc.clone() }); let title = st.title(); - Ok(super::fileio::merge(json!({ "index": index, "title": title, "file": mine, "path": meta.path, "warnings": warnings }), links.to_json())) + Ok(super::fileio::merge( + json!({ "index": index, "title": title, "file": mine, "path": shown_path(meta.path), "warnings": warnings }), + links.to_json(), + )) } fn restore(s: &mut Session, p: &Value) -> Result { @@ -1527,7 +1552,7 @@ pub fn specs() -> Vec { "Save Recovery Data", [], None, - "{} write a recovery copy of every modified document changed since its last copy, in this app's own area of the store (the app does this every autosaveInterval minutes while autosaveRecovery is on; documents of more than 20000 objects are skipped while recoveryOffForComplex is on). Images are stored apart, each once while unchanged. A copy goes when its document is saved, reverted or closed → {saved: [{file, title, bytes, imageBytes (images written this time)}], skipped: [{title, reason}], location} (location: the recovery folder, or browser storage on the web)", + "{} write a recovery copy of every modified document changed since its last copy, in this app's own area of the store (the app does this every autosaveInterval minutes while autosaveRecovery is on; documents of more than 20000 objects are skipped while recoveryOffForComplex is on). Images are stored apart, each once while unchanged. A copy goes when its document is saved, reverted or closed → {saved: [{file, title, bytes, imageBytes (images written this time)}], skipped: [{title, reason}], location} (location: the recovery folder, or browser storage on the web; null under automation read roots when the folder is outside them)", has_store, save ), @@ -1536,7 +1561,7 @@ pub fn specs() -> Vec { "Recovery Data", [], None, - "{} the recovery copies in the store → {copies: [{file (\"/\"), title, path (the document's file, if it had one), format, saved (Unix seconds or null), open (the copy of a document open here), running (kept by another VectorCraft that is running)}], location}; copies neither open nor running were left behind by a crash", + "{} the recovery copies in the store → {copies: [{file (\"/\"), title, path (the document's file, if it had one), format, saved (Unix seconds or null), open (the copy of a document open here), running (kept by another VectorCraft that is running)}], location}; copies neither open nor running were left behind by a crash; under automation read roots, path is null when the document's file is outside them, and location when the recovery folder is", has_store, list ), @@ -1545,7 +1570,7 @@ pub fn specs() -> Vec { "Restore Recovered Documents", [], None, - "{file?} open a copy left behind by a crash (default: every one; a running VectorCraft's copies are never taken) as a new document titled \" [Recovered]\": modified, and Save asks where to save it (suggesting its original file) → {restored: [{index, title, file, path, warnings, missingLinks, modifiedLinks, updatedLinks}], failed: [{file, error}] (damaged copies, when restoring every one; a named one fails the command)}. The copy moves to this app's area and stays until the document is saved or closed", + "{file?} open a copy left behind by a crash (default: every one; a running VectorCraft's copies are never taken) as a new document titled \" [Recovered]\": modified, and Save asks where to save it (suggesting its original file) → {restored: [{index, title, file, path, warnings, missingLinks, modifiedLinks, updatedLinks}], failed: [{file, error}] (damaged copies, when restoring every one; a named one fails the command)}; under automation read roots, path is null when the original file is outside them. The copy moves to this app's area and stays until the document is saved or closed", has_store, restore ), diff --git a/crates/engine/src/cmd/scripts.rs b/crates/engine/src/cmd/scripts.rs index 8c86f0dfe3..44a14dbe34 100644 --- a/crates/engine/src/cmd/scripts.rs +++ b/crates/engine/src/cmd/scripts.rs @@ -233,6 +233,43 @@ impl Session { self.scripts_dir.as_deref().filter(|_| !cfg!(target_arch = "wasm32")) } + /// The last run's report ([`Session::script_report`]) as a reply shows it: under automation read + /// roots, a file the run wrote outside them (`written`) and the file a failed run stopped in + /// (`file`, also in the run's error line in `console`) are named by their file name. What the + /// script wrote itself (`log`, its output lines) stays as written. + pub fn shown_script_report(&self) -> Option { + let mut report = self.script_report()?.clone(); + if let Some(written) = report.get_mut("written").and_then(Value::as_array_mut) { + for path in written.iter_mut() { + if let Some(name) = path.as_str().and_then(hidden_name) { + *path = json!(name); + } + } + } + if let Some(file) = report.get("file").and_then(Value::as_str).map(str::to_string) + && let Some(name) = hidden_name(&file) + { + // The run's error lines come after every line the script wrote. They are written again + // from the report with the file's name, which also covers a line the console cut inside + // the path. A console that dropped its oldest lines may hold only the last of them. + let before = script::error_lines(&report); + report["file"] = json!(name); + if let Some(message) = report.get("message").and_then(Value::as_str) { + report["message"] = json!(message.replace(&file, &name)); + } + let after = script::error_lines(&report); + if let Some(lines) = report.pointer_mut("/console/lines").and_then(Value::as_array_mut) { + let kept = (1..=before.len().min(lines.len())) + .rev() + .find(|&n| lines.iter().rev().take(n).map(Value::as_str).eq(before.iter().rev().take(n).map(|l| Some(l.as_str())))) + .unwrap_or(0); + lines.truncate(lines.len() - kept); + lines.extend(after.iter().skip(after.len().saturating_sub(kept)).map(|l| json!(l))); + } + } + Some(report) + } + /// The text of the script at `path`, which a listing of the Scripts folder gave /// ([`script::folder::read`]), checked as `script.run` checks a file's. pub fn installed_script(&self, path: &str) -> Result { @@ -248,6 +285,13 @@ impl Session { } } +/// The file name a reply names `path` by when the file is outside the automation read roots; `None` +/// when a reply may name it whole. +fn hidden_name(path: &str) -> Option { + let shown = super::fileio::shown_path(path); + (shown != path).then(|| shown.into_owned()) +} + fn list_installed(s: &mut Session, _: &Value) -> Result { let Some(root) = s.scripts_dir() else { return Ok(json!({"folder": null, "scripts": [], "truncated": false, "unreadable": false})); diff --git a/crates/engine/src/script/mod.rs b/crates/engine/src/script/mod.rs index 0821a11e71..0da75a42ed 100644 --- a/crates/engine/src/script/mod.rs +++ b/crates/engine/src/script/mod.rs @@ -2116,6 +2116,27 @@ fn line_to(s: &mut Session, host: &Host, entry: console::Entry, cut: bool) { host.console.borrow_mut().push(line, cut); } +/// The error of the failed run `report` describes, as its console lines give it; `name` is the +/// run's name in those lines ([`console::line_name`]). +fn error_text(name: &str, report: &Value) -> String { + let (line, column) = (report["line"].as_u64().unwrap_or(0), report["column"].as_u64().unwrap_or(0)); + let message = report["message"].as_str().unwrap_or_default(); + match report["file"].as_str() { + Some(file) if line > 0 => format!("{name}: {file}:{line}:{column}: {message}"), + _ if line > 0 => format!("{name}:{line}:{column}: {message}"), + _ => format!("{name}: location unavailable: {message}"), + } +} + +/// The console lines of the error of the failed run `report` describes, each as the console keeps +/// it, cut to its limit. +pub(crate) fn error_lines(report: &Value) -> Vec { + let name = console::line_name(report["name"].as_str().unwrap_or_default()); + let mut lines = vec![]; + each_line(&error_text(&name, report), |line, _| lines.push(line)); + lines +} + /// Ends the console lines of `host`'s run: the line `$.write` left unended, then for a failed run /// (`report["ok"]` false) its error as `name:line:column: message`, then an end line and a report /// line in the session's console. Adds the run's time, host calls by kind, slowest commands, @@ -2130,14 +2151,7 @@ fn close_run(s: &mut Session, host: &Host, report: &mut Value, ending: console:: } } if report["ok"] == false { - let (line, column) = (report["line"].as_u64().unwrap_or(0), report["column"].as_u64().unwrap_or(0)); - let message = report["message"].as_str().unwrap_or_default(); - let text = match report["file"].as_str() { - Some(file) if line > 0 => format!("{}: {file}:{line}:{column}: {message}", host.name), - _ if line > 0 => format!("{}:{line}:{column}: {message}", host.name), - _ => format!("{}: location unavailable: {message}", host.name), - }; - each_line(&text, |line, cut| line_to(s, host, console::Entry::Error(line), cut)); + each_line(&error_text(&host.name, report), |line, cut| line_to(s, host, console::Entry::Error(line), cut)); } let timing = clock().is_some(); let ms = millis(host.run_nanos.get()); diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index a1e615a08d..76df1c2194 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -1433,6 +1433,92 @@ fn plugins_folder_set_again_after_clearing_installs_its_plugins() { run(&mut s, "plugin.remove", json!({"id": id})); } +/// Under automation read roots, `plugin.list` names the plug-in folder last loaded, its failed +/// files and a plug-in's source file only inside them: a folder outside is `null`, and a file +/// outside is named by its file name. +#[test] +fn plugin_list_leaves_out_paths_outside_the_read_root() { + use crate::file_access::{AutomationRoots, confine}; + let _folder = PREFS_FOLDER.lock().unwrap_or_else(|e| e.into_inner()); + let base = crate::tests_links::Folder::new("plugin-list-read-root"); + let (inside, outside) = (base.0.join("in"), base.0.join("out")); + std::fs::create_dir_all(&inside).unwrap(); + std::fs::create_dir_all(&outside).unwrap(); + let id = "test.engine.listroots"; + let src = tpl::returning(&tpl::manifest(id, "filter", "{}"), r#"{"objects":[]}"#); + std::fs::write(outside.join("hidden.wasm"), wat::parse_str(src).unwrap()).unwrap(); + std::fs::write(outside.join("broken.wasm"), b"\0asm?").unwrap(); + let folder = outside.to_string_lossy().to_string(); + let mut s = session(); + let r = run(&mut s, "plugin.reload", json!({"path": folder})); + assert_eq!(r["loaded"], json!([id]), "{r}"); + let broken = outside.join("broken.wasm").to_string_lossy().to_string(); + let source = outside.join("hidden.wasm").to_string_lossy().to_string(); + let entry = |list: &Value| list["plugins"].as_array().unwrap().iter().find(|p| p["id"] == id).cloned().unwrap(); + + // The person sees the paths. + let person = run(&mut s, "plugin.list", json!({})); + assert_eq!((&person["folder"]["folder"], &person["folder"]["failed"][0]["file"]), (&json!(folder), &json!(broken)), "{person}"); + assert_eq!(entry(&person)["source"], json!(source)); + + // The client sees their names. + let roots = AutomationRoots::new(&[&inside], &[]).unwrap(); + let listed = confine(roots.as_ref(), || s.execute("plugin.list", &json!({}))).unwrap(); + let report = &listed["folder"]; + assert_eq!((&report["folder"], &report["loaded"], &report["failed"][0]["file"]), (&Value::Null, &json!([id]), &json!("broken.wasm")), "{listed}"); + assert_eq!(entry(&listed)["source"], "hidden.wasm"); + let info = confine(roots.as_ref(), || s.execute("plugin.info", &json!({"id": id}))).unwrap(); + assert_eq!(info["source"], "hidden.wasm"); + let outside_text = json!(folder).to_string(); + for reply in [&listed, &info] { + assert!(!reply.to_string().contains(outside_text.trim_matches('"')), "no path outside the root: {reply}"); + } + + // A read root holding the folder shows it whole. + let wider = AutomationRoots::new(&[&outside], &[]).unwrap(); + let listed = confine(wider.as_ref(), || s.execute("plugin.list", &json!({}))).unwrap(); + assert_eq!(listed["folder"]["folder"], json!(folder), "{listed}"); + run(&mut s, "plugin.remove", json!({"id": id})); +} + +/// Under automation read roots, a plug-in turned off for the next launch is listed with a file +/// outside them named by its file name, and `plugin.setEnabled` turns it on by that name. +#[test] +fn disabled_plugins_name_files_outside_the_read_root_by_their_names() { + if !in_own_process("disabled_plugins_name_files_outside_the_read_root_by_their_names") { + return; + } + use crate::file_access::{AutomationRoots, confine}; + let base = crate::tests_links::Folder::new("plugin-disabled-read-root"); + let (inside, outside) = (base.0.join("in"), base.0.join("out")); + std::fs::create_dir_all(&inside).unwrap(); + std::fs::create_dir_all(&outside).unwrap(); + let id = "test.engine.disabledroots"; + let src = tpl::returning(&tpl::manifest(id, "filter", "{}"), r#"{"objects":[]}"#); + let file = outside.join("off.wasm"); + std::fs::write(&file, wat::parse_str(src).unwrap()).unwrap(); + let file = file.to_string_lossy().to_string(); + let mut s = session(); + run(&mut s, "plugin.install", json!({"path": file})); + let roots = AutomationRoots::new(&[&inside], &[]).unwrap(); + // The client turns it off by the source plugin.list shows. + let listed = confine(roots.as_ref(), || s.execute("plugin.list", &json!({}))).unwrap(); + let source = listed["plugins"].as_array().unwrap().iter().find(|p| p["id"] == id).unwrap()["source"].clone(); + assert_eq!(source, "off.wasm", "{listed}"); + confine(roots.as_ref(), || s.execute("plugin.setEnabled", &json!({"file": source, "enabled": false}))).unwrap(); + assert_eq!(run(&mut s, "plugin.safeMode", json!({}))["disabled"][0]["file"], json!(file), "the person sees the path"); + + let outside_text = json!(outside.to_string_lossy()).to_string(); + for cmd in ["plugin.list", "plugin.safeMode"] { + let r = confine(roots.as_ref(), || s.execute(cmd, &json!({}))).unwrap(); + assert_eq!(r["disabled"][0]["file"], "off.wasm", "{cmd}: {r}"); + assert!(!r.to_string().contains(outside_text.trim_matches('"')), "{cmd}: no path outside the root: {r}"); + } + let r = confine(roots.as_ref(), || s.execute("plugin.setEnabled", &json!({"file": "off.wasm", "enabled": true}))).unwrap(); + assert_eq!((&r["changed"], &r["disabled"]), (&json!(true), &json!([])), "{r}"); + run(&mut s, "plugin.remove", json!({"id": id})); +} + /// #2541: a plug-in installed in a session with a plug-in store is installed again in the next /// session that attaches the store, and Remove Plug-in takes it out of the store. #[test] diff --git a/crates/engine/src/tests/recovery.rs b/crates/engine/src/tests/recovery.rs index 8248f12a48..1f3867a622 100644 --- a/crates/engine/src/tests/recovery.rs +++ b/crates/engine/src/tests/recovery.rs @@ -1545,6 +1545,71 @@ impl RecoveryStore for DiesAfterDocument { } } +/// Under automation read roots, `file.recovery.save`, `file.recovery.list` and +/// `file.recovery.restore` name a document's file and the recovery folder only inside them. +#[test] +fn recovery_replies_leave_out_paths_outside_the_read_root() { + use crate::file_access::{AutomationRoots, confine}; + use serde_json::Value; + let base = crate::tests_links::Folder::new("recovery-read-root"); + let (inside, outside) = (base.0.join("in"), base.0.join("out")); + std::fs::create_dir_all(&inside).unwrap(); + std::fs::create_dir_all(&outside).unwrap(); + let store: Arc = Arc::new(FolderStore::new(outside.join("recovery"))); + let location = outside.join("recovery").to_string_lossy().to_string(); + let kept = inside.join("kept.vectorcraft").to_string_lossy().to_string(); + let hidden = outside.join("hidden.vectorcraft").to_string_lossy().to_string(); + let mut s = Session::new(); + s.recovery.set_store(store.clone()); + for path in [&kept, &hidden] { + s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap(); + s.execute("file.saveAs", &json!({"path": path})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 20})).unwrap(); + } + let roots = AutomationRoots::new(&[&inside], &[]).unwrap(); + let saved = confine(roots.as_ref(), || s.execute("file.recovery.save", &json!({}))).unwrap(); + assert_eq!(saved["location"], Value::Null, "{saved}"); + s.execute("shape.ellipse", &json!({"x": 80, "y": 20, "width": 20, "height": 20})).unwrap(); + assert_eq!(s.execute("file.recovery.save", &json!({})).unwrap()["location"], json!(location), "the person sees the folder"); + + let sorted = |v: &Value, key: &str| -> Vec { + let mut out: Vec = v[key].as_array().unwrap().iter().map(|c| c["path"].to_string()).collect(); + out.sort(); + out + }; + let both = |first: Value| { + let mut out = vec![first.to_string(), json!(kept).to_string()]; + out.sort(); + out + }; + assert_eq!(sorted(&s.execute("file.recovery.list", &json!({})).unwrap(), "copies"), both(json!(hidden))); + let listed = confine(roots.as_ref(), || s.execute("file.recovery.list", &json!({}))).unwrap(); + assert_eq!((sorted(&listed, "copies"), &listed["location"]), (both(Value::Null), &Value::Null), "{listed}"); + let outside_text = json!(outside.to_string_lossy()).to_string(); + assert!(!listed.to_string().contains(outside_text.trim_matches('"')), "no path outside the root: {listed}"); + // A relative recovery folder is checked as the file system reads it; a store that is no folder + // keeps its name. + let mut relative = Session::new(); + relative.recovery.set_store(Arc::new(FolderStore::new("vc-recovery-relative-folder"))); + let l = confine(roots.as_ref(), || relative.execute("file.recovery.list", &json!({}))).unwrap(); + assert_eq!(l["location"], Value::Null, "{l}"); + let (mut memory, _) = session(); + let l = confine(roots.as_ref(), || memory.execute("file.recovery.list", &json!({}))).unwrap(); + assert_eq!(l["location"], "memory", "{l}"); + + // After a crash, the copies restored. + drop(s); + let mut s = Session::new(); + s.recovery.set_store(store.clone()); + let r = confine(roots.as_ref(), || s.execute("file.recovery.restore", &json!({}))).unwrap(); + assert_eq!(sorted(&r, "restored"), both(Value::Null), "{r}"); + let mut opened: Vec> = s.documents().iter().map(|d| d.path.clone()).collect(); + opened.sort(); + let mut files = vec![Some(hidden), Some(kept)]; + files.sort(); + assert_eq!(opened, files, "the documents keep their files"); +} + /// A crash between writing a copy's document and its details restores the new document with /// every image, the one placed since the last copy too (and the first copy likewise). #[test] diff --git a/crates/engine/src/tests/script_console.rs b/crates/engine/src/tests/script_console.rs index 27f53cef07..48d5c8ed0a 100644 --- a/crates/engine/src/tests/script_console.rs +++ b/crates/engine/src/tests/script_console.rs @@ -265,3 +265,31 @@ fn the_slowest_commands_are_the_longest_in_all_then_by_id() { assert_eq!(ids, ["b.two", "a.one", "c.three", "f", "e"]); assert_eq!((slow[0].calls, slow[0].total_ms, slow[0].max_ms), (3, 9, 4)); } + +/// Under automation read roots, the error lines of a failed run name the included file it stopped +/// in by its file name also when the console cut a line inside the file's path. +#[test] +fn a_cut_error_line_leaves_out_an_included_files_folder_outside_the_read_root() { + use crate::file_access::{AutomationRoots, confine}; + let base = crate::tests_links::Folder::new("script-report-cut-line"); + let inside = base.0.join("in"); + std::fs::create_dir_all(&inside).unwrap(); + // A path longer than a console line, as the report of a failed run names it. + let file = base.0.join("out").join("d".repeat(MAX_LINE_BYTES)).join("bad.jsxinc").to_string_lossy().to_string(); + let mut report = json!({"ok": false, "name": "t.js", "file": file, "line": 2, "column": 7, "message": format!("Error: stopped\nin {file}")}); + let error = script::error_lines(&report); + assert!(error.len() == 2 && error.iter().all(|l| l.len() <= MAX_LINE_BYTES && l.ends_with(CUT_MARK)), "both lines are cut: {error:?}"); + let written: Vec = std::iter::once("written by the script".to_string()).chain(error).collect(); + report["console"] = json!({"lines": written, "droppedLines": 0, "truncatedLines": 2}); + let mut s = Session::new(); + s.script_report = Some(report.clone()); + + let roots = AutomationRoots::new(&[&inside], &[]).unwrap(); + let shown = confine(roots.as_ref(), || s.shown_script_report()).unwrap(); + assert_eq!((&shown["file"], &shown["message"]), (&json!("bad.jsxinc"), &json!("Error: stopped\nin bad.jsxinc")), "{shown}"); + assert_eq!(shown["console"]["lines"], json!(["written by the script", "t.js: bad.jsxinc:2:7: Error: stopped", "in bad.jsxinc"]), "{shown}"); + let outside = json!(base.0.join("out").to_string_lossy()).to_string(); + assert!(!shown.to_string().contains(outside.trim_matches('"')), "no path outside the root: {shown}"); + // The person sees the report as it is. + assert_eq!(s.shown_script_report(), Some(report)); +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 11edb6de6e..ef6a6d20c8 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -253,7 +253,7 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value { } let mut inspected = json!({ "scriptRunning": app.session.script_running(), - "scriptReport": app.session.script_report(), + "scriptReport": app.session.shown_script_report(), "tool": app.session.tool_id(), "toolOptions": app.session.tool_options(), "ui": ui, diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index 26406a4992..3a84e0b94d 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -567,6 +567,50 @@ fn ui_inspect_keeps_text_that_only_looks_like_a_library_id() { let _ = std::fs::remove_dir_all(base); } +/// `ui.inspect`'s `scriptReport` names a file the person's script wrote outside the read root, and +/// the included file a failed run stopped in (also in its error line), by its file name; the person +/// sees the paths. +#[test] +fn ui_inspect_script_report_leaves_out_files_outside_the_read_root() { + let (mut app, tx, base) = confined("inspect-script-report"); + let (kept, hidden) = (s(base.join("in/kept.svg")), s(base.join("outside/hidden.svg"))); + app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let source = format!( + "vc.run('document.export', {{path: {kept}, format: 'svg'}}); vc.run('document.export', {{path: {hidden}, format: 'svg'}});", + kept = json!(kept), + hidden = json!(hidden) + ); + let finish = |app: &mut VectorcraftApp, params: Value| { + app.session.execute("script.run", ¶ms).unwrap(); + for _ in 0..10_000 { + if app.session.script_pump().is_some() { + break; + } + } + }; + finish(&mut app, json!({"source": source, "name": "t.js"})); + let ctx = egui::Context::default(); + let outside = json!(s(base.join("outside"))).to_string(); + let person = crate::control::inspect(&app, &ctx); + assert_eq!(person["scriptReport"]["written"], json!([kept, hidden]), "{person}"); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + assert_eq!(r["result"]["scriptReport"]["written"], json!([kept, "hidden.svg"]), "{r}"); + assert!(!r.to_string().contains(outside.trim_matches('"')), "no path outside the root: {r}"); + assert_eq!(app.session.script_report().unwrap()["written"], json!([kept, hidden]), "the report is not changed"); + + // A run that fails in a file it includes. + std::fs::write(base.join("outside/run.jsx"), "#include \"bad.jsxinc\"\n").unwrap(); + std::fs::write(base.join("outside/bad.jsxinc"), "\nthrow new Error('stopped here');\n").unwrap(); + finish(&mut app, json!({"path": s(base.join("outside/run.jsx"))})); + let person = crate::control::inspect(&app, &ctx); + assert_eq!(person["scriptReport"]["file"], json!(s(base.join("outside/bad.jsxinc"))), "{person}"); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + assert_eq!(r["result"]["scriptReport"]["file"], "bad.jsxinc", "{r}"); + assert!(r["result"]["scriptReport"]["console"].to_string().contains("bad.jsxinc:2:"), "{r}"); + assert!(!r.to_string().contains(outside.trim_matches('"')), "no path outside the root: {r}"); + let _ = std::fs::remove_dir_all(base); +} + /// A client without automation roots sees every loaded library's full id in `ui.inspect`. #[test] fn ui_inspect_shows_full_library_ids_without_a_read_root() { diff --git a/docs/mcp.md b/docs/mcp.md index baa2631f7b..86b5a4af30 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -170,6 +170,17 @@ the suite) keep the agent to the folders you give it: `null`; an empty folder and the scratch disk choices `Startup` and `None` stay. In `prefs.get {}` and `prefs.reset`, an export preset in `screenExportPresets` leaves out its `folder` when the folder is outside the read roots, and the preset stays. The preferences themselves are not changed. +- `plugin.list`, `plugin.safeMode`, `file.recovery.*` and `ui_inspect`'s `scriptReport` leave out the paths + outside the read roots, also those the person or an earlier request put there. In `plugin.list`, the plug-in + folder last loaded is `null` in `folder.folder`, and a failed file in `folder.failed` and a plug-in's `source` + (also in `plugin.info`) are named by their file name, in the errors too. A plug-in turned off for the next + launch (`disabled`, and `pendingItem` in `plugin.safeMode`) has a file outside the read roots named by its file + name. `plugin.setEnabled {file}` takes that name, and a plug-in's `source` name, when no other listed file has + it. In `file.recovery.list` and `file.recovery.restore`, a + document's original file in `path` is `null`, and so is the recovery folder in `location` (`file.recovery.save` + too); the restored documents keep their files. In `scriptReport`, a file the run wrote (`written`) and the + included file a failed run stopped in (`file`, also in `message` and the run's error lines in `console`) are named + by their file name; what the script wrote itself (`log` and its output lines) stays as written. - Each flag may be given more than once (#3546): a path inside any of the read roots may be read, one inside any of the write roots written, so one session can read a project folder and a shared assets folder, or write to an output folder and a scratch folder, without widening a root to their common parent. A folder given twice diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f28078ec33..320a43ccc2 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -766,6 +766,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Link replies under `--automation-read-root`: `links.list`, `links.check`, `links.info` and `document.open`'s link rows leave out the path, found path and folder of a linked file outside the read roots, and the stored size and date of it in `links.info`; each row keeps its id, file name and status, and the document's links are not changed. `crates/engine/src/tests/linkspanel.rs` (`link_paths_outside_the_read_roots_are_left_out`), `crates/engine/src/tests/placed_document.rs` (`link_info_leaves_out_a_placed_documents_path_outside_the_read_roots`). - Swatch, brush, symbol and graphic style library lists under `--automation-read-root`: library files and the user library folder outside the read roots are left out, a library loaded from outside them is listed and found by its file name, and the app's own list is not changed. `crates/engine/src/tests/library_roots.rs`. - Preferences commands under `--automation-read-root`: `prefs.get`, `prefs.list`, `prefs.set` and `prefs.reset` show folder preferences outside the read roots as `null` and leave out an export preset's folder outside them, and the preferences are not changed. `crates/engine/src/tests/prefs_roots.rs`. + - Plug-in, recovery and script report replies under `--automation-read-root`: `plugin.list`, `plugin.info` and `plugin.safeMode` name the plug-in folder, failed files, plug-in sources and disabled plug-ins' files outside the read roots by file name or `null`, `file.recovery.*` leave out a document's original file and the recovery folder outside them, and `ui.inspect`'s `scriptReport` names files a run wrote outside them by file name. `crates/engine/src/tests/plugins.rs` (`plugin_list_leaves_out_paths_outside_the_read_root`), `crates/engine/src/tests/recovery.rs` (`recovery_replies_leave_out_paths_outside_the_read_root`), `crates/ui-egui/src/tests/automation.rs` (`ui_inspect_script_report_leaves_out_files_outside_the_read_root`). - Scripts: `alert`, `confirm` and `prompt` open dialogs during a run, each with a Stop button that ends the run and puts every document back. In the app the run waits at the call for the person's answer; a run over MCP or the command line takes its answers in order from `script.run`'s `answers`, and the report lists each dialog with its answer. `crates/engine/src/tests/script_dialogs.rs`, `crates/ui-egui/src/tests/script_dialogs.rs`. - Parameter keys: the engine refuses a key that a command's params description doesn't name before the command runs, in every build and for every caller, including the app's own calls, the commands a command runs and the steps of `command.batch` and `action.batch`. An action recorded by an older version still plays from the Actions panel and the Batch dialog (`Session::replay`). `crates/engine/tests/it/param_keys.rs`. - MCP server with 33 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, undo/redo, UI inspect/screenshot aliases, any command), attached to the running app or headless. Headless and remote `command_list` include desktop `UI_COMMANDS` (`ui: true`); a UI-only id still needs `--connect` to run. Eight prompts (poster, icon-set, recolor, trace-and-style, export-set, check-spelling, variables-bind, path-tools). Effect ids are not command ids. Prefer a CLI built from the checkout when a release binary lags (#6460). `vectorcraft://commands/reference` serves the API reference. Tool descriptions and replies agree with the engine: format lists come from its format table, `save_file` says it keeps the document's own format, `inspect_document` reports the paint type's characters show, and selection exports report the encoder's warnings. `--automation-read-root` / `--automation-write-root` (on `vectorcraft-cli mcp` and on `vectorcraft --control`) confine the files commands read and write to chosen folders, links followed, with the flags PhotoCraft takes (#832); each flag may be given more than once, a path inside any of its folders being allowed (#3546, `file_access::tests::several_roots_of_a_kind_each_grant_their_access`, `cli::mcp_automation_roots_take_several_folders`). A long export can be cancelled (`notifications/cancelled` stops it at the next page or group and writes nothing), reports page progress to a client that sent a progress token, and does not make other requests wait: engine-free ones answer at once, the rest are refused as busy after two seconds (#2165, #2171, #2172). From 3adadd52f4daad74527b886ade6174cd6fb7c6eb Mon Sep 17 00:00:00 2001 From: ruchirguitar <113586074+ruchirguitar@users.noreply.github.com> Date: Thu, 12 Nov 2026 19:40:04 +0530 Subject: [PATCH 25/26] UI: a tap within 1 pt still selects a numeric field's value (#1326) Review feedback: with no tolerance, pen or trackpad jitter turned a tap into a drag and the value was no longer selected. A release within 1 pt of the press is a tap; anything further keeps the caret. Regression test: a_tap_with_a_pixel_of_jitter_still_selects_all_on_release. --- crates/ui-egui/src/tests/numfields.rs | 18 ++++++++++++++++-- crates/ui-egui/src/widgets/text.rs | 15 ++++++++++----- docs/gaps.md | 2 +- 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/tests/numfields.rs b/crates/ui-egui/src/tests/numfields.rs index d7ceed77b9..0e1e794b8d 100644 --- a/crates/ui-egui/src/tests/numfields.rs +++ b/crates/ui-egui/src/tests/numfields.rs @@ -1361,8 +1361,8 @@ fn a_short_drag_released_as_a_click_keeps_its_caret() { assert_eq!(f.frame(vec![enter()], &draw), Some(1273456.0), "typing lands where the drag left the caret"); } -/// #1326: the smallest pointer movement egui still reports as a click (under the 3-point drag -/// threshold) must keep the caret too; only a press released where it went down selects all. +/// #1326: a pointer movement well under egui's click tolerance must keep the caret too; only a +/// press released within a point of where it went down selects all. #[test] fn a_sub_threshold_movement_released_as_a_click_keeps_its_caret() { let mut f = Field::new(); @@ -1391,6 +1391,20 @@ fn a_tap_without_movement_still_selects_all_on_release() { assert_eq!(f.selection(), Some((0, field_text(0).chars().count())), "a plain tap selects all"); } +/// A pen or trackpad tap often moves a pixel between press and release: it is still a tap. +#[test] +fn a_tap_with_a_pixel_of_jitter_still_selects_all_on_release() { + let mut f = Field::new(); + let draw = |ui: &mut egui::Ui| numeric_fixture(ui, 0); + f.frame(vec![], &draw); + let at = f.text_point(field_text(0), 2, 7.0); + f.frame(vec![Event::PointerMoved(at), pointer_button(at, true)], &draw); + let jitter = egui::vec2(0.7, -0.6); + f.frame(vec![Event::PointerMoved(at + jitter)], &draw); + f.frame(vec![pointer_button(at + jitter, false)], &draw); + assert_eq!(f.selection(), Some((0, field_text(0).chars().count())), "a jittery tap selects all"); +} + #[test] fn keyboard_focus_keeps_selecting_the_entire_numeric_field() { for mode in 0..4 { diff --git a/crates/ui-egui/src/widgets/text.rs b/crates/ui-egui/src/widgets/text.rs index 5ede108998..eec3cd3a94 100644 --- a/crates/ui-egui/src/widgets/text.rs +++ b/crates/ui-egui/src/widgets/text.rs @@ -147,10 +147,14 @@ pub(crate) fn own_field_shift_enter(ui: &Ui, id: egui::Id, buf: &mut String, sho shift_enter_keeps_focus(ui, id) } +/// How far, in points, a focus click may move between press and release and still be a tap that +/// selects the whole numeric value. Covers pen and trackpad jitter; anything further is a drag. +const TAP_SLOP: f32 = 1.0; + /// Numeric fields select the whole text on keyboard focus, a completed focus click or a /// double-click. A pointer press keeps its caret so dragging selects from the pressed position, /// and a short drag the toolkit still counts as a click keeps it too (#1326): only a tap -/// releases into select-all. +/// (released within [`TAP_SLOP`] of its press) releases into select-all. pub(super) fn select_all_on_focus(ui: &Ui, resp: &Response, text: &str) { let pending = resp.id.with("select-all-on-focus-click"); let press_pos = resp.id.with("select-all-on-focus-press"); @@ -171,13 +175,14 @@ pub(super) fn select_all_on_focus(ui: &Ui, resp: &Response, text: &str) { }); } let clicked_focus = released && ui.data_mut(|d| d.remove_temp::(pending)).unwrap_or(false) && resp.clicked_by(egui::PointerButton::Primary); - // Any pointer movement from the press is a drag, not a tap. egui still reports a click for - // a movement under its click tolerance, and a release inside one character leaves no text - // selection, so select-all on release would replace the drag the user just made (#1326). + // A pointer released more than a point from its press is a drag, not a tap. egui still + // reports a click for a movement under its click tolerance, and a release inside one + // character leaves no text selection, so select-all on release would replace the drag the + // user just made (#1326). The one point of slack keeps a jittery pen or trackpad tap a tap. let dragged = clicked_focus && ui .data_mut(|d| d.remove_temp::(press_pos)) - .is_some_and(|p0| ui.input(|i| i.pointer.interact_pos()).is_some_and(|p1| (p1 - p0).length() > 0.0)); + .is_some_and(|p0| ui.input(|i| i.pointer.interact_pos()).is_some_and(|p1| (p1 - p0).length() > TAP_SLOP)); if resp.lost_focus() { ui.data_mut(|d| { d.remove_temp::(pending); diff --git a/docs/gaps.md b/docs/gaps.md index be3ffe3f41..5e101fe46f 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -320,7 +320,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi - **Done:** Divide splits filled paths along open cutting lines and curves (#1307), preserving the frontmost filled appearance, implicit filled closure, holes, unrelated objects and one Undo/Redo; the existing bounded-map fallback remains. -- **Done:** Numeric fields keep a text drag's start when the pointer gains focus, including short movements that egui still classifies as clicks (#1326). Any pointer movement from the press keeps the caret; only a tap without movement selects the whole value. Keyboard focus, double-click and numeric stepping remain regression-tested (`tests/numfields.rs`, `a_short_drag_released_as_a_click_keeps_its_caret` and `a_sub_threshold_movement_released_as_a_click_keeps_its_caret`). +- **Done:** Numeric fields keep a text drag's start when the pointer gains focus, including short movements that egui still classifies as clicks (#1326). A release more than 1 pt from the press keeps the caret; a tap, including one with a pixel of pen or trackpad jitter, selects the whole value. Keyboard focus, double-click and numeric stepping remain regression-tested (`tests/numfields.rs`, `a_short_drag_released_as_a_click_keeps_its_caret` and `a_sub_threshold_movement_released_as_a_click_keeps_its_caret`). - **Done:** DXF reads control-point SPLINE closure (#1299 follow-up after #1320); open curves keep their open state. DXF export now recognizes circular cubic paths conservatively and writes native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE, and a non-circular ellipse keeps the existing fallback. Alter Paths for Appearance outlines only the strokes a CAD line can't draw (aligned, profiled, offset dashes, wider than 2.11 mm, or in R13 and R14), so a 1 pt circle or arc stays a CIRCLE or ARC. CNC preset and VCarve QA remain open. Escape leaves Artboard editing for Selection; during a drag it first cancels the gesture. Headless egui key events cover idle Escape, active-drag cancellation and a subsequent Escape in `tests/distortkeys.rs`. - **Done:** DXF reads control-point SPLINE closure (#1299 follow-up after #1320); open curves keep their open state. DXF export now recognizes circular cubic paths conservatively and writes native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE. A closed four-cubic ellipse, or a circle scaled out of proportion, is written as a native ELLIPSE in R13 and later; R12 keeps the existing fallback. CNC preset and VCarve QA remain open. Escape leaves Artboard editing for Selection; during a drag it first cancels the gesture. Headless egui key events cover idle Escape, active-drag cancellation and a subsequent Escape in `tests/distortkeys.rs`. From be1b946b1e5a9085b8a90dead125e236ff4f8990 Mon Sep 17 00:00:00 2001 From: ruchirguitar <113586074+ruchirguitar@users.noreply.github.com> Date: Thu, 12 Nov 2026 19:46:23 +0530 Subject: [PATCH 26/26] Docs: revision rows for the numeric-field tap tolerance (#1326) --- docs/gaps.md | 1 + docs/ui-parity.md | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/gaps.md b/docs/gaps.md index 5e101fe46f..e4b81f6b48 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -853,6 +853,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | G11: numeric fields keep the caret when a focus click is released more than 1 pt from its press; a tap within 1 pt still selects the whole value (#6659, follow-up to #1326); short-drag and jitter-tap regressions in `tests/numfields.rs`; estimates unchanged. | | 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. | | 2026-11-11 | minor | G5: built-in Status Effects graphic style library (Poison Tint, Freeze Edge, Burn Aura, Stun Pulse, Stealth Fade, Shield Buff); Missing trimmed; estimate 7–17 → 6–16 h; MCP docs list UI Frames and Map Markers under Symbol libraries. | | 2026-11-11 | minor | G5: built-in Inventory Icons symbol library (Sword, Shield, Potion, Key, Gem; no default 9-slice); Missing trimmed; estimate 8–18 → 7–17 h. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 44d0b19a4e..964ebdb039 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -283,7 +283,7 @@ tools, never checked side by side) isn't captured row by row. | Number fields: Up/Down step, Shift ×10, Cmd/Ctrl ÷10, wheel over a hovered field without a click; steps, the wheel and label scrubs stop at the field's bounds (a stroke weight at 0, a miter limit at 1) | done | Shift+arrow in the Stroke weight field doesn't step by 10 (#991); bounds #3370. The field takes the wheel only when the pointer was moved onto it after the field last moved or was covered by a window or overlay, and the wheel stream began there; a stream that began elsewhere stays there until 0.5 s pass with no wheel input or the pointer is pressed on a field, so a panel scrolled under a still pointer keeps scrolling past the field. This rule and its 0.5 s are VectorCraft's choices. | | Numeric spinner arrows step once on a click and keep repeating while held with the mouse | done | #6307; headless test `a_held_spinner_arrow_repeats` holds past egui's click timeout and checks release. | | Arithmetic in fields (`+ - * /`, and `%` of the value before it: `3cm*50%`, `50pt+25%`) and unit suffixes (`3 mm`, `1in`, `2p6`) | done | `Unit::parse`; mixed units in one expression not verified | -| Numeric fields: focus click selects the whole value; dragging selects from the pressed character | partial | #1326; select-all waits for click release; px/pt/plain/optional fields, both directions and replacement text tested; native API input, 17 px partial replacement, Undo/Redo, saved-file round trip and screenshots verified; Tab, double-click, arrows and wheel preserved; short movements still classified as clicks can select all and remain open | +| Numeric fields: focus click selects the whole value; dragging selects from the pressed character | partial | #1326; select-all waits for click release; px/pt/plain/optional fields, both directions and replacement text tested; native API input, 17 px partial replacement, Undo/Redo, saved-file round trip and screenshots verified; Tab, double-click, arrows and wheel preserved; a release more than 1 pt from the press keeps the caret, a tap within 1 pt selects all (#6659) | | Units: pt, pc, in, mm, cm, px, ft-in, m, yd, ft; General/Stroke/Type units | done | units preference reverting to points on SVG output reported (#864). A file that records no units (PDF, EPS or `.ai` without ruler units, raster images, metafiles, Affinity files) opens and reverts in General units; native, SVG and DXF files and a `.ai` or EPS with ruler units keep theirs, as a [Community Expert's answer](https://community.adobe.com/questions-652/change-units-from-points-to-inches-won-t-stick-765372) describes (#5820) | | Units › East Asian Type (Q and Ha) | done | #394: `unitsAsianType` offers every unit and Q and Ha, a quarter of a millimetre each ([Scripting Guide's Measurement Units](https://ai-scripting.docsforadobe.dev/scripting/measurementUnits/): 0.709 pt per Q; [InDesign's unit notation](https://helpx.adobe.com/in/indesign/desktop/layout-and-grid-tools/rulers-and-measure-tools/change-ruler-measurement-units.html): `q`, `h`); only it lists Q and Ha. The public pages do not say which fields use it, so no field uses it yet and that waits for a source. Tests `q_and_ha_are_a_quarter_millimetre`, `east_asian_type_unit_*` | | Field precision: 3 decimals for steps; values kept as f64 | done | | @@ -511,6 +511,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | Numeric fields: a focus click released more than 1 pt from its press keeps the caret; a tap within 1 pt still selects the whole value (#6659, follow-up to #1326). Scores and estimates unchanged. | | 2026-11-11 | minor | Outline view: a click on a path's outline selects it through an image or a gradient mesh in front, which Outline draws as a frame or mesh lines (#6437). | | 2026-11-11 | minor | A middle-click on a document tab (or its ×) closes it as the × does; closing a document's last view with unsaved changes asks to save first (#6506). VectorCraft addition; scores and estimates unchanged. | | 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. |