From 13dfcdd142e9f630c60b91983d4472a07f807319 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 10:42:33 -0800 Subject: [PATCH 01/52] Tools: commands return the error when a tool cannot be chosen tool.toggleTouchMode reported success with the old tool when the tool switch failed, and it had already stored the outgoing tool as the drawing tool to return to. It now returns the error, and the drawing tool changes only after the switch succeeds. tool.select, ui.cropImage and the Path Precision Tools' Corner Tool and Shared Edge Merge Tool commands also return the error instead of reporting success, and the two Path Precision commands no longer reveal the Anchor Inspector after a failed choice. A failed choice no longer stores the requested tool as its toolbar group's tool. Toolbar clicks, shortcuts and other UI paths still show the error in the status bar. Docs: docs/mcp.md, docs/target-app-parity.md and the command params text. --- crates/ui-egui/src/addons/pathtools/corner.rs | 2 +- .../src/addons/pathtools/shared_edge.rs | 2 +- crates/ui-egui/src/app.rs | 2 + crates/ui-egui/src/app/commands.rs | 42 ++++++++++---- crates/ui-egui/src/canvas/tests.rs | 58 +++++++++++++++++++ crates/ui-egui/src/lib.rs | 4 ++ crates/ui-egui/src/menus/run.rs | 12 +--- crates/ui-egui/src/menus/ui_commands.rs | 6 +- crates/ui-egui/src/touch.rs | 21 +++---- docs/mcp.md | 5 +- docs/target-app-parity.md | 1 + 11 files changed, 120 insertions(+), 35 deletions(-) diff --git a/crates/ui-egui/src/addons/pathtools/corner.rs b/crates/ui-egui/src/addons/pathtools/corner.rs index ace9be954c..58cc95882d 100644 --- a/crates/ui-egui/src/addons/pathtools/corner.rs +++ b/crates/ui-egui/src/addons/pathtools/corner.rs @@ -19,7 +19,7 @@ const FIELD_W: f32 = 96.0; /// Object › Plug-ins › Path Precision Tools › Corner Tool. pub fn open(app: &mut VectorcraftApp) -> Result { - app.select_tool(ID); + app.try_select_tool(ID)?; crate::panel_docking::reveal_panel(app, super::ID) } diff --git a/crates/ui-egui/src/addons/pathtools/shared_edge.rs b/crates/ui-egui/src/addons/pathtools/shared_edge.rs index d682aad776..57254e1aec 100644 --- a/crates/ui-egui/src/addons/pathtools/shared_edge.rs +++ b/crates/ui-egui/src/addons/pathtools/shared_edge.rs @@ -17,7 +17,7 @@ const LABEL_W: f32 = 80.0; /// Object › Plug-ins › Path Precision Tools › Shared Edge Merge Tool. pub fn open(app: &mut VectorcraftApp) -> Result { - app.select_tool(ID); + app.try_select_tool(ID)?; crate::panel_docking::reveal_panel(app, super::ID) } diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 8c8a153a94..186a7891ab 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -181,6 +181,8 @@ impl VectorcraftApp { host_iteration: 0, #[cfg(test)] now_override: None, + #[cfg(test)] + fail_next_tool_choice: None, tick_iteration: None, input_frames: vec![], shortcuts_frames: vec![], diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 50c7b1be30..6000bb8a24 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -406,34 +406,54 @@ impl VectorcraftApp { } } - /// Select a tool (also used by the toolbar and shortcuts). + /// Select a tool (also used by the toolbar and shortcuts). A choice that fails shows its error + /// in the status bar. pub fn select_tool(&mut self, id: &str) { + if let Err(e) = self.try_select_tool(id) { + self.ui.status = e; + } + } + + /// Select a tool for a command ([`Self::select_tool`]): a choice that fails returns its error, + /// and the tool remembered for the toolbar group and for the Touch workspace's drawing mode + /// stays as it was. + pub(crate) fn try_select_tool(&mut self, id: &str) -> Result<(), String> { // The tip of a pen whose eraser end selected the Eraser doesn't undo this choice, and // neither does letting go of a held tool shortcut. self.pen_end.forget_tool(); self.held_tool.clear(); - self.activate_tool(id, true); + self.activate_tool(id, true)?; let now = self.session.tool_id(); if crate::touch::is_drawing_tool(now) { self.ui.touch_drawing_tool = Some(now.to_string()); } + Ok(()) } /// Go back to tool `id` after a tool that stood in for it (the pen's eraser end, [`stylus`]): - /// a perspective grid hidden meanwhile stays hidden. + /// a perspective grid hidden meanwhile stays hidden. A switch that fails shows its error in the + /// status bar. pub(crate) fn return_to_tool(&mut self, id: &str) { - self.activate_tool(id, false); + if let Err(e) = self.activate_tool(id, false) { + self.ui.status = e; + } } /// Make tool `id` active: chosen afresh (`fresh`, [`vectorcraft_engine::Session::select_tool`]), /// or back from a tool that stood in for it ([`vectorcraft_engine::Session::switch_tool`]). - fn activate_tool(&mut self, id: &str, fresh: bool) { + /// The toolbar group remembers `id` only when the switch succeeds. + fn activate_tool(&mut self, id: &str, fresh: bool) -> Result<(), String> { let v = self.view_info(); let before = self.session.tool_id(); - let switched = if fresh { self.session.select_tool(id, v) } else { self.session.switch_tool(id, v) }; - if let Err(e) = switched { - self.ui.status = e.to_string(); - } + #[cfg(test)] + let failed = self.fail_next_tool_choice.take(); + #[cfg(not(test))] + let failed: Option = None; + let switched = match failed { + Some(e) => Err(e), + None if fresh => self.session.select_tool(id, v).map_err(|e| e.to_string()), + None => self.session.switch_tool(id, v).map_err(|e| e.to_string()), + }; if before != self.session.tool_id() { // A control gesture cannot resume after another tool has owned the pointer. self.control_rotate_press = None; @@ -445,13 +465,15 @@ impl VectorcraftApp { { panels::artboards::select(self, panels::artboards::selected(self, n)); } + self.ui.flyout = None; + switched?; if let Some(g) = vectorcraft_tools::catalog::group_of(id) && let Some(slot) = self.ui.group_tool.get_mut(g) { *slot = id.to_string(); } toolbar::remember(self, id); - self.ui.flyout = None; + Ok(()) } /// Show a transient status message. diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 6bf8a6fa97..38d3163ef3 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -1051,6 +1051,64 @@ fn switch_selection_and_drawing_modes_command() { } } +/// A Switch Selection and Drawing Modes whose tool choice fails returns the error, in both +/// directions: the tool stays, the drawing tool to return to stays, and no undo step is added. +#[test] +fn a_failed_mode_switch_returns_its_error() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.session.execute("shape.rectangle", &json!({"x": 40, "y": 40, "width": 30, "height": 20})).unwrap(); + let undo = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len(); + let steps = undo(&app); + app.select_tool("rectangle"); + app.ui.touch_drawing_tool = Some("pen".into()); + app.fail_next_tool_choice = Some("no switch".into()); + let r = app.run(crate::touch::TOGGLE_MODE, json!({})); + assert!(r.as_ref().is_err_and(|e| e.contains("no switch")), "{r:?}"); + assert_eq!((app.session.tool_id(), app.ui.touch_drawing_tool.as_deref(), undo(&app)), ("rectangle", Some("pen"), steps), "drawing mode"); + app.select_tool("selection"); + app.fail_next_tool_choice = Some("no switch".into()); + let r = app.run(crate::touch::TOGGLE_MODE, json!({})); + assert!(r.as_ref().is_err_and(|e| e.contains("no switch")), "{r:?}"); + assert_eq!((app.session.tool_id(), app.ui.touch_drawing_tool.as_deref(), undo(&app)), ("selection", Some("pen"), steps), "selection mode"); + // The next switch works again. + assert_eq!(app.run(crate::touch::TOGGLE_MODE, json!({})).unwrap(), json!({"tool": "pen"})); + assert_eq!(undo(&app), steps); +} + +/// The commands that choose a tool (Select Tool, Crop Image, and the Path Precision Tools' Corner +/// Tool and Shared Edge Merge Tool) return the error when the choice fails, and the tool and the +/// toolbar group's tool stay as they were. +#[test] +fn tool_commands_return_a_failed_choice() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let img = image::RgbaImage::from_pixel(20, 10, image::Rgba([200, 120, 40, 255])); + let mut png = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let data = vectorcraft_format::base64_encode(&png); + app.run("file.place", json!({"name": "texture.png", "dataBase64": data, "link": false, "at": [150, 150]})).unwrap(); + app.select_tool("selection"); + let group = vectorcraft_tools::catalog::group_of("ellipse").unwrap(); + let commands = [ + ("tool.select", json!({"tool": "ellipse"})), + ("ui.cropImage", json!({})), + (crate::addons::pathtools::corner::COMMAND, json!({})), + (crate::addons::pathtools::shared_edge::COMMAND, json!({})), + ]; + for (command, params) in commands { + let slot = app.ui.group_tool[group].clone(); + app.fail_next_tool_choice = Some("no switch".into()); + let r = app.run(command, params.clone()); + assert!(r.as_ref().is_err_and(|e| e.contains("no switch")), "{command}: {r:?}"); + assert_eq!(app.session.tool_id(), "selection", "{command}"); + assert_eq!(app.ui.group_tool[group], slot, "{command}"); + app.run(command, params).unwrap(); + assert_ne!(app.session.tool_id(), "selection", "{command} works again"); + app.select_tool("selection"); + } +} + /// The Artboard tool's label holds the artboard's name (never translated); the tools' own /// labels are interface text. #[test] diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c527991ed0..3019886391 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -339,6 +339,10 @@ pub struct VectorcraftApp { /// can be driven one simulated frame at a time (production always leaves this `None`). #[cfg(test)] pub(crate) now_override: Option, + /// An error the next tool choice returns before the engine sees it ([`Self::try_select_tool`]), + /// so tests can drive a failed switch (production has no such field). + #[cfg(test)] + pub(crate) fail_next_tool_choice: Option, /// The host iteration [`Self::tick`] ran in (`None`: never): once per iteration, never per pass. tick_iteration: Option, /// The context, viewport and frame whose UI-phase input was already taken in this host diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index efa4e95ac4..0d46481841 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -677,14 +677,11 @@ fn run_ui_part6(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::chrome::step_document(app, true), "window.previousDocument" => crate::chrome::step_document(app, false), "tool.select" => match s("tool") { - Some(t) if vectorcraft_tools::tool_info(&t).is_some() => { - app.select_tool(&t); - Ok(json!({"tool": app.session.tool_id()})) - } + Some(t) if vectorcraft_tools::tool_info(&t).is_some() => app.try_select_tool(&t).map(|()| json!({"tool": app.session.tool_id()})), Some(t) => Err(format!("unknown tool `{t}`")), None => Err("missing `tool`".into()), }, - crate::touch::TOGGLE_MODE => Ok(crate::touch::toggle_mode(app)), + crate::touch::TOGGLE_MODE => crate::touch::toggle_mode(app), crate::addons::pathtools::corner::COMMAND => crate::addons::pathtools::corner::open(app), crate::addons::pathtools::shared_edge::COMMAND => crate::addons::pathtools::shared_edge::open(app), crate::addons::arrange::ZOOM => crate::addons::arrange::zoom(app), @@ -696,10 +693,7 @@ fn run_ui_part6(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::dialogs::open_effect_dialog(app, p), "ui.recolorDialog" => crate::dialogs::recolor::open(app, p), "ui.cropImage" if !selected_image(app, |_| true) => Err("select an image".into()), - "ui.cropImage" => { - app.select_tool(vectorcraft_tools::cropimage::ID); - Ok(json!({"tool": app.session.tool_id()})) - } + "ui.cropImage" => app.try_select_tool(vectorcraft_tools::cropimage::ID).map(|()| json!({"tool": app.session.tool_id()})), "ui.paramDialog" => { let cmd = s("command").unwrap_or_default(); let mut fields = p.get("params").and_then(Value::as_object).cloned().unwrap_or_default(); diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 5f6da1ca36..2d7b157706 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -771,7 +771,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "Cmd+Shift+F6", "{} make the open document before the current one in tab order the current one, as a click on its tab does (before the first document it goes to the last, and from the Home screen to the last); each view window.newWindow opened is a step of its own; returns {index}, with `view` for a document with more than one view", ), - ("tool.select", "Select Tool", "", "{tool: id} (see tools)"), + ("tool.select", "Select Tool", "", "{tool: id} (see tools) → {tool}; a tool that cannot be chosen returns the error"), ( "tool.setOption", "Tool Option", @@ -782,7 +782,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ crate::touch::TOGGLE_MODE, "Switch Selection and Drawing Modes", "", - "{} from the Selection, Direct Selection, Group Selection, Magic Wand, Lasso or Perspective Selection tool, choose the drawing tool chosen last in this window (none yet: the Pencil tool); from any other tool, choose the Selection tool, remembering the tool left as the drawing tool unless it is the Hand, Rotate View, Zoom or Print Tiling tool → {tool}. A two-finger tap on the canvas runs it in the Touch workspace (Enable Touch Gestures on)", + "{} from the Selection, Direct Selection, Group Selection, Magic Wand, Lasso or Perspective Selection tool, choose the drawing tool chosen last in this window (none yet: the Pencil tool); from any other tool, choose the Selection tool, remembering the tool left as the drawing tool unless it is the Hand, Rotate View, Zoom or Print Tiling tool → {tool}; a tool that cannot be chosen returns the error and leaves the drawing tool as it was. A two-finger tap on the canvas runs it in the Touch workspace (Enable Touch Gestures on)", ), ( "effect.dialog", @@ -801,7 +801,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.cropImage", "Crop Image", "", - "{} show a crop box on the selected image (tool `cropImage`): it starts on the part over the image's artboard; drag its handles or inside it, then Enter or the Control bar's Apply crops the image to it (object.cropImage {rect}), Escape or Cancel leaves it as it is, both back to the Selection tool. tool.setOption {key: \"rect\", value: [x, y, width, height]} sets the box", + "{} show a crop box on the selected image (tool `cropImage`): it starts on the part over the image's artboard; drag its handles or inside it, then Enter or the Control bar's Apply crops the image to it (object.cropImage {rect}), Escape or Cancel leaves it as it is, both back to the Selection tool. tool.setOption {key: \"rect\", value: [x, y, width, height]} sets the box. → {tool}; a tool that cannot be chosen returns the error", ), ("effect.applyLast", "Apply Last Effect", "Cmd+Shift+E", "{}"), ( diff --git a/crates/ui-egui/src/touch.rs b/crates/ui-egui/src/touch.rs index 7cd1a4ad76..48db775c31 100644 --- a/crates/ui-egui/src/touch.rs +++ b/crates/ui-egui/src/touch.rs @@ -153,19 +153,20 @@ pub(crate) fn is_drawing_tool(id: &str) -> bool { /// [`TOGGLE_MODE`]: from a tool of selection mode, choose the drawing tool chosen last (none yet: /// [`FIRST_DRAWING_TOOL`]); from any other tool, keep it as the drawing tool and choose the -/// Selection tool. → `{tool}`, the tool now active. -pub(crate) fn toggle_mode(app: &mut crate::VectorcraftApp) -> serde_json::Value { +/// Selection tool. → `{tool}`, the tool now active. A switch that fails returns its error and +/// leaves the drawing tool to return to as it was. +pub(crate) fn toggle_mode(app: &mut crate::VectorcraftApp) -> Result { let now = app.session.tool_id().to_string(); - let next = if selects(&now) { - app.ui.touch_drawing_tool.clone().unwrap_or_else(|| FIRST_DRAWING_TOOL.to_string()) + let (next, outgoing) = if selects(&now) { + (app.ui.touch_drawing_tool.clone().unwrap_or_else(|| FIRST_DRAWING_TOOL.to_string()), None) } else { - if is_drawing_tool(&now) { - app.ui.touch_drawing_tool = Some(now); - } - "selection".to_string() + ("selection".to_string(), is_drawing_tool(&now).then_some(now)) }; - app.select_tool(&next); - serde_json::json!({"tool": app.session.tool_id()}) + app.try_select_tool(&next)?; + if let Some(tool) = outgoing { + app.ui.touch_drawing_tool = Some(tool); + } + Ok(serde_json::json!({"tool": app.session.tool_id()})) } #[cfg(test)] diff --git a/docs/mcp.md b/docs/mcp.md index 6800d7d7e5..b7c0584968 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5735,7 +5735,10 @@ desktop app) instead and undoes nothing ([the help page](https://helpx.adobe.com From the Selection, Direct Selection, Group Selection, Magic Wand, Lasso or Perspective Selection tool it chooses the drawing tool chosen last in the window, or the Pencil tool when none has been chosen yet; from any other tool it chooses the Selection tool, and the tool left becomes the drawing tool unless it is the Hand, Rotate View, Zoom or Print -Tiling tool. It returns `{tool}` and adds no undo step. Pencil as the first drawing tool and the list of selection tools +Tiling tool. It returns `{tool}` and adds no undo step. When the tool cannot be chosen it returns the error, the tool +and the drawing tool stay as they were, and it adds no undo step; `tool.select`, `ui.cropImage`, +`addon.pathtools.cornerTool` and `addon.pathtools.sharedEdgeTool` also return the error when their tool cannot be +chosen. Pencil as the first drawing tool and the list of selection tools are VectorCraft's own rules. A tap in Presentation Mode runs nothing. `gridlineEvery` (72 pt by default) and `gridSubdivisions` (8 by default) set the document grid that View › Show Grid diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c50a45b639..0ba0a72c9b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1188,6 +1188,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A two-finger rotation on a Mac trackpad turns the view about the pointer once it reaches 5° (Preferences › General › Trackpad Gesture to Rotate View, on by default). On a touch screen a second finger cancels the tool press the first finger began, in the frame it lands or later: the Pencil's stroke or a Selection move is rolled back with no undo entry, and a finger that lands while another is down presses nothing, so a pinch or two-finger pan only zooms and pans (#4198; canvas test `a_second_finger_cancels_the_tool_gesture_the_first_began`). In the Touch workspace a two-finger tap switches between the Selection tool and the drawing tool chosen last (the Pencil tool when none has been chosen yet) and undoes nothing; outside Touch it still undoes, and a three-finger tap still redoes ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/touch-workspace-in-illustrator.html); `tool.toggleTouchMode`; canvas test `two_finger_tap_in_touch_switches_selection_and_drawing_modes`). + When the tool cannot be chosen, `tool.toggleTouchMode` returns the error instead of `{tool}`, keeps the drawing tool it returns to, and adds no undo step; `tool.select`, `ui.cropImage` and the Path Precision Tools' Corner Tool and Shared Edge Merge Tool commands also return the error, and the toolbar group keeps its tool (canvas tests `a_failed_mode_switch_returns_its_error` and `tool_commands_return_a_failed_choice`). A tool press dropped by a second finger, a long touch or a canceled touch cancels the tool's drag as Escape does: the tool gets back the options it had when the press began (the Polygon tool's sides, set with ↑ and ↓) and they are not stored, a press held still opens no size dialog, a Selection or Artboard tool marquee selects nothing, and the Pen goes on drawing its path without the dropped anchor (canvas tests `a_canceled_touch_drops_the_drag_without_keeping_its_options` and `long_touch_drops_a_tool_press_without_committing_art`; engine tests in `tests::toolsettings`). The status bar navigator's field shows the active artboard's number and name, and its drop-down lists every artboard by number and name, as the Artboards panel does. Choosing an artboard in the list makes it the active artboard and fits it in the window (`view.goToArtboard`). The list opens on the active artboard's row and lays out only the rows in view and at most one more. - **Geometry and effects:** From f1cef17323980d2cdc3a9916d46d3d0add5bdcbd Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 11:42:00 -0800 Subject: [PATCH 02/52] MCP: regenerate the UI command catalog for the tool error text crates/mcp/src/ui_catalog.rs now carries the tool.select, tool.toggleTouchMode and ui.cropImage params text from crates/ui-egui/src/menus/ui_commands.rs, so `cargo xtask ui-commands --check` passes. --- crates/mcp/src/ui_catalog.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index df2627dfc2..66c9fd3bce 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -770,7 +770,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "Cmd+Shift+F6", "{} make the open document before the current one in tab order the current one, as a click on its tab does (before the first document it goes to the last, and from the Home screen to the last); each view window.newWindow opened is a step of its own; returns {index}, with `view` for a document with more than one view", ), - ("tool.select", "Select Tool", "", "{tool: id} (see tools)"), + ("tool.select", "Select Tool", "", "{tool: id} (see tools) → {tool}; a tool that cannot be chosen returns the error"), ( "tool.setOption", "Tool Option", @@ -781,7 +781,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "tool.toggleTouchMode", "Switch Selection and Drawing Modes", "", - "{} from the Selection, Direct Selection, Group Selection, Magic Wand, Lasso or Perspective Selection tool, choose the drawing tool chosen last in this window (none yet: the Pencil tool); from any other tool, choose the Selection tool, remembering the tool left as the drawing tool unless it is the Hand, Rotate View, Zoom or Print Tiling tool → {tool}. A two-finger tap on the canvas runs it in the Touch workspace (Enable Touch Gestures on)", + "{} from the Selection, Direct Selection, Group Selection, Magic Wand, Lasso or Perspective Selection tool, choose the drawing tool chosen last in this window (none yet: the Pencil tool); from any other tool, choose the Selection tool, remembering the tool left as the drawing tool unless it is the Hand, Rotate View, Zoom or Print Tiling tool → {tool}; a tool that cannot be chosen returns the error and leaves the drawing tool as it was. A two-finger tap on the canvas runs it in the Touch workspace (Enable Touch Gestures on)", ), ( "effect.dialog", @@ -800,7 +800,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.cropImage", "Crop Image", "", - "{} show a crop box on the selected image (tool `cropImage`): it starts on the part over the image's artboard; drag its handles or inside it, then Enter or the Control bar's Apply crops the image to it (object.cropImage {rect}), Escape or Cancel leaves it as it is, both back to the Selection tool. tool.setOption {key: \"rect\", value: [x, y, width, height]} sets the box", + "{} show a crop box on the selected image (tool `cropImage`): it starts on the part over the image's artboard; drag its handles or inside it, then Enter or the Control bar's Apply crops the image to it (object.cropImage {rect}), Escape or Cancel leaves it as it is, both back to the Selection tool. tool.setOption {key: \"rect\", value: [x, y, width, height]} sets the box. → {tool}; a tool that cannot be chosen returns the error", ), ("effect.applyLast", "Apply Last Effect", "Cmd+Shift+E", "{}"), ( From 01043c05cd0b33d7ee48a9b0b25b0a4f552317d5 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 17:19:44 -0800 Subject: [PATCH 03/52] Graphs: paint, stroke and transparency commands leave a design's art in a series out The graph draws the art of a column or marker design in a series again from the design when its data, type or designs change, and an edit made to that art is lost. - stroke.set, stroke.weightUp, stroke.weightDown, stroke.setAdvanced, paint.editGradient, paint.setGradientGeom, object.setOverprint, paint.swap, paint.default, and paint.invert and paint.complement with an item leave the art out and edit the other objects given. - transparency.set without an item, the paint.freeform.* commands that edit points and lines, graphicStyle.breakLink and effect.expandAppearance are an error when all their objects are design art. Expand Appearance is disabled for a selection of only design art, and it leaves the design art inside a selected graph or series group as it was. - When the first selected object is design art, the commands do not use the Appearance panel's active row, which is a row of that art. - The error names the command that ran. docs/mcp.md lists the commands. The params texts of the appearance.* commands and graphicStyle.apply name the rule, and the Graph column designs entry in docs/target-app-parity.md states it. --- crates/engine/src/cmd/appearance.rs | 78 ++++++---- crates/engine/src/cmd/effectcmd.rs | 40 ++++- crates/engine/src/cmd/effectcmd/graphics.rs | 2 +- crates/engine/src/cmd/freeform.rs | 3 +- crates/engine/src/cmd/gradient.rs | 3 +- crates/engine/src/cmd/graph/tests.rs | 154 ++++++++++++++++++++ crates/engine/src/cmd/overprint.rs | 2 +- crates/engine/src/cmd/paint.rs | 11 +- crates/engine/src/cmd/stroke.rs | 5 +- crates/engine/src/cmd/style.rs | 6 +- crates/engine/src/cmd/stylelib.rs | 2 +- docs/mcp.md | 25 ++-- docs/target-app-parity.md | 1 + 13 files changed, 276 insertions(+), 56 deletions(-) diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 02bc111746..67534ac500 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -48,7 +48,7 @@ pub fn specs() -> Vec { "Add New Fill", ["Window", "Appearance"], Some("Cmd+/"), - "{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack, on an image right below its Contents row, with its top fill's paint (type without one: its characters' fill; else the default fill). With a fill row active (appearance.setActiveItem), no `ids`, no `id` and no `target: \"contents\"`, the new fill takes that row's paint, goes right above it, and becomes the active row; appearance.duplicateItem copies a whole row. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", + "{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack, on an image right below its Contents row, with its top fill's paint (type without one: its characters' fill; else the default fill). With a fill row active (appearance.setActiveItem), no `ids`, no `id` and no `target: \"contents\"`, the new fill takes that row's paint, goes right above it, and becomes the active row; appearance.duplicateItem copies a whole row. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", has_doc, |s, p| add_item(s, p, true) ), @@ -57,7 +57,7 @@ pub fn specs() -> Vec { "Add New Stroke", ["Window", "Appearance"], Some("Cmd+Alt+/"), - "{ids?|id?, target?} add a stroke on top of each selected object's own stack, on an image right below its Contents row, with its top stroke's paint (else the default stroke) and that stroke's weight when it has paint, at least 1 pt. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", + "{ids?|id?, target?} add a stroke on top of each selected object's own stack, on an image right below its Contents row, with its top stroke's paint (else the default stroke) and that stroke's weight when it has paint, at least 1 pt. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", has_doc, |s, p| add_item(s, p, false) ), @@ -66,7 +66,7 @@ pub fn specs() -> Vec { "Clear Appearance", ["Window", "Appearance"], None, - "{ids?|id?, target?} leave each object one empty fill and stroke (None; a group none of its own) and reset its opacity and blend mode. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", + "{ids?|id?, target?} leave each object one empty fill and stroke (None; a group none of its own) and reset its opacity and blend mode. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", has_doc, clear_appearance ), @@ -75,7 +75,7 @@ pub fn specs() -> Vec { "Reduce to Basic Appearance", ["Window", "Appearance"], None, - "{ids?|id?, target?} keep only one visible fill and stroke (without ids: the Appearance panel's active row in place of the topmost one of its kind; else the topmost), without their own opacity, blend mode or effects (a group keeps no rows it lacked), and drop the object's effects. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", + "{ids?|id?, target?} keep only one visible fill and stroke (without ids: the Appearance panel's active row in place of the topmost one of its kind; else the topmost), without their own opacity, blend mode or effects (a group keeps no rows it lacked), and drop the object's effects. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", has_doc, reduce_basic ), @@ -84,7 +84,7 @@ pub fn specs() -> Vec { "Appearance Item", [], None, - "{index: paint-order item index, ids?|id? (default: the selection), target?: \"object\"|\"contents\", opacity?: 0..100, blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill), tint?: 0..100, keepModel?: bool (both as paint.setFill), aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} edit one fill/stroke of each target object's own appearance stack. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", + "{index: paint-order item index, ids?|id? (default: the selection), target?: \"object\"|\"contents\", opacity?: 0..100, blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill), tint?: 0..100, keepModel?: bool (both as paint.setFill), aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} edit one fill/stroke of each target object's own appearance stack. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", has_doc, set_item ), @@ -93,7 +93,7 @@ pub fn specs() -> Vec { "Remove Item", [], None, - "{index | indices: [..] (paint-order item indices), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} remove those fills/strokes from each target object's own stack. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", + "{index | indices: [..] (paint-order item indices), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} remove those fills/strokes from each target object's own stack. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", has_doc, remove_item ), @@ -111,7 +111,7 @@ pub fn specs() -> Vec { "Duplicate Item", ["Window", "Appearance"], None, - "{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (with several, of the lowest copy: they land together in their order; default: each right above its item; not with aligned rows of a mixed selection), contents?: count (with `to`: how many items paint below the Contents (Characters) row afterwards; default: it stays between the same items), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} copy fills/strokes with their effects, as Alt-dragging rows places them. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", + "{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (with several, of the lowest copy: they land together in their order; default: each right above its item; not with aligned rows of a mixed selection), contents?: count (with `to`: how many items paint below the Contents (Characters) row afterwards; default: it stays between the same items), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} copy fills/strokes with their effects, as Alt-dragging rows places them. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", has_doc, duplicate_item ), @@ -120,7 +120,7 @@ pub fn specs() -> Vec { "Reorder Appearance Item", [], None, - "{from: paint-order item index | \"contents\", indices?: [..] (several items instead of from: they move as one block in their order, the lowest landing at `to`; a block landing next to the Contents row keeps its lowest item's side), to, contents?: count, ids?|id?, target?, aligned?: bool (refused when the targets' appearances differ)} move fill/stroke `from` to paint-order index `to`; on a group, layer or type the Contents (Characters) row stays between the same items (a moved item landing next to it keeps its side) unless `contents` says how many items paint below it afterwards. from \"contents\": move that row so `to` items paint below the members (characters) and the rest above. On a path directly in a graph series (a series group's id stands for those paths, except with from \"contents\", which moves the group's own row), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {index: where the item landed | contents: the row's slot}", + "{from: paint-order item index | \"contents\", indices?: [..] (several items instead of from: they move as one block in their order, the lowest landing at `to`; a block landing next to the Contents row keeps its lowest item's side), to, contents?: count, ids?|id?, target?, aligned?: bool (refused when the targets' appearances differ)} move fill/stroke `from` to paint-order index `to`; on a group, layer or type the Contents (Characters) row stays between the same items (a moved item landing next to it keeps its side) unless `contents` says how many items paint below it afterwards. from \"contents\": move that row so `to` items paint below the members (characters) and the rest above. On a path directly in a graph series (a series group's id stands for those paths, except with from \"contents\", which moves the group's own row), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {index: where the item landed | contents: the row's slot}", has_doc, move_item ), @@ -129,7 +129,7 @@ pub fn specs() -> Vec { "Eyedropper", [], None, - "{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes using effective character dimensions while preserving target transforms) from `source` to ids (default: highlighted characters while editing type, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art); with an object of a dynamic symbol instance selected by Direct Selection and no ids, reverse, append or defaultsOnly, that object takes the source's fill and stroke (solid, none, gradient or pattern) for this instance only, as the Eyedropper Options allow, and a source that is not a path, compound path or type, or options that pick up neither color, are refused. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md); on a graph label, the graph stores, for every label of its kind, the character attributes the pick gives all of its text, its object opacity and blend mode, and with `append` its topmost visible fill and stroke rows → {ids}", + "{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes using effective character dimensions while preserving target transforms) from `source` to ids (default: highlighted characters while editing type, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art); with an object of a dynamic symbol instance selected by Direct Selection and no ids, reverse, append or defaultsOnly, that object takes the source's fill and stroke (solid, none, gradient or pattern) for this instance only, as the Eyedropper Options allow, and a source that is not a path, compound path or type, or options that pick up neither color, are refused. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores, for every label of its kind, the character attributes the pick gives all of its text, its object opacity and blend mode, and with `append` its topmost visible fill and stroke rows → {ids}", has_doc, copy_from ), @@ -147,7 +147,7 @@ pub fn specs() -> Vec { "Show All Hidden Attributes", ["Window", "Appearance"], None, - "{ids?|id?, target?} make every hidden fill, stroke and effect (the object's and each item's) of each selected object visible again; errors when nothing is hidden. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {ids}", + "{ids?|id?, target?} make every hidden fill, stroke and effect (the object's and each item's) of each selected object visible again; errors when nothing is hidden. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {ids}", has_doc, show_all_hidden ), @@ -165,7 +165,7 @@ pub fn specs() -> Vec { "Move Appearance", [], None, - "{source: id, target: id, copy?: bool} give `target` (a layer, group or object) the appearance (fills, strokes, effects) and transparency (opacity, blend mode, isolation, knockout) of `source`, as dragging a target circle onto another in the Layers panel does; the source is left with a cleared appearance (as appearance.clear) unless `copy` (Alt-drag). Opacity masks stay; placed gradients keep their place relative to each object's bounds. On a path directly in a graph series (a series group's id stands for those paths; as `source`, when the group has no effect, opacity, blend mode, isolation, knockout or style link of its own, the topmost one is read and a move clears them all; as `target` each takes the appearance and the group is cleared, a `source` among them keeps its look, and a `source` that is the group's only path is an error), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {source, target}", + "{source: id, target: id, copy?: bool} give `target` (a layer, group or object) the appearance (fills, strokes, effects) and transparency (opacity, blend mode, isolation, knockout) of `source`, as dragging a target circle onto another in the Layers panel does; the source is left with a cleared appearance (as appearance.clear) unless `copy` (Alt-drag). Opacity masks stay; placed gradients keep their place relative to each object's bounds. On a path directly in a graph series (a series group's id stands for those paths; as `source`, when the group has no effect, opacity, blend mode, isolation, knockout or style link of its own, the topmost one is read and a move clears them all; as `target` each takes the appearance and the group is cleared, a `source` among them keeps its look, and a `source` that is the group's only path is an error), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {source, target}", has_doc, transfer ), @@ -183,7 +183,7 @@ pub(crate) struct ActiveItem { impl Session { /// The method returns explicit target parameters and the selected appearance effect list. pub fn appearance_effect_target(&self, params: &Value) -> Option<(Value, &[vectorcraft_doc::Effect])> { - let mut ids = appearance_targets(self, params).ok()?; + let mut ids = drop_design_art(&self.active()?.doc, appearance_targets(self, params).ok()?); let item = item_target(self, params, "effect.apply").ok()?; let anchor = item.anchor(self, &ids); let node = self.active()?.doc.node(*ids.get(anchor)?)?; @@ -253,6 +253,10 @@ pub(crate) fn item_target_at(s: &Session, p: &Value, key: &str, cmd: &str) -> Re .ok_or_else(|| bad(cmd, format!("`{key}` must be an appearance item index (paint order) or null"))), // The active item is a row of the first selected object's own stack. None if p.get("ids").is_some() || p.get("id").is_some() || targets_contents(p)? => Ok(ItemTarget::Top), + // A row of a graph design's art in a series stands for nothing: the commands leave that art out. + None if s.active().and_then(|st| st.selection.subjects().first().map(|id| super::graph::is_design_art(&st.doc, *id))) == Some(true) => { + Ok(ItemTarget::Top) + } None => Ok(s.appearance_item().map_or(ItemTarget::Top, |index| ItemTarget::Item { index, explicit: false, aligned: true })), } } @@ -424,22 +428,44 @@ const DESIGN_ART: &str = "the art of a graph design in a graph series is drawn a /// The [`appearance_targets`] whose rows and effects a command edits: the art of graph designs drawn in graph series /// ([`super::graph::is_design_art`]) is left out, as the graph draws it again from the design. A command whose targets -/// are all such art is refused. -pub(crate) fn edit_targets(s: &Session, p: &Value) -> Result> { +/// are all such art is refused as an error of `cmd`. +pub(crate) fn edit_targets(s: &Session, p: &Value, cmd: &str) -> Result> { let ids = appearance_targets(s, p)?; - without_design_art(&s.doc()?.doc, ids, "appearance") + without_design_art(&s.doc()?.doc, ids, cmd) } /// `ids` without the art of graph designs drawn in graph series; `cmd` is refused when `ids` held nothing else. -pub(crate) fn without_design_art(d: &Document, mut ids: Vec, cmd: &str) -> Result> { +pub(crate) fn without_design_art(d: &Document, ids: Vec, cmd: &str) -> Result> { let given = ids.len(); - ids.retain(|id| !super::graph::is_design_art(d, *id)); + let ids = drop_design_art(d, ids); if given > 0 && ids.is_empty() { return Err(bad(cmd, DESIGN_ART)); } Ok(ids) } +/// `ids` without the art of graph designs drawn in graph series, for the paint commands, which leave it out without an +/// error. +pub(crate) fn drop_design_art(d: &Document, mut ids: Vec) -> Vec { + ids.retain(|id| !super::graph::is_design_art(d, *id)); + ids +} + +/// The outermost art of graph designs drawn in graph series inside `roots`: the objects directly in a series group that +/// [`super::graph::is_design_art`] accepts. +pub(crate) fn design_art_in(d: &Document, roots: &[NodeId]) -> Vec { + let mut out = vec![]; + for id in roots { + let Some(n) = d.node(*id) else { continue }; + n.walk(&mut |m| { + if m.extras().series_index.is_some() { + out.extend(m.children().into_iter().flatten().map(|c| c.id).filter(|c| super::graph::is_design_art(d, *c))); + } + }); + } + out +} + /// `ids` with each graph series group that has no fill or stroke row of its own replaced by the paths directly inside /// it ([`super::graph::series_group_marks`]). A series group with rows, as a document saved earlier can hold, stays: /// the Appearance panel lists those rows and edits them by index. @@ -476,15 +502,15 @@ pub(crate) fn look_holder<'a>(d: &'a Document, n: &'a Node) -> &'a Node { /// Objects whose fill and stroke rows a command edits: the [`edit_targets`], with a graph series group standing for the /// paths directly inside it ([`rowless_series_marks`]), so the graph can store the edit. -pub(crate) fn row_targets(s: &Session, p: &Value) -> Result> { - let ids = edit_targets(s, p)?; +pub(crate) fn row_targets(s: &Session, p: &Value, cmd: &str) -> Result> { + let ids = edit_targets(s, p, cmd)?; Ok(rowless_series_marks(&s.doc()?.doc, &ids)) } /// Objects whose whole appearance a command edits: the [`row_targets`], then each graph series group they stand for, /// which can hold effects and a graphic style link of its own. -pub(crate) fn whole_targets(s: &Session, p: &Value) -> Result> { - let own = edit_targets(s, p)?; +pub(crate) fn whole_targets(s: &Session, p: &Value, cmd: &str) -> Result> { + let own = edit_targets(s, p, cmd)?; let mut ids = rowless_series_marks(&s.doc()?.doc, &own); let groups = stood_for(&own, &ids); ids.extend(groups); @@ -511,7 +537,7 @@ pub(crate) fn clear_stood_for(d: &mut Document, groups: &[NodeId]) { /// [`whole_targets`] of `cmd`, which needs at least one. fn require_whole_targets(s: &Session, p: &Value, cmd: &str) -> Result> { - let ids = whole_targets(s, p)?; + let ids = whole_targets(s, p, cmd)?; if ids.is_empty() { return Err(bad(cmd, "select objects or give ids")); } @@ -520,7 +546,7 @@ fn require_whole_targets(s: &Session, p: &Value, cmd: &str) -> Result Result> { - let ids = row_targets(s, p)?; + let ids = row_targets(s, p, cmd)?; if ids.is_empty() { return Err(bad(cmd, "select objects or give ids")); } @@ -599,8 +625,8 @@ pub(crate) fn edits_stroke(s: &Session, p: &Value, item: ItemTarget, default: bo return Ok(b); } let ItemTarget::Item { index, .. } = item else { return Ok(default) }; - let ids = appearance_targets(s, p)?; let d = &s.doc()?.doc; + let ids = drop_design_art(d, appearance_targets(s, p)?); Ok(ids.get(item.anchor(s, &ids)).and_then(|id| d.node(*id)?.appearance.items.get(index)).map_or(default, |it| !it.is_fill())) } @@ -1023,7 +1049,7 @@ fn set_item(s: &mut Session, p: &Value) -> Result { check_numbers(p, &["weight", "opacity"], "appearance.setItem")?; check_switches(p, &["visible"], "appearance.setItem")?; let paint = paint_from(s, p)?; - let ids = row_targets(s, p)?; + let ids = row_targets(s, p, "appearance.setItem")?; if ids.is_empty() { return Err(bad("appearance.setItem", "select objects or give ids")); } @@ -1271,7 +1297,7 @@ fn move_item(s: &mut Session, p: &Value) -> Result { let aligned = aligned(p, C)?; if p.get("from").and_then(Value::as_str) == Some("contents") { // A graph series group's own Contents row, not its paths, which have none. - let ids = edit_targets(s, p)?; + let ids = edit_targets(s, p, C)?; if ids.is_empty() { return Err(bad(C, "select objects or give ids")); } diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 6ee3905d95..2a1860af3a 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -298,7 +298,7 @@ fn ids_json(ids: &[NodeId]) -> Value { /// edits its equal item, or its equal object-level effect, each target matched on its own whatever /// the other targets have; in a target without them, `item` and `index` stand for themselves. pub(crate) fn effect_targets(s: &Session, p: &Value, item: ItemTarget, index: usize, cmd: &str) -> Result, usize)>> { - let roots = edit_targets(s, p)?; + let roots = edit_targets(s, p, cmd)?; let d = &s.doc()?.doc; let per = item.per_target(s, &roots); let first = roots.first().and_then(|f| d.node(*f)); @@ -453,7 +453,7 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result { } let item = item_target(s, p, C)?; if effects::is_three_d(id) { - let targets = edit_targets(s, p)?; + let targets = edit_targets(s, p, C)?; for (target, at) in targets.iter().copied().zip(item.per_target(s, &targets)) { let d = &s.doc()?.doc; let n = d.node(target).ok_or(EngineError::NoNode(target))?; @@ -494,7 +494,7 @@ fn loose_for_pathfinder(s: &Session, p: &Value, id: &str) -> Result= 2 && !grouped).then_some(roots)) } @@ -607,7 +607,7 @@ fn move_effect(s: &mut Session, p: &Value) -> Result { let src = item_target_at(s, p, "fromItem", C)?; let dst = if p.get("toItem").is_some() { item_target_at(s, p, "toItem", C)? } else { src }; let copy = switch_or(p, "copy", false, C)?; - let roots = edit_targets(s, p)?; + let roots = edit_targets(s, p, C)?; // Where an aligned effect would go in each of several different stacks isn't one place. if aligned(p, C)? && super::appearance::appearances_differ(&s.doc()?.doc, &roots) { return Err(bad(C, "effects of objects whose appearances differ can't be moved together")); @@ -892,8 +892,11 @@ fn can_expand(s: &Session) -> std::result::Result<(), String> { has_doc(s)?; let ids = appearance_targets(s, &Value::Null).map_err(|e| e.to_string())?; let d = &s.doc().map_err(|e| e.to_string())?.doc; + // The art of a graph design in a series, selected or inside a selected graph, is drawn again from the design. + let ids = super::appearance::drop_design_art(d, ids); // A dimension selected with the art it measures is left as it is. - let kept = super::dimension::with_their_art(d, &ids); + let mut kept = super::dimension::with_their_art(d, &ids); + kept.extend(super::appearance::design_art_in(d, &ids)); if ids.iter().filter_map(|id| d.node(*id)).any(|n| expandable_except(n, &kept)) { Ok(()) } else { @@ -1219,12 +1222,22 @@ fn holds_plugin_effect(d: &Document, n: &Node, in_art: bool, seen: &mut std::col } fn expand_appearance(s: &mut Session, p: &Value) -> Result { - let roots = appearance_targets(s, p)?; + let roots = edit_targets(s, p, "effect.expandAppearance")?; // In Safe Mode a plug-in effect draws nothing: expanding it would drop the effect and keep the // geometry it hasn't changed. if vectorcraft_plugins::safe_mode::active() { - // A dimension selected with the art it measures is left as it is. - let d = &*super::dimension::without_kept(&s.doc()?.doc, &roots); + // A dimension selected with the art it measures is left as it is, and so is the art of a graph design in a + // series. + let mut d = super::dimension::without_kept(&s.doc()?.doc, &roots); + let designs = super::appearance::design_art_in(&d, &roots); + if !designs.is_empty() { + let view = d.to_mut(); + for id in designs { + // Art already gone (inside other art removed) needs nothing more. + let _ = view.remove(id); + } + } + let d = &*d; let mut seen = std::collections::HashSet::new(); if roots.iter().filter_map(|id| d.node(*id)).any(|n| holds_plugin_effect(d, n, false, &mut seen)) { return Err(EngineError::Other("Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode".into())); @@ -1234,6 +1247,8 @@ fn expand_appearance(s: &mut Session, p: &Value) -> Result { let colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok(); let ids = s.edit("Expand Appearance", |d, _| { let before = d.clone(); + // The art of graph designs in the graphs and series among the roots stays as the graph draws it. + let designs = super::appearance::design_art_in(d, &roots); // A dimension selected with the art it measures keeps its own lines and label: it is out // of the document while the art expands. let lifted = super::dimension::lift(d, &roots); @@ -1245,6 +1260,15 @@ fn expand_appearance(s: &mut Session, p: &Value) -> Result { for id in &roots { expand_node(d, *id, &brushes, &mut out); } + for id in &designs { + if let (Some(was), Some(slot)) = (before.node(*id), d.node_mut(*id)) { + *slot = was.clone(); + } + } + if !designs.is_empty() { + // A mark that expands into a group stays listed: only what lies in the restored art is left out. + out.retain(|id| d.node(*id).is_some() && !d.ancestry(*id).is_some_and(|up| up.iter().any(|a| designs.contains(a)))); + } super::dimension::put_back(d, lifted); // A dimension whose source was replaced stays where it was, no longer linked. super::dimension::restore_unchanged(&before, d, ended); diff --git a/crates/engine/src/cmd/effectcmd/graphics.rs b/crates/engine/src/cmd/effectcmd/graphics.rs index 9f4b89d2f4..e80c1b2d36 100644 --- a/crates/engine/src/cmd/effectcmd/graphics.rs +++ b/crates/engine/src/cmd/effectcmd/graphics.rs @@ -95,7 +95,7 @@ fn map_graphic(s: &mut Session, p: &Value) -> Result { effect_targets(s, p, item, index, C)? } else { let mut out = vec![]; - for id in edit_targets(s, p)? { + for id in edit_targets(s, p, C)? { let (item, index) = d.node(id).and_then(first_three_d).ok_or_else(|| bad(C, format!("object {} needs a 3D effect", id.0)))?; out.push((id, item, index)); } diff --git a/crates/engine/src/cmd/freeform.rs b/crates/engine/src/cmd/freeform.rs index bdba729211..a37cce38d0 100644 --- a/crates/engine/src/cmd/freeform.rs +++ b/crates/engine/src/cmd/freeform.rs @@ -182,7 +182,8 @@ fn edit(s: &mut Session, p: &Value, cmd: &str, mut f: impl FnMut(&mut Freefor let item = item_target(s, p, cmd)?; let stroke = edits_stroke(s, p, item, !s.fill_active, cmd)?; let item = item.of_kind(s, !stroke); - let ids = item.targets(s, p)?; + // The art of a graph design in a series keeps the design's gradient. + let ids = super::appearance::without_design_art(&s.doc()?.doc, item.targets(s, p)?, cmd)?; if ids.is_empty() { return Err(bad(cmd, "select an object with a freeform gradient")); } diff --git a/crates/engine/src/cmd/gradient.rs b/crates/engine/src/cmd/gradient.rs index 54ca07da7e..30590d5364 100644 --- a/crates/engine/src/cmd/gradient.rs +++ b/crates/engine/src/cmd/gradient.rs @@ -530,7 +530,7 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result { check_switches(p, &["reverse", "dither"], C)?; check_numbers(p, &["angle"], C)?; let item = item.of_kind(s, !stroke); - let ids = item.targets(s, p)?; + let ids = super::appearance::drop_design_art(&s.doc()?.doc, item.targets(s, p)?); // How the gradient lies on a stroke (the panel's Stroke buttons). let mode = match str_param(p, "strokeMode") { None => None, @@ -760,6 +760,7 @@ fn set_gradient_geom(s: &mut Session, p: &Value) -> Result { if targets.is_empty() { return Err(EngineError::Other("nothing selected".into())); } + let targets = super::appearance::drop_design_art(&s.doc()?.doc, targets); let model = super::paint::new_color_model(s, &Value::Null); edit_items(s, &targets, item, C, "Gradient", !stroke, |n, index| { if index.is_none() diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index c997e803d5..8464f6d0b4 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -416,9 +416,162 @@ fn refused_on_design_art(s: &mut Session, cmd: &str, p: serde_json::Value) { let steps = s.doc().unwrap().history.undo.len(); let e = s.execute(cmd, &p).unwrap_err().to_string(); assert!(e.contains("drawn again from the design"), "{cmd}: {e}"); + // `appearance.addEffect` is an alias of `effect.apply`, whose errors name `effect.apply`. + let named = if cmd == "appearance.addEffect" { "effect.apply" } else { cmd }; + assert!(e.contains(&format!("`{named}`")), "the error names the command that ran: {e}"); assert_eq!(s.doc().unwrap().history.undo.len(), steps, "{cmd}"); } +/// The look of object `id`: its appearance, opacity, blend mode, isolation, knockout and overprint. +fn look(s: &Session, id: NodeId) -> String { + format!("{:?}", s.doc().unwrap().doc.node(id).unwrap()) +} + +/// An edit of a graph design's art in a series lasts only until the graph is drawn again: the graph draws the art again +/// from the design. +#[test] +fn an_edit_of_a_graph_designs_art_is_lost_when_the_graph_is_drawn_again() { + let (mut s, id, _, art, _) = design_art(); + let path = |s: &Session| { + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let mut out = None; + series(&n, 0).children().unwrap()[0].walk(&mut |m| { + if out.is_none() && matches!(m.kind, NodeKind::Path { .. }) { + out = Some(m.clone()); + } + }); + out.unwrap() + }; + assert_eq!(path(&s).id, art); + s.execute("object.setProps", &json!({"id": art.0, "opacity": 50})).unwrap(); + assert!((path(&s).opacity - 0.5).abs() < 1e-6); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("graph.setData", &json!({"csv": ",2024,2025\nQ1,3,2\nQ2,5,8\nQ3,-1,6"})).unwrap(); + assert!((path(&s).opacity - 1.0).abs() < 1e-6); +} + +/// The paint, stroke, gradient and overprint commands leave the art of a graph design in a series as the design draws +/// it, and edit the other objects they are given. +#[test] +fn paint_commands_leave_a_graph_designs_art_out() { + let (mut s, _, copy, art, mark) = design_art(); + let fill = top_row(&s, art, true); + let commands = [ + ("stroke.set", json!({"weight": 5})), + ("stroke.weightUp", json!({})), + ("stroke.setAdvanced", json!({"arrowScale": [200, 200]})), + ("paint.editGradient", json!({"kind": "linear"})), + ("paint.setGradientGeom", json!({"start": [0, 0], "end": [50, 50]})), + ("object.setOverprint", json!({"fill": true})), + ("paint.swap", json!({})), + ("paint.default", json!({})), + ("paint.invert", json!({"item": fill, "stroke": false})), + ]; + let (copy_was, art_was) = (look(&s, copy), look(&s, art)); + for (cmd, p) in &commands { + for ids in [json!([copy.0]), json!([art.0])] { + let steps = s.doc().unwrap().history.undo.len(); + let mut q = p.clone(); + q["ids"] = ids; + let _ = s.execute(cmd, &q); + assert_eq!((look(&s, copy), look(&s, art)), (copy_was.clone(), art_was.clone()), "{cmd}"); + assert_eq!(s.doc().unwrap().history.undo.len(), steps, "{cmd}"); + } + let mark_was = look(&s, mark); + let mut q = p.clone(); + q["ids"] = json!([art.0, mark.0]); + s.execute(cmd, &q).unwrap(); + assert_eq!(look(&s, art), art_was, "{cmd}"); + assert_ne!(look(&s, mark), mark_was, "{cmd}"); + s.execute("edit.undo", &json!({})).unwrap(); + } + // Selected, without ids. + s.execute("select.set", &json!({"ids": [art.0]})).unwrap(); + s.execute("stroke.set", &json!({"weight": 5})).unwrap(); + assert_eq!(look(&s, art), art_was); + // A freeform gradient point is refused on the art alone. + refused_on_design_art(&mut s, "paint.freeform.addPoint", json!({"ids": [art.0], "at": [0, 0]})); +} + +/// `transparency.set` without `item` refuses the art of a graph design in a series, and given it with other objects +/// sets only theirs. +#[test] +fn object_transparency_leaves_a_graph_designs_art_out() { + let (mut s, _, copy, art, mark) = design_art(); + refused_on_design_art(&mut s, "transparency.set", json!({"ids": [art.0], "opacity": 40})); + refused_on_design_art(&mut s, "transparency.set", json!({"ids": [copy.0], "isolate": true})); + s.execute("transparency.set", &json!({"ids": [art.0, mark.0], "opacity": 40, "blend": "multiply"})).unwrap(); + let d = &s.doc().unwrap().doc; + assert!((d.node(art).unwrap().opacity - 1.0).abs() < 1e-6); + assert!((d.node(mark).unwrap().opacity - 0.4).abs() < 1e-6); +} + +/// With the art of a graph design selected first, the Appearance panel's active row (a row of that art) stands for no +/// row of the other objects selected: an effect goes on each of them as a whole. +#[test] +fn an_active_row_of_a_graph_designs_art_is_not_applied_to_other_objects() { + let (mut s, _, _, art, mark) = design_art(); + let stroke = top_row(&s, art, false); + s.execute("select.set", &json!({"ids": [art.0, mark.0]})).unwrap(); + assert_eq!(s.doc().unwrap().selection.subjects().first(), Some(&art)); + s.execute("appearance.setActiveItem", &json!({"index": stroke})).unwrap(); + s.execute("effect.apply", &json!({"effect": "stylize.dropShadow"})).unwrap(); + let n = s.doc().unwrap().doc.node(mark).unwrap().clone(); + assert_eq!(n.appearance.effects.len(), 1); + assert!(n.appearance.items.iter().all(|it| it.effects().is_empty())); + assert!(s.doc().unwrap().doc.node(art).unwrap().appearance.effects.is_empty()); +} + +/// Expand Appearance leaves the art of a graph design in a series as the design draws it. +#[test] +fn expand_appearance_leaves_a_graph_designs_art() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let r = s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 10, "height": 10})).unwrap()["id"].clone(); + s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [r]})).unwrap(); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + s.execute("graph.design", &json!({"new": "Shadow"})).unwrap(); + s.execute("graph.column", &json!({"id": id.0, "seriesIndexes": [0], "design": "Shadow"})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let mut art = vec![]; + series(&n, 0).children().unwrap()[0].walk(&mut |m| { + if !m.appearance.effects.is_empty() { + art.push(m.id); + } + }); + let art = art[0]; + let other = s.execute("shape.rectangle", &json!({"x": 700, "y": 700, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap(); + s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [other]})).unwrap(); + let art_was = look(&s, art); + // Given alone, by id or selected, it leaves Expand Appearance disabled. + for p in [json!({"ids": [art.0]}), json!({})] { + s.execute("select.set", &json!({"ids": [art.0]})).unwrap(); + assert!(s.execute("effect.expandAppearance", &p).unwrap_err().to_string().contains("not available")); + assert_eq!(look(&s, art), art_was); + } + // Inside a selected graph or series group, the art stays as the design draws it: with nothing else to expand there, + // Expand Appearance is disabled, and with a mark's effect, the mark expands alone. + let series_group = series(&n, 0).id; + for root in [id, series_group] { + s.execute("select.set", &json!({"ids": [root.0]})).unwrap(); + assert!(s.execute("effect.expandAppearance", &json!({})).unwrap_err().to_string().contains("not available")); + assert_eq!(look(&s, art), art_was); + } + let mark = series(&n, 1).children().unwrap()[0].id; + s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [mark.0]})).unwrap(); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + let expanded = s.execute("effect.expandAppearance", &json!({})).unwrap(); + assert_eq!(expanded["ids"], json!([mark.0])); + assert_eq!(look(&s, art), art_was); + s.execute("edit.undo", &json!({})).unwrap(); + // Selected with another object, the art is left as it is and the other object expands. + s.execute("select.set", &json!({"ids": [art.0, other]})).unwrap(); + s.execute("effect.expandAppearance", &json!({})).unwrap(); + assert_eq!(look(&s, art), art_was); + assert!(s.doc().unwrap().doc.node(NodeId(other)).is_none_or(|m| m.appearance.effects.is_empty())); +} + /// The commands that give rows, effects or graphic styles refuse the art of a graph design inside a series, given by /// id (the design's copy or a path inside it) or selected: the graph draws that art again from the design. #[test] @@ -445,6 +598,7 @@ fn appearance_commands_refuse_a_graph_designs_art_in_a_series() { refused_on_design_art(&mut s, "effect.remove", json!({"ids": ids, "index": 0})); refused_on_design_art(&mut s, "threeD.mapGraphic", json!({"ids": ids, "symbol": "Mark"})); refused_on_design_art(&mut s, "graphicStyle.apply", json!({"name": "Blue", "ids": ids})); + refused_on_design_art(&mut s, "graphicStyle.breakLink", json!({"ids": ids})); } for cmd in ["appearance.removeItem", "appearance.duplicateItem"] { refused_on_design_art(&mut s, cmd, json!({"ids": [art.0], "index": fill})); diff --git a/crates/engine/src/cmd/overprint.rs b/crates/engine/src/cmd/overprint.rs index 4cc1fa24c3..781a8242ce 100644 --- a/crates/engine/src/cmd/overprint.rs +++ b/crates/engine/src/cmd/overprint.rs @@ -133,7 +133,7 @@ fn aims(s: &Session, p: &Value, cmd: &str, kinds: impl IntoIterator .into_iter() .map(|fill| { let it = item.of_kind(s, fill); - Ok((fill, it, it.targets(s, p)?)) + Ok((fill, it, super::appearance::drop_design_art(&s.doc()?.doc, it.targets(s, p)?))) }) .collect() } diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index 3ec28e7ce7..1f25f5b56f 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -902,7 +902,7 @@ pub(super) fn restore_unpainted_stroke(ap: &mut Appearance, index: Option } fn swap(s: &mut Session, p: &Value) -> Result { - let ids = paint_targets(s, p)?; + let ids = super::appearance::drop_design_art(&s.doc()?.doc, paint_targets(s, p)?); if !ids.is_empty() { // When every object comes out as it was, the document and its undo history stay as they are. let before = s.doc()?.doc.clone(); @@ -936,7 +936,7 @@ fn swap(s: &mut Session, p: &Value) -> Result { } fn default_paint(s: &mut Session, p: &Value) -> Result { - let ids = paint_targets(s, p)?; + let ids = super::appearance::drop_design_art(&s.doc()?.doc, paint_targets(s, p)?); if !ids.is_empty() { // When every object comes out as it was, the document and its undo history stay as they are. let before = s.doc()?.doc.clone(); @@ -1006,7 +1006,7 @@ fn proxy_recolor(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn( let item = item_target(s, p, cmd)?.of_kind(s, !stroke); let (r, shown_item) = match item { ItemTarget::Item { index, .. } => { - let ids = item.targets(s, p)?; + let ids = super::appearance::drop_design_art(&s.doc()?.doc, item.targets(s, p)?); let mut changed = 0; edit_items(s, &ids, item, cmd, label, !stroke, |n, i| { let paint = @@ -1076,6 +1076,9 @@ fn transparency(s: &mut Session, p: &Value) -> Result { if ids.is_empty() { return Err(bad(C, "select objects or give ids")); } + // The object-level opacity, blend mode, isolation and knockout of a graph design's art in a series are drawn again + // from the design too. + let ids = super::appearance::without_design_art(&s.doc()?.doc, ids, C)?; // While an opacity mask is edited the selection is its art, so the Appearance panel's active // item (a row of that art) doesn't stand for the masked object. let editing_mask = p.get("ids").is_none() && p.get("id").is_none() && s.doc()?.doc.mask_edit.is_some(); @@ -1102,7 +1105,7 @@ fn transparency(s: &mut Session, p: &Value) -> Result { return s.execute("object.setProps", &Value::Object(q)); }; // Opacity and blend belong to the targeted fill/stroke; isolate/knockout/knockoutShape stay object-level. - let item_ids = if editing_mask { ids } else { row_targets(s, p)? }; + let item_ids = if editing_mask { ids } else { row_targets(s, p, C)? }; if explicit { let d = &s.doc()?.doc; if !item_ids.iter().any(|id| d.node(*id).is_some_and(|n| index < n.appearance.items.len())) { diff --git a/crates/engine/src/cmd/stroke.rs b/crates/engine/src/cmd/stroke.rs index c5e5ba2a5c..74aff88fdf 100644 --- a/crates/engine/src/cmd/stroke.rs +++ b/crates/engine/src/cmd/stroke.rs @@ -234,6 +234,8 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result { let item = item_target(s, p, C)?.of_kind(s, false); let mut ids = if item == ItemTarget::Top { own_stroke_or_leaves(s, p)? } else { item.targets(s, p)? }; let new_art = no_targets(&ids, p); + // The art of a graph design in a series keeps the design's stroke. + ids = super::appearance::drop_design_art(&s.doc()?.doc, ids); if item == ItemTarget::Top { // Images and symbol instances take no stroke from the panel. let d = &s.doc()?.doc; @@ -368,6 +370,7 @@ fn stroke_step(s: &mut Session, p: &Value, up: bool) -> Result { } return Ok(json!({"objects": 0})); } + let ids = super::appearance::drop_design_art(&s.doc()?.doc, ids); let label = if up { "Stroke Weight Up" } else { "Stroke Weight Down" }; edit_items(s, &ids, item, c, label, false, |n, index| { if index.is_none() @@ -509,7 +512,7 @@ fn stroke_advanced(s: &mut Session, p: &Value) -> Result { return Err(bad(C, "nothing to change")); } let item = item_target(s, p, C)?.of_kind(s, false); - let ids = item.targets(s, p)?; + let ids = super::appearance::drop_design_art(&s.doc()?.doc, item.targets(s, p)?); let arrows_and_profile = |st: &mut StrokeLayer| { if let Some(sc) = scale { st.arrow_scale = sc; diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 370664f795..1ac72cdfbb 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -31,7 +31,7 @@ pub fn specs() -> Vec { "Apply Graphic Style", ["Window", "Graphic Styles"], None, - "{name, add?: bool, ids?|id? (layers too), target?: \"object\"|\"contents\"} give the objects (default: the selection; a group or layer itself, its fills and strokes painting its members and its effects applying to them as one piece; target contents: the objects inside instead) the style's appearance, opacity, blend mode, isolate and knockout, and link them to it (placed gradients land at the same place relative to each object's bounds); add: true (Alt-click) adds its fills, strokes and effects on top of the existing appearance instead and unlinks. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (with `add`: of the kinds of row the style shows) and, without `add`, the object's opacity and blend mode (exceptions: docs/mcp.md); on a graph label, the graph stores the label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind. With nothing selected (and no ids) the next object drawn takes the style instead (see appearance.newArt) → {newArt: true}", + "{name, add?: bool, ids?|id? (layers too), target?: \"object\"|\"contents\"} give the objects (default: the selection; a group or layer itself, its fills and strokes painting its members and its effects applying to them as one piece; target contents: the objects inside instead) the style's appearance, opacity, blend mode, isolate and knockout, and link them to it (placed gradients land at the same place relative to each object's bounds); add: true (Alt-click) adds its fills, strokes and effects on top of the existing appearance instead and unlinks. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (with `add`: of the kinds of row the style shows) and, without `add`, the object's opacity and blend mode (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores the label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind. With nothing selected (and no ids) the next object drawn takes the style instead (see appearance.newArt) → {newArt: true}", has_doc, style_apply ), @@ -332,7 +332,7 @@ fn style_apply(s: &mut Session, p: &Value) -> Result { let i = style_index(&s.doc()?.doc, name)?; let add = bool_or(p, "add", false); let chars = s.prefs.override_char_color; - let own = super::appearance::edit_targets(s, p)?; + let own = super::appearance::edit_targets(s, p, "graphicStyle.apply")?; let ids = super::appearance::rowless_series_marks(&s.doc()?.doc, &own); // The series groups their paths stand for: a style that replaces the appearance clears what they hold themselves, // and a style added on top unlinks them. @@ -516,7 +516,7 @@ fn style_redefine(s: &mut Session, p: &Value) -> Result { } fn style_break_link(s: &mut Session, p: &Value) -> Result { - let ids = super::appearance::whole_targets(s, p)?; + let ids = super::appearance::whole_targets(s, p, "graphicStyle.breakLink")?; s.edit("Break Link to Graphic Style", |d, _| { set_link(d, &ids, None); Ok(()) diff --git a/crates/engine/src/cmd/stylelib.rs b/crates/engine/src/cmd/stylelib.rs index 09b478e8c7..177447fb56 100644 --- a/crates/engine/src/cmd/stylelib.rs +++ b/crates/engine/src/cmd/stylelib.rs @@ -167,7 +167,7 @@ fn add(s: &mut Session, p: &Value) -> Result { } if apply { // Objects that can't take a style are refused before any style is added. - super::appearance::edit_targets(s, &if ids_given { json!({"ids": p["ids"]}) } else { json!({}) })?; + super::appearance::edit_targets(s, &if ids_given { json!({"ids": p["ids"]}) } else { json!({}) }, C)?; } let mut names = names_param(p, C)?; names.extend(text_param(p, "name", C)?.map(str::to_string)); diff --git a/docs/mcp.md b/docs/mcp.md index f7d99cbd04..ff49c796c3 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -805,15 +805,22 @@ The other commands given a series group's id edit the group itself, and the grap `appearance.addEffect`, and the paint, stroke and gradient commands with a fill or stroke `item`, which is a row of the group's own stack. The Appearance panel and `appearance.setActiveItem` read the selected group's own rows. -The graph draws the art of a column or marker design in a series again from the design, so the commands that add, -change, move or remove fill and stroke rows, effects or graphic styles leave that art out: the `appearance.*` commands -listed above, `effect.apply`, `appearance.addEffect` and the other `effect.*` commands that edit, move or remove an -effect, `graphicStyle.apply` (and `graphicStyle.addFromLibrary` with `apply`), `transparency.set` with a fill or stroke -`item`, and `appearance.copyFrom` as the target. The art is any object inside a series group other than a path directly -in it, whether given by id, selected, or reached through `target: "contents"`. The commands edit the other objects they -are given, and a command whose objects are all design art is an error. `appearance.transfer` with design art as its -`target`, or as its `source` without `copy`, is an error. The paint commands leave the art out as well, without an -error. To change the art, edit the design with `graph.design`. +When a graph is drawn again (a change to its data, its type, or its column or marker designs), it draws the art of a +column or marker design in a series again from the design, and an edit made to that art is lost. The art is any object +inside a series group other than a path directly in it, a legend swatch's included, whether given by id, selected, or +reached through `target: "contents"`. These commands leave that art out, edit the other objects they are given, and +are an error when all their objects are design art: the `appearance.*` commands listed above, `effect.apply`, +`appearance.addEffect` and the other `effect.*` commands that edit, move or remove an effect, +`effect.expandAppearance` (which also leaves the art inside a selected graph or series group as it was, unless an +effect of that group turns the whole group into other art, and is disabled when only design art is selected or +given), `threeD.mapGraphic`, +`graphicStyle.apply`, `graphicStyle.addFromLibrary` with `apply`, `graphicStyle.breakLink`, `transparency.set`, the +`paint.freeform.*` commands that edit points and lines, and `appearance.copyFrom` as the target. +`appearance.transfer` with design art as its `target`, or as its `source` without `copy`, is an error. +`paint.setFill`, `paint.setStroke`, `paint.swap`, `paint.default`, `paint.invert` and `paint.complement` with an +`item`, `stroke.set`, `stroke.weightUp`, `stroke.weightDown`, `stroke.setAdvanced`, `paint.editGradient`, +`paint.setGradientGeom` and `object.setOverprint` leave the art out without an error. When the first selected object is design art, the commands +do not use the Appearance panel's active row. To change the art, edit the design with `graph.design`. The graph also stores a paint or stroke weight that one of these commands changes on the topmost visible fill or stroke of a path directly in a series: a weight set with `stroke.set`, `stroke.weightUp` or `stroke.weightDown`; a gradient diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 19b9c8d319..2740e257a9 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1112,6 +1112,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur What Global Edit gives a mark from another match (in an isolated graph) is kept on the graph the same way, and so is the object opacity, with its blend mode, that Randomize Opacity and Sort Objects change on marks. - **Graph column designs:** Object › Graph › Column (`graph.column`) draws the columns, bars or stacked segments and the legend swatch of the series picked with Group Selection with a graph design: Vertically Scaled stretches it to each column's length, Uniformly Scaled scales it both ways and centers it, Repeating stacks a copy for each Each Design Represents value and chops or scales the last one (For Fractions), and Sliding keeps the part above the design's guide line in proportion and stretches the part below. The dialog lists the designs with a preview; the choice is saved with the graph and follows the design's rename and delete. Type in a column design that holds a `%` followed by two digits shows each column's value in place of the `%` and the digits: the first digit is the least number of places before the decimal point (zeros are added in front; 0 gives as many as the value needs), and the second digit sets the places after it, rounded. Unless it is inside a group that clips or has its own opacity or appearance, or inside a blend, compound shape, envelope, repeat or intertwined objects, the type keeps its size and is drawn once per column in a repeating design. The legend swatch leaves it out. + The transparency, paint, stroke, gradient and overprint commands and Expand Appearance also leave the art of a column or marker design inside a series unchanged. The graph draws that art again from the design (`docs/mcp.md`). - **Other:** Eyedropper, Gradient annotator, Artboard (Alt-drag duplicates an artboard, and its art with Move/Copy Artwork with Artboard on; Copy, Cut and Paste take the artboard with its art, Paste right of the last artboard, Paste in Place where it was, into this or another document, #446; Object › Artboards › Rearrange All Artboards lays them out in a grid by row or by column, in one row or one column, left to right or right to left, from the menu, the Artboards panel or Properties' Rearrange All, #681; Scale Artwork with Artboard, in the Control bar and Properties beside Move Artwork with Artboard, resizes the artboard proportionally with the art fully inside it and its guides, strokes and corners per the Scale preferences, #602), Measure, Type, Hand, Zoom, Rotate View. While a drag rotates the view, a dial at the center of the canvas shows the angle. - **Editing type:** double-clicking type with a selection tool (Selection or Direct Selection) switches to the Type tool with the caret where it was clicked; pressing Escape finishes editing and switches back to the selection tool used before double-clicking it (when in isolation mode, the first Escape exits text editing while preserving isolation mode, and a second Escape exits isolation). From 23e8e169ab4baa9a3bf82ab172edfd60eec737f8 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 19:33:03 -0800 Subject: [PATCH 04/52] i18n: translate the read-root, linked JavaScript and graph design refusals No complete-message catalog had a row for "a linked JavaScript file is outside the read roots" (an SVG or SVGZ export or save of a document that links a script outside the read roots) or for "the art of a graph design in a graph series is drawn again from the design: edit the design to change it" (an appearance edit of only that art), so the status bar showed both in English. "the library's file is outside the read roots" (Copy to User Defined) and "{file} names more than one linked JavaScript file" (svgInteractivity.removeFile) had no row in ca, el, fi, hi, ka and vi. - Add the two refusals to 20 complete-message catalogs and the two siblings to ca, el, fi, hi, ka and vi (52 rows), next to each catalog's read-root and graph-design link rows, using their terms. The nl, sv and tr rows and the other catalogs' sibling rows are in #7362, #7370, #7359, #7355, #7316, #7307, #7354, #7314, #7350, #7337 and #7352. - Add a test that checks each reason's row in every complete-message catalog that has one (translated, keeps `{file}`, and shown inside the invalid-parameters error of the command that returns it), and requires at least one catalog to have a row for each reason. --- crates/ui-egui/src/i18n/ca.tsv | 4 +++ crates/ui-egui/src/i18n/cs.tsv | 2 ++ crates/ui-egui/src/i18n/de.tsv | 2 ++ crates/ui-egui/src/i18n/el.tsv | 4 +++ crates/ui-egui/src/i18n/es.tsv | 2 ++ crates/ui-egui/src/i18n/fi.tsv | 4 +++ crates/ui-egui/src/i18n/fr.tsv | 2 ++ crates/ui-egui/src/i18n/hi.tsv | 4 +++ crates/ui-egui/src/i18n/hu.tsv | 2 ++ crates/ui-egui/src/i18n/it.tsv | 2 ++ crates/ui-egui/src/i18n/ja.tsv | 2 ++ crates/ui-egui/src/i18n/ka.tsv | 4 +++ crates/ui-egui/src/i18n/ko.tsv | 2 ++ crates/ui-egui/src/i18n/pl.tsv | 2 ++ crates/ui-egui/src/i18n/pt-br.tsv | 2 ++ crates/ui-egui/src/i18n/ru.tsv | 2 ++ crates/ui-egui/src/i18n/tests.rs | 42 +++++++++++++++++++++++++++++ crates/ui-egui/src/i18n/uk.tsv | 2 ++ crates/ui-egui/src/i18n/vi.tsv | 4 +++ crates/ui-egui/src/i18n/zh-hans.tsv | 2 ++ crates/ui-egui/src/i18n/zh-hant.tsv | 2 ++ 21 files changed, 94 insertions(+) diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 373eb0b79d..1e8825cd4e 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5404,6 +5404,9 @@ digits Malayalam Malaiàlam @msg automation path rejected: cannot resolve {path}: {e} camí d’automatització rebutjat: no es pot resoldre {path}: {e} @msg automation path rejected: outside the read root {root}: {path} camí d’automatització rebutjat: fora de l’arrel de lectura {root}: {path} @msg automation path rejected: outside the read roots {roots}: {path} camí d’automatització rebutjat: fora de les arrels de lectura {roots}: {path} +@msg a linked JavaScript file is outside the read roots un fitxer JavaScript enllaçat és fora de les arrels de lectura +@msg the library's file is outside the read roots el fitxer de la biblioteca és fora de les arrels de lectura +@msg {file} names more than one linked JavaScript file {file} correspon a més d’un fitxer JavaScript enllaçat @msg automation path rejected: outside the write root {root}: {path} camí d’automatització rebutjat: fora de l’arrel d’escriptura {root}: {path} @msg automation path rejected: outside the write roots {roots}: {path} camí d’automatització rebutjat: fora de les arrels d’escriptura {roots}: {path} @msg automation path rejected: parent traversal below a folder that doesn't exist: {path} camí d’automatització rebutjat: recorregut ascendent per sota d’una carpeta que no existeix: {path} @@ -5828,6 +5831,7 @@ digits Malayalam Malaiàlam @msg no library `{key}` (see {list}) no hi ha la biblioteca `{key}` (vegeu {list}) @msg No linked files to look for No hi ha fitxers enllaçats per buscar @msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas L’enllaç és dins d’un símbol, un disseny de gràfic, un motiu o una màscara d’opacitat i no té lloc al llenç +@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it la il·lustració d’un disseny de gràfic en una sèrie d’un gràfic es torna a dibuixar a partir del disseny: editeu el disseny per canviar-la @msg no Live Paint edge near that point no hi ha cap aresta de pintura interactiva a prop d’aquell punt @msg no Live Paint face at that point no hi ha cap cara de pintura interactiva en aquell punt @msg no native format no hi ha format natiu diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 7178be3143..d914e74fb5 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -5490,6 +5490,7 @@ imageTrace Outline Obrys @msg every numbered name for {name} is taken in the document's folder ve složce dokumentu jsou obsazena všechna číslovaná jména pro {name} @msg No linked files to look for Žádné propojené soubory k vyhledání @msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Propojení je uvnitř symbolu, návrhu grafu, vzorku nebo masky krytí a nemá na plátně žádné místo +@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it kresba návrhu grafu v datové řadě grafu se znovu vykresluje podle návrhu: chcete-li ji změnit, upravte návrh @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` je odkaz na krok {r}, který vyhodnocují jen `run` a `command_batch` @msg `ids` must be an array of object ids, not {v} `ids` musí být pole ID objektů, ne {v} @msg `colors` must be an array of colors, not {v} `colors` musí být pole barev, ne {v} @@ -5509,6 +5510,7 @@ imageTrace Outline Obrys @msg automation filesystem access is not granted: write authority is absent: {path} přístup automatizace k systému souborů není udělen: chybí oprávnění k zápisu: {path} @msg automation path rejected: outside the read root {root}: {path} cesta automatizace odmítnuta: mimo kořen pro čtení {root}: {path} @msg automation path rejected: outside the read roots {roots}: {path} cesta automatizace odmítnuta: mimo kořeny pro čtení {roots}: {path} +@msg a linked JavaScript file is outside the read roots propojený soubor JavaScript je mimo kořeny pro čtení @msg automation path rejected: outside the write root {root}: {path} cesta automatizace odmítnuta: mimo kořen pro zápis {root}: {path} @msg automation path rejected: outside the write roots {roots}: {path} cesta automatizace odmítnuta: mimo kořeny pro zápis {roots}: {path} @msg automation path rejected: path is empty cesta automatizace odmítnuta: cesta je prázdná diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 951f6f9c37..b46b896c06 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5106,6 +5106,7 @@ theme Appearance Erscheinungsbild @msg every numbered name for {name} is taken in the document's folder alle nummerierten Namen für {name} sind im Ordner des Dokuments bereits vergeben @msg No linked files to look for Keine verknüpften Dateien zum Suchen @msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Die Verknüpfung befindet sich in einem Symbol, einem Diagrammdesign, einem Muster oder einer Deckkraftmaske und hat keinen Platz auf der Zeichenfläche +@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it die Grafik eines Diagrammdesigns in einer Diagrammreihe wird aus dem Design neu gezeichnet: Bearbeiten Sie das Design, um sie zu ändern # The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages. Services Dienste @@ -5172,6 +5173,7 @@ theme Appearance Erscheinungsbild @msg automation filesystem access is not granted: write authority is absent: {path} Dateisystemzugriff für die Automatisierung ist nicht gewährt: Die Schreibberechtigung fehlt: {path} @msg automation path rejected: outside the read root {root}: {path} Automatisierungspfad abgelehnt: außerhalb des Lesestammordners {root}: {path} @msg automation path rejected: outside the read roots {roots}: {path} Automatisierungspfad abgelehnt: außerhalb der Lesestammordner {roots}: {path} +@msg a linked JavaScript file is outside the read roots eine verknüpfte JavaScript-Datei liegt außerhalb der Lesestammordner @msg automation path rejected: outside the write root {root}: {path} Automatisierungspfad abgelehnt: außerhalb des Schreibstammordners {root}: {path} @msg automation path rejected: outside the write roots {roots}: {path} Automatisierungspfad abgelehnt: außerhalb der Schreibstammordner {roots}: {path} @msg automation path rejected: path is empty Automatisierungspfad abgelehnt: Der Pfad ist leer diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 62e3adc491..4775c22864 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -5131,6 +5131,7 @@ digits Malayalam Μαλαγιάλαμ @msg every numbered name for {name} is taken in the document's folder όλα τα αριθμημένα ονόματα για το {name} χρησιμοποιούνται ήδη στον φάκελο εγγράφου @msg No linked files to look for Δεν υπάρχουν συνδεδεμένα αρχεία για αναζήτηση @msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Ο σύνδεσμος βρίσκεται μέσα σε σύμβολο, σχεδιασμό γραφήματος, μοτίβο ή μάσκα αδιαφάνειας και δεν έχει θέση στον καμβά +@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it το έργο ενός σχεδιασμού γραφήματος σε μια σειρά γραφήματος σχεδιάζεται ξανά από τον σχεδιασμό: επεξεργαστείτε τον σχεδιασμό για να το αλλάξετε Hide VectorCraft Απόκρυψη VectorCraft Bring All to Front Μεταφορά όλων στο προσκήνιο Break Link Κατάργηση σύνδεσης @@ -5188,6 +5189,9 @@ digits Malayalam Μαλαγιάλαμ @msg automation filesystem access is not granted: write authority is absent: {path} δεν έχει επιτραπεί πρόσβαση αυτοματισμού στο σύστημα αρχείων: δεν υπάρχει δικαίωμα εγγραφής: {path} @msg automation path rejected: outside the read root {root}: {path} απορρίφθηκε η διαδρομή αυτοματισμού: εκτός ρίζας ανάγνωσης {root}: {path} @msg automation path rejected: outside the read roots {roots}: {path} απορρίφθηκε η διαδρομή αυτοματισμού: εκτός των ριζών ανάγνωσης {roots}: {path} +@msg a linked JavaScript file is outside the read roots ένα συνδεδεμένο αρχείο JavaScript είναι εκτός των ριζών ανάγνωσης +@msg the library's file is outside the read roots το αρχείο της βιβλιοθήκης είναι εκτός των ριζών ανάγνωσης +@msg {file} names more than one linked JavaScript file το {file} αντιστοιχεί σε περισσότερα από ένα συνδεδεμένα αρχεία JavaScript @msg automation path rejected: outside the write root {root}: {path} απορρίφθηκε η διαδρομή αυτοματισμού: εκτός ρίζας εγγραφής {root}: {path} @msg automation path rejected: outside the write roots {roots}: {path} απορρίφθηκε η διαδρομή αυτοματισμού: εκτός των ριζών εγγραφής {roots}: {path} @msg automation path rejected: path is empty απορρίφθηκε η διαδρομή αυτοματισμού: η διαδρομή είναι κενή diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index d1c6aa53af..76cfb9b2bd 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -5182,6 +5182,7 @@ axis Both Ambos @msg every numbered name for {name} is taken in the document's folder todos los nombres numerados para {name} ya existen en la carpeta del documento @msg No linked files to look for No hay archivos vinculados que buscar @msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas El vínculo está dentro de un símbolo, un diseño de gráfico, un motivo o una máscara de opacidad y no tiene lugar en el lienzo +@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it la ilustración de un diseño de gráfico en una serie de un gráfico se vuelve a dibujar a partir del diseño: edita el diseño para cambiarla # The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages. Services Servicios @@ -5227,6 +5228,7 @@ axis Both Ambos @msg automation filesystem access is not granted: write authority is absent: {path} la automatización no tiene acceso al sistema de archivos: falta el permiso de escritura: {path} @msg automation path rejected: outside the read root {root}: {path} ruta de automatización rechazada: fuera de la carpeta raíz de lectura {root}: {path} @msg automation path rejected: outside the read roots {roots}: {path} ruta de automatización rechazada: fuera de las carpetas raíz de lectura {roots}: {path} +@msg a linked JavaScript file is outside the read roots un archivo JavaScript vinculado está fuera de las carpetas raíz de lectura @msg automation path rejected: outside the write root {root}: {path} ruta de automatización rechazada: fuera de la carpeta raíz de escritura {root}: {path} @msg automation path rejected: outside the write roots {roots}: {path} ruta de automatización rechazada: fuera de las carpetas raíz de escritura {roots}: {path} @msg automation path rejected: path is empty ruta de automatización rechazada: la ruta está vacía diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 45e47fd355..3b5b270f67 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -5389,6 +5389,7 @@ imageTrace Outline Ääriviiva @msg every numbered name for {name} is taken in the document's folder kaikki kohteen {name} numeroidut nimet ovat jo käytössä asiakirjan kansiossa @msg No linked files to look for Ei etsittäviä linkitettyjä tiedostoja @msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Linkki on symbolin, kaaviomallin, kuvion tai peittävyysmaskin sisällä, eikä sillä ole paikkaa piirtoalueella +@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it kaavion sarjassa olevan kaaviomallin piirros piirretään uudelleen mallista: muokkaa mallia muuttaaksesi sitä @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` on vaiheviittaus {r}, jonka vain `run` ja `command_batch` ratkaisevat @msg `ids` must be an array of object ids, not {v} `ids` on oltava objektitunnusten taulukko, ei {v} @msg `colors` must be an array of colors, not {v} `colors` on oltava värien taulukko, ei {v} @@ -5408,6 +5409,9 @@ imageTrace Outline Ääriviiva @msg automation filesystem access is not granted: write authority is absent: {path} automaation tiedostojärjestelmäkäyttöä ei ole myönnetty: kirjoitusoikeus puuttuu: {path} @msg automation path rejected: outside the read root {root}: {path} automaation polku hylättiin: lukujuuren {root} ulkopuolella: {path} @msg automation path rejected: outside the read roots {roots}: {path} automaation polku hylättiin: lukujuurten {roots} ulkopuolella: {path} +@msg a linked JavaScript file is outside the read roots linkitetty JavaScript-tiedosto on lukujuurten ulkopuolella +@msg the library's file is outside the read roots kirjaston tiedosto on lukujuurten ulkopuolella +@msg {file} names more than one linked JavaScript file {file} vastaa useampaa kuin yhtä linkitettyä JavaScript-tiedostoa @msg automation path rejected: outside the write root {root}: {path} automaation polku hylättiin: kirjoitusjuuren {root} ulkopuolella: {path} @msg automation path rejected: outside the write roots {roots}: {path} automaation polku hylättiin: kirjoitusjuurten {roots} ulkopuolella: {path} @msg automation path rejected: path is empty automaation polku hylättiin: polku on tyhjä diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 057dce6872..86c021d16e 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -5169,6 +5169,7 @@ axis Both Les deux @msg every numbered name for {name} is taken in the document's folder tous les noms numérotés pour {name} sont déjà pris dans le dossier du document @msg No linked files to look for Aucun fichier lié à rechercher @msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Le lien se trouve dans un symbole, une conception de graphe, un motif ou un masque d’opacité et n’a pas de place sur le canevas +@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it l’illustration d’une conception de graphe dans une série de graphe est redessinée à partir de la conception, qu’il faut modifier pour la changer # The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages. Services Services @@ -5234,6 +5235,7 @@ axis Both Les deux @msg automation filesystem access is not granted: write authority is absent: {path} l’automatisation n’a pas accès au système de fichiers : aucune autorisation d’écriture : {path} @msg automation path rejected: outside the read root {root}: {path} chemin d’automatisation refusé : hors du dossier racine de lecture {root} : {path} @msg automation path rejected: outside the read roots {roots}: {path} chemin d’automatisation refusé : hors des dossiers racines de lecture {roots} : {path} +@msg a linked JavaScript file is outside the read roots un fichier JavaScript lié est hors des dossiers racines de lecture @msg automation path rejected: outside the write root {root}: {path} chemin d’automatisation refusé : hors du dossier racine d’écriture {root} : {path} @msg automation path rejected: outside the write roots {roots}: {path} chemin d’automatisation refusé : hors des dossiers racines d’écriture {roots} : {path} @msg automation path rejected: path is empty chemin d’automatisation refusé : le chemin est vide diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 9d056e085a..4d0372b41e 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -6428,6 +6428,9 @@ antiAlias Sharp शार्प @msg automation path rejected: cannot resolve {path}: {e} स्वचालन पाथ खारिज कर दिया: {path} को हल नहीं कर सकता: {e} @msg automation path rejected: outside the read root {root}: {path} स्वचालन पाथ खारिज: पढ़ने की जड़ के बाहर {root}: {path} @msg automation path rejected: outside the read roots {roots}: {path} स्वचालन पाथ खारिज: पढ़ने की जड़ों के बाहर {roots}: {path} +@msg a linked JavaScript file is outside the read roots एक लिंक की गई जावास्क्रिप्ट फ़ाइल पढ़ने की जड़ों के बाहर है +@msg the library's file is outside the read roots लाइब्रेरी की फ़ाइल पढ़ने की जड़ों के बाहर है +@msg {file} names more than one linked JavaScript file {file} एक से अधिक लिंक की गई जावास्क्रिप्ट फ़ाइलों से मेल खाता है @msg automation path rejected: outside the write root {root}: {path} स्वचालन पाथ खारिज: लिखने की जड़ के बाहर {root}: {path} @msg automation path rejected: outside the write roots {roots}: {path} स्वचालन पाथ खारिज: लिखने की जड़ों के बाहर {roots}: {path} @msg automation path rejected: parent traversal below a folder that doesn't exist: {path} स्वचालन पाथ खारिज कर दिया: एक फ़ोल्डर के नीचे माता-पिता traversal जो मौजूद नहीं है: {path} @@ -7339,6 +7342,7 @@ antiAlias Sharp शार्प @msg No line breaks to remove हटाने के लिए कोई लाइन ब्रेक नहीं @msg No linked files to look for देखने के लिए कोई लिंक्ड फ़ाइल नहीं @msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas लिंक किसी सिंबल, ग्राफ़ डिज़ाइन, पैटर्न या ओपेसिटी मास्क के अंदर है और कैनवास पर इसकी कोई जगह नहीं है +@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it ग्राफ़ श्रृंखला में ग्राफ़ डिज़ाइन की कला डिज़ाइन से फिर से बनाई जाती है: इसे बदलने के लिए डिज़ाइन संपादित करें @msg no Live Paint edge near that point उस बिंदु के पास कोई लाइव पेंट एज नहीं @msg no Live Paint face at that point उस बिंदु पर कोई लाइव पेंट फेस नहीं @msg no metafile format कोई मेटाफ़ाइल फ़ॉर्मैट diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index c2f1a52cd9..b488a7d78e 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5017,6 +5017,7 @@ imageTrace Outline Körvonal @msg automation path rejected: cannot resolve {path}: {e} az automatizálás útvonala elutasítva: nem oldható fel: {path}: {e} @msg automation path rejected: outside the read root {root}: {path} az automatizálás útvonala elutasítva: kívül esik az olvasási gyökéren ({root}): {path} @msg automation path rejected: outside the read roots {roots}: {path} az automatizálás útvonala elutasítva: kívül esik az olvasási gyökereken ({roots}): {path} +@msg a linked JavaScript file is outside the read roots egy csatolt JavaScript-fájl kívül esik az olvasási gyökereken @msg automation path rejected: outside the write root {root}: {path} az automatizálás útvonala elutasítva: kívül esik az írási gyökéren ({root}): {path} @msg automation path rejected: outside the write roots {roots}: {path} az automatizálás útvonala elutasítva: kívül esik az írási gyökereken ({roots}): {path} @msg automation path rejected: parent traversal below a folder that doesn't exist: {path} az automatizálás útvonala elutasítva: szülőmappára lépés egy nem létező mappa alatt: {path} @@ -5224,6 +5225,7 @@ imageTrace Outline Körvonal @msg every numbered name for {name} is taken in the document's folder a dokumentum mappájában minden sorszámozott név foglalt: {name} @msg No linked files to look for Nincs keresendő csatolt fájl @msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas A csatolás egy szimbólumban, diagrammintában, mintázatban vagy átlátszósági maszkban van, és nincs helye a vásznon +@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it egy diagram adatsorában a diagramminta grafikája a mintából rajzolódik újra: a módosításához szerkeszd a mintát @msg every search thread stopped on an internal error (please report this bug) minden keresési szál belső hibával leállt (kérjük, jelentsd ezt a hibát) @msg Expand Appearance: the selection has a basic appearance Megjelenés kibontása: a kijelölésnek alapmegjelenése van @msg Expand: no brush strokes selected Kibontás: nincs kijelölt ecsetvonás diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 0ac2816b07..88dc06b581 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5234,6 +5234,7 @@ axis Both Entrambi @msg every numbered name for {name} is taken in the document's folder tutti i nomi numerati per {name} sono già usati nella cartella del documento @msg No linked files to look for Nessun file collegato da cercare @msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Il collegamento si trova in un simbolo, un design di grafico, un motivo o una maschera di opacità e non ha una posizione nell’area di disegno +@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it la grafica di un design di grafico in una serie del grafico viene ridisegnata dal design: modifica il design per cambiarla # The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages. Services Servizi @@ -5279,6 +5280,7 @@ axis Both Entrambi @msg automation filesystem access is not granted: write authority is absent: {path} l'automazione non ha accesso al file system: manca l'autorizzazione di scrittura: {path} @msg automation path rejected: outside the read root {root}: {path} percorso di automazione rifiutato: fuori dalla cartella radice di lettura {root}: {path} @msg automation path rejected: outside the read roots {roots}: {path} percorso di automazione rifiutato: fuori dalle cartelle radice di lettura {roots}: {path} +@msg a linked JavaScript file is outside the read roots un file JavaScript collegato è fuori dalle cartelle radice di lettura @msg automation path rejected: outside the write root {root}: {path} percorso di automazione rifiutato: fuori dalla cartella radice di scrittura {root}: {path} @msg automation path rejected: outside the write roots {roots}: {path} percorso di automazione rifiutato: fuori dalle cartelle radice di scrittura {roots}: {path} @msg automation path rejected: path is empty percorso di automazione rifiutato: il percorso è vuoto diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 71de1ce91c..630dfecb6f 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5301,6 +5301,7 @@ swatch Bright Violet ビビッドバイオレット @msg every numbered name for {name} is taken in the document's folder ドキュメントのフォルダーでは、{name} の番号付きの名前がすべて使われています @msg No linked files to look for 検索するリンクファイルがありません @msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas このリンクはシンボル、グラフデザイン、パターン、または不透明マスクの中にあり、カンバス上に位置がありません +@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it グラフの系列にあるグラフデザインのアートはデザインから描き直されます: 変更するにはデザインを編集してください @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` はステップ参照 {r} です。ステップ参照を解決できるのは `run` と `command_batch` だけです @msg `ids` must be an array of object ids, not {v} `ids` はオブジェクト ID の配列にしてください ({v} は使えません) @msg `colors` must be an array of colors, not {v} `colors` は色の配列にしてください ({v} は使えません) @@ -5320,6 +5321,7 @@ swatch Bright Violet ビビッドバイオレット @msg automation filesystem access is not granted: write authority is absent: {path} 自動化にはファイルシステムへのアクセスが許可されていません: 書き込み権限がありません: {path} @msg automation path rejected: outside the read root {root}: {path} 自動化のパスを拒否しました: 読み込みルート {root} の外です: {path} @msg automation path rejected: outside the read roots {roots}: {path} 自動化のパスを拒否しました: 読み込みルート {roots} のいずれの中でもありません: {path} +@msg a linked JavaScript file is outside the read roots リンクされた JavaScript ファイルが読み込みルートの外にあります @msg automation path rejected: outside the write root {root}: {path} 自動化のパスを拒否しました: 書き込みルート {root} の外です: {path} @msg automation path rejected: outside the write roots {roots}: {path} 自動化のパスを拒否しました: 書き込みルート {roots} のいずれの中でもありません: {path} @msg automation path rejected: path is empty 自動化のパスを拒否しました: パスが空です diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 2cff51d94b..5663231298 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -4835,6 +4835,9 @@ font-technology Variable ცვლადი @msg automation filesystem access is not granted: write authority is absent: {path} ავტომატიზაციას ფაილურ სისტემაზე წვდომა არ აქვს: ჩაწერის უფლება არ არის: {path} @msg automation path rejected: outside the read root {root}: {path} ავტომატიზაციის გზა უარყოფილია: წაკითხვის ძირის {root} გარეთაა: {path} @msg automation path rejected: outside the read roots {roots}: {path} ავტომატიზაციის გზა უარყოფილია: წაკითხვის ძირების {roots} გარეთაა: {path} +@msg a linked JavaScript file is outside the read roots დაკავშირებული JavaScript ფაილი წაკითხვის ძირების გარეთაა +@msg the library's file is outside the read roots ბიბლიოთეკის ფაილი წაკითხვის ძირების გარეთაა +@msg {file} names more than one linked JavaScript file {file} ერთზე მეტ დაკავშირებულ JavaScript ფაილს შეესაბამება @msg automation path rejected: outside the write root {root}: {path} ავტომატიზაციის გზა უარყოფილია: ჩაწერის ძირის {root} გარეთაა: {path} @msg automation path rejected: outside the write roots {roots}: {path} ავტომატიზაციის გზა უარყოფილია: ჩაწერის ძირების {roots} გარეთაა: {path} @msg automation path rejected: path is empty ავტომატიზაციის გზა უარყოფილია: გზა ცარიელია @@ -5196,6 +5199,7 @@ font-technology Variable ცვლადი @msg every numbered name for {name} is taken in the document's folder დოკუმენტის საქაღალდეში {name}-ის ყველა დანომრილი სახელი დაკავებულია @msg No linked files to look for მოსაძებნი დაკავშირებული ფაილები არ არის @msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas ბმული სიმბოლოში, დიაგრამის დიზაინში, პატერნში ან გაუმჭვირვალობის ნიღაბშია და ტილოზე ადგილი არ აქვს +@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it დიაგრამის სერიაში დიაგრამის დიზაინის გრაფიკა დიზაინიდან თავიდან იხატება: მის შესაცვლელად დაარედაქტირეთ დიზაინი Ruler Origin სახაზავების სათავე Reset Ruler Origin სახაზავების სათავის ჩამოყრა Artistic მხატვრული diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 4715a413f4..cf74342499 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5228,6 +5228,7 @@ theme Appearance 외관 @msg every numbered name for {name} is taken in the document's folder 문서 폴더에서 {name}을(를) 위한 번호 이름이 모두 사용 중입니다 @msg No linked files to look for 찾을 링크된 파일이 없습니다 @msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas 링크가 심볼, 그래프 디자인, 패턴 또는 불투명 마스크 안에 있어 캔버스에 위치가 없습니다 +@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it 그래프 계열에 있는 그래프 디자인의 아트는 디자인에서 다시 그려집니다: 변경하려면 디자인을 편집하세요 @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id`는 단계 참조 {r}이며, `run`과 `command_batch`만 이를 해석합니다 @msg `ids` must be an array of object ids, not {v} `ids`는 {v}이(가) 아니라 개체 id 배열이어야 합니다 @msg `colors` must be an array of colors, not {v} `colors`는 {v}이(가) 아니라 색상 배열이어야 합니다 @@ -5247,6 +5248,7 @@ theme Appearance 외관 @msg automation filesystem access is not granted: write authority is absent: {path} 자동화 파일 시스템 접근이 허용되지 않았습니다: 쓰기 권한이 없습니다: {path} @msg automation path rejected: outside the read root {root}: {path} 자동화 경로가 거부되었습니다: 읽기 루트 {root} 밖입니다: {path} @msg automation path rejected: outside the read roots {roots}: {path} 자동화 경로가 거부되었습니다: 읽기 루트 {roots} 밖입니다: {path} +@msg a linked JavaScript file is outside the read roots 연결된 JavaScript 파일이 읽기 루트 밖에 있습니다 @msg automation path rejected: outside the write root {root}: {path} 자동화 경로가 거부되었습니다: 쓰기 루트 {root} 밖입니다: {path} @msg automation path rejected: outside the write roots {roots}: {path} 자동화 경로가 거부되었습니다: 쓰기 루트 {roots} 밖입니다: {path} @msg automation path rejected: path is empty 자동화 경로가 거부되었습니다: 경로가 비어 있습니다 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 48f2671c2d..5fa51a64d9 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5317,6 +5317,7 @@ imageTrace Outline Kontur @msg every numbered name for {name} is taken in the document's folder wszystkie numerowane nazwy dla {name} są zajęte w folderze dokumentu @msg No linked files to look for Brak połączonych plików do wyszukania @msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Łącze znajduje się w symbolu, wzorze wykresu, wzorku lub masce krycia i nie ma miejsca na obszarze roboczym +@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it grafika wzoru wykresu w serii danych wykresu jest rysowana od nowa na podstawie wzoru: aby ją zmienić, edytuj wzór @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` to odwołanie do kroku {r}, które rozwiązują tylko `run` i `command_batch` @msg `ids` must be an array of object ids, not {v} `ids` musi być tablicą identyfikatorów obiektów, nie {v} @msg `colors` must be an array of colors, not {v} `colors` musi być tablicą kolorów, nie {v} @@ -5336,6 +5337,7 @@ imageTrace Outline Kontur @msg automation filesystem access is not granted: write authority is absent: {path} nie przyznano dostępu automatyzacji do systemu plików: brak uprawnień do zapisu: {path} @msg automation path rejected: outside the read root {root}: {path} odrzucono ścieżkę automatyzacji: poza katalogiem głównym odczytu {root}: {path} @msg automation path rejected: outside the read roots {roots}: {path} odrzucono ścieżkę automatyzacji: poza katalogami głównymi odczytu {roots}: {path} +@msg a linked JavaScript file is outside the read roots połączony plik JavaScript jest poza katalogami głównymi odczytu @msg automation path rejected: outside the write root {root}: {path} odrzucono ścieżkę automatyzacji: poza katalogiem głównym zapisu {root}: {path} @msg automation path rejected: outside the write roots {roots}: {path} odrzucono ścieżkę automatyzacji: poza katalogami głównymi zapisu {roots}: {path} @msg automation path rejected: path is empty odrzucono ścieżkę automatyzacji: ścieżka jest pusta diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index f6e54552d4..44cea16ea4 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -4641,6 +4641,7 @@ axis Both Ambos @msg automation filesystem access is not granted: write authority is absent: {path} o acesso da automação ao sistema de arquivos não foi concedido: falta autorização de gravação: {path} @msg automation path rejected: outside the read root {root}: {path} caminho da automação rejeitado: fora da raiz de leitura {root}: {path} @msg automation path rejected: outside the read roots {roots}: {path} caminho da automação rejeitado: fora das raízes de leitura {roots}: {path} +@msg a linked JavaScript file is outside the read roots um arquivo JavaScript vinculado está fora das raízes de leitura @msg automation path rejected: outside the write root {root}: {path} caminho da automação rejeitado: fora da raiz de gravação {root}: {path} @msg automation path rejected: outside the write roots {roots}: {path} caminho da automação rejeitado: fora das raízes de gravação {roots}: {path} @msg automation path rejected: path is empty caminho da automação rejeitado: o caminho está vazio @@ -5418,6 +5419,7 @@ axis Both Ambos @msg every numbered name for {name} is taken in the document's folder todos os nomes numerados para {name} já existem na pasta do documento @msg No linked files to look for Nenhum arquivo vinculado para procurar @msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas O link está dentro de um símbolo, projeto de gráfico, padrão ou máscara de opacidade e não tem lugar na tela +@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it a arte de um projeto de gráfico em uma série do gráfico é desenhada de novo a partir do projeto: edite o projeto para alterá-la De-Interlace Desentrelaçar Eliminate Eliminar Create New Fields by Criar Novos Campos por diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 8dc8336089..c015fd66f7 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5219,6 +5219,7 @@ axis Both Оба @msg every numbered name for {name} is taken in the document's folder все нумерованные имена для {name} уже заняты в папке документа @msg No linked files to look for Нет связанных файлов для поиска @msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Связь находится внутри символа, дизайна диаграммы, узора или непрозрачной маски и не имеет места на холсте +@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it графика дизайна диаграммы в ряду данных диаграммы перерисовывается по дизайну: чтобы изменить её, отредактируйте дизайн # The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages. Services Службы @@ -5266,6 +5267,7 @@ axis Both Оба @msg automation filesystem access is not granted: write authority is absent: {path} у автоматизации нет доступа к файловой системе: нет права на запись: {path} @msg automation path rejected: outside the read root {root}: {path} путь автоматизации отклонён: вне корневой папки для чтения {root}: {path} @msg automation path rejected: outside the read roots {roots}: {path} путь автоматизации отклонён: вне корневых папок для чтения {roots}: {path} +@msg a linked JavaScript file is outside the read roots связанный файл JavaScript находится вне корневых папок для чтения @msg automation path rejected: outside the write root {root}: {path} путь автоматизации отклонён: вне корневой папки для записи {root}: {path} @msg automation path rejected: outside the write roots {roots}: {path} путь автоматизации отклонён: вне корневых папок для записи {roots}: {path} @msg automation path rejected: path is empty путь автоматизации отклонён: путь пуст diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 705fc68db2..b0b2ab0b63 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -2931,6 +2931,48 @@ fn selected_path_reason_is_translated() { } } +/// The refusals of a linked JavaScript file or a library's file outside the read roots, of a file +/// name that fits more than one linked JavaScript file, and of an appearance edit of the art a +/// graph design draws in a graph series have translated rows in languages that translate every +/// message. Each row keeps `{file}` and shows translated both alone and inside the +/// invalid-parameters error of the command that returns it. +#[test] +fn read_root_script_and_graph_design_reasons_are_translated() { + use vectorcraft_engine::EngineError; + let reasons = [ + ("document.export", "a linked JavaScript file is outside the read roots"), + ("swatch.library.copyToUser", "the library's file is outside the read roots"), + ("svgInteractivity.removeFile", "{file} names more than one linked JavaScript file"), + ("appearance", "the art of a graph design in a graph series is drawn again from the design: edit the design to change it"), + ]; + let literals = message_literals(); + for (_, reason) in reasons { + assert!(literals.contains(reason), "{reason}"); + } + let mut rows = [0; 4]; + for code in COMPLETE_MESSAGES { + let lang = Lang::from_code(code).expect("registered"); + let (entries, _) = parse_entries(lang.0.source); + for (i, (cmd, reason)) in reasons.into_iter().enumerate() { + let row = entries.iter().find(|(ctx, src, _)| ctx == "@msg" && src == reason).map(|(_, _, tr)| tr.as_str()); + let Some(row) = row else { continue }; + rows[i] += 1; + assert_ne!(row, reason, "{code}"); + assert_eq!(row.matches("{file}").count(), reason.matches("{file}").count(), "{code}: {row}"); + let sample = reason.replace("{file}", "\"a.js\""); + let translated = row.replace("{file}", "\"a.js\""); + assert_eq!(message(lang, &sample), translated, "{code}"); + let error = EngineError::BadParams { cmd: cmd.into(), msg: sample.clone() }.to_string(); + assert_eq!(message(Lang::EN, &error), error); + let shown = message(lang, &error); + assert!(shown.contains(&translated) && !shown.contains(&sample) && shown.contains(&format!("`{cmd}`")), "{code}: {shown}"); + } + } + for ((_, reason), count) in reasons.into_iter().zip(rows) { + assert!(count > 0, "no complete-message catalog has a `@msg` row for {reason:?}"); + } +} + /// The errors a script's `File` and `Folder` objects, `vc.run` and `$.evalFile` raise are /// translated in every language of [`COMPLETE_MESSAGES`], and so is the status bar's line for a run /// that ends with one: the script's name, its place, the command ids and what the script itself diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 50690fa0c7..9e57d09d55 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5218,6 +5218,7 @@ axis Both Обидві @msg every numbered name for {name} is taken in the document's folder усі нумеровані імена для {name} уже зайняті в папці документа @msg No linked files to look for Немає пов’язаних файлів для пошуку @msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Зв’язок міститься в символі, дизайні діаграми, візерунку або масці непрозорості й не має місця на полотні +@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it графіка дизайну діаграми в ряді даних діаграми перемальовується за дизайном: щоб змінити її, відредагуйте дизайн Services Служби Hide VectorCraft Приховати VectorCraft Zoom Масштаб @@ -5263,6 +5264,7 @@ axis Both Обидві @msg automation filesystem access is not granted: write authority is absent: {path} автоматизація не має доступу до файлової системи: немає права на запис: {path} @msg automation path rejected: outside the read root {root}: {path} шлях автоматизації відхилено: поза кореневою папкою для читання {root}: {path} @msg automation path rejected: outside the read roots {roots}: {path} шлях автоматизації відхилено: поза кореневими папками для читання {roots}: {path} +@msg a linked JavaScript file is outside the read roots зв’язаний файл JavaScript перебуває поза кореневими папками для читання @msg automation path rejected: outside the write root {root}: {path} шлях автоматизації відхилено: поза кореневою папкою для запису {root}: {path} @msg automation path rejected: outside the write roots {roots}: {path} шлях автоматизації відхилено: поза кореневими папками для запису {roots}: {path} @msg automation path rejected: path is empty шлях автоматизації відхилено: шлях порожній diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index d8469bce53..044e7233c4 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -4671,6 +4671,9 @@ theme Appearance Giao diện @msg automation path rejected: cannot resolve {path}: {e} đường dẫn tự động bị từ chối: không thể phân giải {path}: {e} @msg automation path rejected: outside the read root {root}: {path} đường dẫn tự động bị từ chối: ngoài thư mục gốc đọc {root}: {path} @msg automation path rejected: outside the read roots {roots}: {path} đường dẫn tự động bị từ chối: ngoài các thư mục gốc đọc {roots}: {path} +@msg a linked JavaScript file is outside the read roots một tệp JavaScript được liên kết nằm ngoài các thư mục gốc đọc +@msg the library's file is outside the read roots tệp của thư viện nằm ngoài các thư mục gốc đọc +@msg {file} names more than one linked JavaScript file {file} khớp với nhiều hơn một tệp JavaScript được liên kết @msg automation path rejected: outside the write root {root}: {path} đường dẫn tự động bị từ chối: ngoài thư mục gốc ghi {root}: {path} @msg automation path rejected: outside the write roots {roots}: {path} đường dẫn tự động bị từ chối: ngoài các thư mục gốc ghi {roots}: {path} @msg automation path rejected: parent traversal below a folder that doesn't exist: {path} đường dẫn tự động bị từ chối: duyệt cha bên dưới thư mục không tồn tại: {path} @@ -5052,6 +5055,7 @@ theme Appearance Giao diện @msg no library `{key}` (see {list}) không có thư viện `{key}` (xem {list}) @msg No linked files to look for Không có tệp liên kết để tìm kiếm @msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Liên kết nằm trong ký hiệu, thiết kế biểu đồ, họa tiết hoặc mặt nạ độ mờ và không có vị trí trên canvas +@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it hình vẽ của một thiết kế biểu đồ trong một chuỗi dữ liệu của biểu đồ được vẽ lại từ thiết kế: hãy sửa thiết kế để thay đổi nó @msg no Live Paint edge near that point không có cạnh tô trực tiếp gần điểm đó @msg no Live Paint face at that point không có mặt tô trực tiếp tại điểm đó @msg no native format không có định dạng gốc diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index d9d9110276..d8e39f8ab9 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -4978,6 +4978,7 @@ imageTrace Outline 轮廓 @msg every numbered name for {name} is taken in the document's folder 文档所在文件夹中 {name} 的所有带编号的名称均已被占用 @msg No linked files to look for 没有要查找的链接文件 @msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas 该链接位于符号、图表设计、图案或不透明度蒙版中,在画布上没有位置 +@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it 图表系列中图表设计的图稿会根据设计重新绘制:要更改它,请编辑设计 @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` 是步骤引用 {r},只有 `run` 和 `command_batch` 能解析它 @msg `ids` must be an array of object ids, not {v} `ids` 必须是由对象 id 组成的数组,而不是 {v} @msg `colors` must be an array of colors, not {v} `colors` 必须是由颜色组成的数组,而不是 {v} @@ -4997,6 +4998,7 @@ imageTrace Outline 轮廓 @msg automation filesystem access is not granted: write authority is absent: {path} 未授予自动化文件系统访问权限:缺少写入权限:{path} @msg automation path rejected: outside the read root {root}: {path} 已拒绝自动化路径:位于读取根目录 {root} 之外:{path} @msg automation path rejected: outside the read roots {roots}: {path} 已拒绝自动化路径:位于所有读取根目录 {roots} 之外:{path} +@msg a linked JavaScript file is outside the read roots 链接的 JavaScript 文件位于所有读取根目录之外 @msg automation path rejected: outside the write root {root}: {path} 已拒绝自动化路径:位于写入根目录 {root} 之外:{path} @msg automation path rejected: outside the write roots {roots}: {path} 已拒绝自动化路径:位于所有写入根目录 {roots} 之外:{path} @msg automation path rejected: path is empty 已拒绝自动化路径:路径为空 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index a0d987fe69..be0c44f21c 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2692,6 +2692,7 @@ file-filter Templates 範本 @msg every numbered name for {name} is taken in the document's folder 文件所在資料夾中 {name} 的所有編號名稱都已被使用 @msg No linked files to look for 沒有要尋找的連結檔案 @msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas 此連結位於符號、圖表設計、圖樣或不透明度遮色片中,在畫布上沒有位置 +@msg the art of a graph design in a graph series is drawn again from the design: edit the design to change it 圖表數列中圖表設計的圖稿會依設計重新繪製:若要變更,請編輯設計 @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` 是步驟參照 {r},只有 `run` 和 `command_batch` 能解析它 @msg `ids` must be an array of object ids, not {v} `ids` 必須是由物件 id 組成的陣列,而非 {v} @msg `colors` must be an array of colors, not {v} `colors` 必須是由顏色組成的陣列,而非 {v} @@ -2703,6 +2704,7 @@ file-filter Templates 範本 @msg automation filesystem access is not granted: write authority is absent: {path} 未授予自動化檔案系統存取權:沒有寫入權限:{path} @msg automation path rejected: outside the read root {root}: {path} 已拒絕自動化路徑:位於讀取根目錄 {root} 之外:{path} @msg automation path rejected: outside the read roots {roots}: {path} 已拒絕自動化路徑:位於所有讀取根目錄 {roots} 之外:{path} +@msg a linked JavaScript file is outside the read roots 連結的 JavaScript 檔案位於所有讀取根目錄之外 @msg automation path rejected: outside the write root {root}: {path} 已拒絕自動化路徑:位於寫入根目錄 {root} 之外:{path} @msg automation path rejected: outside the write roots {roots}: {path} 已拒絕自動化路徑:位於所有寫入根目錄 {roots} 之外:{path} @msg automation path rejected: path is empty 已拒絕自動化路徑:路徑是空的 From 92d3a8158ba7896d1219b74bab052c2b1b7d9562 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 20:14:36 -0800 Subject: [PATCH 05/52] i18n: Turkish Draw labels use the drawing verb instead of a tied game MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Turkish catalog translated the toolbar's Draw category and the freeform gradient's Draw: row as "Beraberlik", which means a tied game. Both now read "Çiz", the verb the catalog already uses for Draw Behind, Draw Inside and Draw Normal. The Turkish "unknown control" message also listed the control names as "colouring,drawMode": it now lists "coloring, drawMode", the names the command accepts. The two dimension scale rows read the English word order wrongly. The hint under the Dimension dialog's scale field now says that 1:10 labels 1 unit on the page as 10, and the scale error keeps "çizim:gerçek" as one form instead of splitting it around the verb. --- crates/ui-egui/src/i18n/tests.rs | 22 ++++++++++++++++++++++ crates/ui-egui/src/i18n/tr.tsv | 10 +++++----- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 705fc68db2..e49c33baf0 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -280,6 +280,28 @@ fn languages_resolve_from_their_tags_and_name_themselves() { } } +/// Turkish `Draw` labels use the drawing verb of the catalog's other drawing modes, and the +/// accepted control names in its messages keep the spelling the command reads. The dimension +/// scale rows keep the `drawing:real` form and say that the ratio labels a page unit. +#[test] +fn turkish_draw_labels_use_the_drawing_verb() { + let tr_lang = Lang::from_code("tr").expect("tr registered"); + assert_eq!(tr(tr_lang, "Draw"), "Çiz"); + assert_eq!(tr(tr_lang, "Draw:"), "Çiz:"); + assert_eq!(tr(tr_lang, "Draw Behind"), "Arkaya Çiz"); + let (entries, _) = parse_entries(tr_lang.0.source); + let tie: Vec<_> = + entries.iter().filter(|(_, _, t)| t.to_lowercase().contains("beraber")).map(|(_, src, t)| format!("{src:?} -> {t:?}")).collect(); + assert!(tie.is_empty(), "Turkish rows read as a tied game:\n{}", tie.join("\n")); + let unknown = message(tr_lang, "unknown control `x`: fillStroke, coloring, drawMode or screenMode"); + assert!(unknown.contains("fillStroke, coloring, drawMode veya screenMode"), "{unknown}"); + assert_eq!( + tr(tr_lang, "Drawing : real size — 1:10 labels 1 unit on the page as 10."), + "Çizim : gerçek boyut — 1:10, sayfadaki 1 birimi 10 olarak etiketler." + ); + assert_eq!(message(tr_lang, "Scale must be drawing:real, for example 1:10"), "Ölçek çizim:gerçek biçiminde olmalıdır, örneğin 1:10"); +} + /// The Catalan catalog registers under `ca`, claims complete menus and messages, and passes /// locale resolution, vocabulary and count formatting checks. #[test] diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 2b3e843ba3..09b9d27210 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -903,12 +903,12 @@ font-technology Variable Değişken Drag art here, or select it and click +. Hold Alt for one asset of several objects. Resmi buraya sürükleyin veya seçip + işaretine tıklayın. Birkaç nesneden oluşan bir varlık için Alt tuşunu basılı tutun. Drag onto art to apply this appearance Bu görünümü uygulamak için resmin üzerine sürükleyin Drag the art to move it on the paper Resmi kağıt üzerinde taşımak için sürükleyin - Draw Beraberlik + Draw Çiz Draw Behind Arkaya Çiz Draw in Perspective Perspektifte Çiz Draw Inside İçini Çiz Draw Normal Normal Çiz - Draw: Beraberlik: + Draw: Çiz: Drawing Mode Çizim Modu Drop Shadow Alt Gölge Drop Shadow… Alt Gölge… @@ -5714,7 +5714,7 @@ theme Appearance Görünüm Drawer menu Çekmece menüsü Toolbar: Show Controls Araç Çubuğu: Kontrolleri Göster @msg control must be fillStroke, coloring, drawMode or screenMode control fillStroke, coloring, drawMode veya screenMode olmalıdır -@msg unknown control `{name}`: fillStroke, coloring, drawMode or screenMode bilinmeyen kontrol `{name}`: fillStroke, colouring,drawMode veya screenMode +@msg unknown control `{name}`: fillStroke, coloring, drawMode or screenMode bilinmeyen kontrol `{name}`: fillStroke, coloring, drawMode veya screenMode @msg give a fraction `t` from 0 to 1 0'dan 1'e kadar bir `t` kesri verin @msg give the path `id` `id` yolunu ver @msg invalid stroke widths geçersiz kontur genişlikleri @@ -5734,7 +5734,7 @@ theme Appearance Görünüm Below Object Nesnenin altında Registration: Kayıt: Dimension Boyut - Drawing : real size — 1:10 labels 1 unit on the page as 10. Çizim : Gerçek boyut — 1:10 etiketler sayfada 10 adet olarak 1 adettir. + Drawing : real size — 1:10 labels 1 unit on the page as 10. Çizim : gerçek boyut — 1:10, sayfadaki 1 birimi 10 olarak etiketler. Preview (1 inch on the page) Önizleme (sayfada 1 inç) Set as Default Varsayılan Olarak Ayarla out from a highlighted edge to place its dimension | boyutunu yerleştirmek için vurgulanan kenardan dışarı | @@ -5772,7 +5772,7 @@ theme Appearance Görünüm @msg Measured segment no longer exists Ölçülen segment artık mevcut değil @msg Non-finite dimension Sonlu olmayan boyut @msg Only linear dimensions are horizontal or vertical Yalnızca doğrusal boyutlar yatay veya dikey olabilir -@msg Scale must be drawing:real, for example 1:10 Ölçek çizim olmalıdır:gerçek, örneğin 1:10 +@msg Scale must be drawing:real, for example 1:10 Ölçek çizim:gerçek biçiminde olmalıdır, örneğin 1:10 @msg Scale ratio must be between 1:1000000 and 1000000:1 Ölçek oranı 1:1000000 ile 1000000:1 arasında olmalıdır @msg Second measured segment no longer exists Ölçülen ikinci segment artık mevcut değil @msg Unknown dimension mode Bilinmeyen boyut modu From c7dacd432e5ab23f9eda67f23060c4cbe302ea22 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 20:43:43 -0800 Subject: [PATCH 06/52] Graphs: stroke edits of a design's art alone leave the new-art stroke as it is stroke.setAdvanced and the strokeMode of paint.editGradient decided whether they were edits for new art after the art of a graph design in a series had been left out, so a selection holding only that art changed the stroke the next object drawn takes. Both now decide it from the objects given or selected, as stroke.set does. --- crates/engine/src/cmd/gradient.rs | 8 ++++++-- crates/engine/src/cmd/graph/tests.rs | 14 ++++++++++++++ crates/engine/src/cmd/stroke.rs | 7 +++++-- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/gradient.rs b/crates/engine/src/cmd/gradient.rs index 30590d5364..d23906b6bb 100644 --- a/crates/engine/src/cmd/gradient.rs +++ b/crates/engine/src/cmd/gradient.rs @@ -530,7 +530,11 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result { check_switches(p, &["reverse", "dither"], C)?; check_numbers(p, &["angle"], C)?; let item = item.of_kind(s, !stroke); - let ids = super::appearance::drop_design_art(&s.doc()?.doc, item.targets(s, p)?); + let ids = item.targets(s, p)?; + // Objects were given or selected, even when all of them are graph design art left out below: the stroke mode is then + // not one for new art. + let selected = !ids.is_empty(); + let ids = super::appearance::drop_design_art(&s.doc()?.doc, ids); // How the gradient lies on a stroke (the panel's Stroke buttons). let mode = match str_param(p, "strokeMode") { None => None, @@ -605,7 +609,7 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result { Ok(()) })?; if let Some(m) = mode - && ids.is_empty() + && !selected && p.get("ids").is_none() && let Some(st) = s.new_art_stroke_mut() { diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index 8464f6d0b4..4e9d0c07b5 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -493,6 +493,20 @@ fn paint_commands_leave_a_graph_designs_art_out() { refused_on_design_art(&mut s, "paint.freeform.addPoint", json!({"ids": [art.0], "at": [0, 0]})); } +/// With only the art of a graph design selected, `stroke.setAdvanced` and the stroke mode of `paint.editGradient` act +/// as edits of a selection, not of the stroke the next object drawn takes. +#[test] +fn stroke_edits_of_selected_design_art_leave_the_new_art_stroke() { + let (mut s, _, _, art, _) = design_art(); + s.execute("select.set", &json!({"ids": [art.0]})).unwrap(); + let was = format!("{:?}", s.new_art_stroke()); + s.execute("stroke.setAdvanced", &json!({"arrowScale": [200, 200], "swapArrows": true})).unwrap(); + assert_eq!(format!("{:?}", s.new_art_stroke()), was, "stroke.setAdvanced"); + let mode = s.new_art_stroke().gradient_mode; + s.execute("paint.editGradient", &json!({"kind": "linear", "stroke": true, "strokeMode": "along"})).unwrap(); + assert_eq!(s.new_art_stroke().gradient_mode, mode, "paint.editGradient"); +} + /// `transparency.set` without `item` refuses the art of a graph design in a series, and given it with other objects /// sets only theirs. #[test] diff --git a/crates/engine/src/cmd/stroke.rs b/crates/engine/src/cmd/stroke.rs index 74aff88fdf..2a0159930d 100644 --- a/crates/engine/src/cmd/stroke.rs +++ b/crates/engine/src/cmd/stroke.rs @@ -512,7 +512,10 @@ fn stroke_advanced(s: &mut Session, p: &Value) -> Result { return Err(bad(C, "nothing to change")); } let item = item_target(s, p, C)?.of_kind(s, false); - let ids = super::appearance::drop_design_art(&s.doc()?.doc, item.targets(s, p)?); + let ids = item.targets(s, p)?; + let new_art = no_targets(&ids, p); + // The art of a graph design in a series keeps the design's stroke. + let ids = super::appearance::drop_design_art(&s.doc()?.doc, ids); let arrows_and_profile = |st: &mut StrokeLayer| { if let Some(sc) = scale { st.arrow_scale = sc; @@ -526,7 +529,7 @@ fn stroke_advanced(s: &mut Session, p: &Value) -> Result { } }; // As stroke.set: with nothing selected, for the next object drawn. - if no_targets(&ids, p) + if new_art && (scale.is_some() || swap || flip.is_some()) && let Some(st) = s.new_art_stroke_mut() { From ba3c5b0a3c7a159d099db6d217029ae068c2f039 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 21:04:08 -0800 Subject: [PATCH 07/52] .ai save: a top object with uneven sides stays a live envelope A top object whose top and bottom sides, or left and right sides, have different numbers of segments was written to the editing data as its distorted art. The writer now splits the side with fewer segments into as many as the side across from it has, each segment into equal spans of its curve, up to the 50 columns or rows the grid holds. The reader opens the grid's edge as the top object's outline with the same corners and the same mapping of the contents; the outline holds the added anchors. A split that would share out the contents another way, or a side of more than 50 segments, still writes the envelope as its art with the warning. --- crates/eps/src/ai_editing/tests.rs | 121 ++++++++++++++++-- crates/eps/src/ai_editing/writer/envelopes.rs | 90 ++++++++++--- docs/target-app-parity.md | 1 + 3 files changed, 184 insertions(+), 28 deletions(-) diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index 967c982ad4..bc9d3a5332 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -9689,7 +9689,7 @@ fn an_envelope_is_written_in_the_order_the_reader_reads_it() { /// Envelopes the reader would open in another shape, or not at all, are written as their distorted /// art with a warning, and open as that art: an envelope of type, one in a turned frame, a warp drawn /// without its style's net, a mesh the reader would lay out the other way round, a top object -/// whose sides don't pair up, a Fidelity that isn't a whole number and bounds margins kept with +/// with a side of more segments than the grid has columns, a Fidelity that isn't a whole number and bounds margins kept with /// Distort Appearance off. Other live objects keep their own warning. #[test] fn envelopes_the_reader_cannot_open_live_are_written_as_their_art() { @@ -9726,15 +9726,10 @@ fn envelopes_the_reader_cannot_open_live_are_written_as_their_art() { **kind = EnvelopeKind::Mesh { rows: 2, cols: 3, points: transpose(points, 3, 2), handles: vec![] }; } }); - let mut uneven = kurbo::BezPath::new(); - uneven.move_to((30.0, 10.0)); - for p in [(100.0, 0.0), (170.0, 10.0), (170.0, 90.0), (30.0, 90.0)] { - uneven.line_to(p); - } - uneven.close_path(); - let lopsided = change(&|k| { + // A top side of 51 segments: more grid columns than the reader reads. + let crowded = change(&|k| { if let NodeKind::Envelope { kind, .. } = k { - **kind = EnvelopeKind::TopObject { path: PathData::from_bezpath(&uneven), corners: None }; + **kind = EnvelopeKind::TopObject { path: PathData::from_bezpath(&uneven_top_object(51)), corners: None }; } }); let fractional = change(&|k| { @@ -9753,7 +9748,7 @@ fn envelopes_the_reader_cannot_open_live_are_written_as_their_art() { ("turned", turned), ("old warp", old_warp), ("sideways mesh", sideways), - ("lopsided top object", lopsided), + ("top object past the grid's limit", crowded), ("fractional fidelity", fractional), ("margin kept", margin_kept), ]; @@ -9774,6 +9769,112 @@ fn transpose(points: Vec, rows: usize, cols: usize) -> Vec kurbo::BezPath { + let mut path = kurbo::BezPath::new(); + path.move_to((30.0, 10.0)); + for k in 1..top { + path.line_to((30.0 + 140.0 * k as f64 / top as f64, if k % 2 == 1 { 20.0 } else { 10.0 })); + } + for p in [(170.0, 10.0), (170.0, 90.0), (30.0, 90.0)] { + path.line_to(p); + } + path.close_path(); + path +} + +/// Do the patches of top objects `a` and `b` lie within 0.001 pt of each other at every place of a +/// 32 × 32 grid? +fn same_top_object_patch(a: &vectorcraft_doc::live::EnvelopeKind, b: &vectorcraft_doc::live::EnvelopeKind) -> bool { + use vectorcraft_doc::live::{Coons, EnvelopeKind}; + let (EnvelopeKind::TopObject { path, corners }, EnvelopeKind::TopObject { path: p, corners: c }) = (a, b) else { return false }; + let (Some(a), Some(b)) = (Coons::from_top_object(path, *corners), Coons::from_top_object(p, *c)) else { return false }; + let at = |i: u32| f64::from(i) / 32.0; + (0..=32).all(|j| (0..=32).all(|i| a.eval(at(i), at(j)).distance(b.eval(at(i), at(j))) < 1e-3)) +} + +/// A top object whose top and bottom sides have different numbers of segments is written live, its +/// side with fewer segments split into as many as the side across from it has, with Edit Contents off +/// and on: two straight segments over one straight one, three over one, and two over one curve. +/// VectorCraft's reader opens it as a top object with its corners on the same four points, the same +/// patch, contents, Fidelity, Edit Contents and Envelope Options, and saved again it opens the same. +#[test] +fn a_top_object_with_uneven_sides_is_read_back_live() { + use vectorcraft_doc::live::{EnvelopeKind, EnvelopeOptions}; + let mut curved = kurbo::BezPath::new(); + curved.move_to((30.0, 10.0)); + curved.line_to((100.0, 0.0)); + curved.line_to((170.0, 10.0)); + curved.line_to((170.0, 90.0)); + curved.curve_to((130.0, 105.0), (70.0, 105.0), (30.0, 90.0)); + curved.close_path(); + let shapes = [("two over one", uneven_top_object(2), 6), ("three over one", uneven_top_object(3), 8), ("two over a curve", curved, 6)]; + for (name, path, anchors) in shapes { + let kind = EnvelopeKind::TopObject { path: PathData::from_bezpath(&path), corners: None }; + for editing in [false, true] { + let what = format!("{name}, editing {editing}"); + let doc = envelope_doc(kind.clone(), 50.0, editing, EnvelopeOptions::NEW); + let text = program(&doc); + assert!(text.contains("AIDeformOptionsKey") && text.contains("%_3 /Int (AIDeformStyle) ,\n"), "{what}: {text}"); + let (back, warnings, notes) = envelope_back(&doc); + assert!(!warnings.iter().any(|w| w == writer::ENVELOPES), "{what}: {warnings:?}"); + assert!(!notes.iter().any(|w| w == crate::import::ENVELOPE_DRAWN), "{what}: {notes:?}"); + let NodeKind::Envelope { content, kind: got, fidelity, editing: e, options, frame } = &back.kind else { panic!("{what}: {back:#?}") }; + assert_eq!((*fidelity, *e, *options, **frame), (50.0, editing, EnvelopeOptions::NEW, Affine::IDENTITY), "{what}"); + let EnvelopeKind::TopObject { path: outline, corners: Some(corners) } = &**got else { panic!("{what}: {got:?}") }; + let sp = &outline.subpaths[0]; + assert_eq!(sp.anchors.len(), anchors, "{what}: {outline:?}"); + let points = corners.map(|i| sp.anchors[i as usize].p); + let want = [(30.0, 10.0), (170.0, 10.0), (170.0, 90.0), (30.0, 90.0)].map(kurbo::Point::from); + assert!(points.iter().zip(want).all(|(a, b)| a.distance(b) < 1e-6), "{what}: {points:?}"); + assert!(same_top_object_patch(&kind, got), "{what}: {got:?}"); + let colors: Vec> = content.iter().map(|c| c.appearance.fill_paint().color()).collect(); + assert_eq!(colors, [Some(Color::rgb(1.0, 0.0, 0.0)), Some(Color::rgb(0.0, 0.0, 1.0))], "{what}"); + let mut again = Document::new(200.0, 100.0); + let layer = again.layers[0].id; + again.insert(Some(layer), usize::MAX, back.clone()).unwrap(); + let (twice, warnings, notes) = envelope_back(&again); + assert!(!warnings.iter().any(|w| w == writer::ENVELOPES), "{what}, saved again: {warnings:?}"); + let NodeKind::Envelope { kind: twice, .. } = &twice.kind else { panic!("{what}, saved again: {twice:#?} {notes:?}") }; + assert!(same_top_object_patch(&kind, twice), "{what}, saved again: {twice:?}"); + } + } +} + +/// A top object with a top side of 50 segments over a bottom side of one is written live with a grid +/// of 50 columns, the most the reader reads. +#[test] +fn a_top_object_split_to_the_grids_limit_is_read_back_live() { + use vectorcraft_doc::live::{EnvelopeKind, EnvelopeOptions}; + let kind = EnvelopeKind::TopObject { path: PathData::from_bezpath(&uneven_top_object(50)), corners: None }; + let doc = envelope_doc(kind.clone(), 50.0, false, EnvelopeOptions::NEW); + let text = program(&doc); + assert!(text.contains("[50 1] /Size X#"), "{text}"); + let (back, warnings, notes) = envelope_back(&doc); + assert!(!warnings.iter().any(|w| w == writer::ENVELOPES), "{warnings:?}"); + let NodeKind::Envelope { kind: got, .. } = &back.kind else { panic!("{back:#?} {notes:?}") }; + assert!(same_top_object_patch(&kind, got), "{got:?}"); +} + +/// A top object whose side with fewer segments would share out the content another way once split +/// (two curves that aren't one curve, under three segments) is written as its art, with a warning. +#[test] +fn a_top_object_whose_split_side_would_map_another_way_is_written_as_its_art() { + use vectorcraft_doc::live::{EnvelopeKind, EnvelopeOptions}; + let mut path = uneven_top_object(3); + path.truncate(5); + path.curve_to((150.0, 110.0), (120.0, 110.0), (100.0, 90.0)); + path.curve_to((80.0, 95.0), (40.0, 80.0), (30.0, 90.0)); + path.close_path(); + let kind = EnvelopeKind::TopObject { path: PathData::from_bezpath(&path), corners: None }; + let doc = envelope_doc(kind, 50.0, false, EnvelopeOptions::NEW); + assert!(!program(&doc).contains("AIDeformOptionsKey")); + let (back, warnings, _) = envelope_back(&doc); + assert!(warnings.iter().any(|w| w == writer::ENVELOPES), "{warnings:?}"); + assert!(matches!(back.kind, NodeKind::Group { .. }), "{back:#?}"); +} + /// An envelope with its contents in an opacity mask's art is written as its art there, and an /// envelope in a group stays live. #[test] diff --git a/crates/eps/src/ai_editing/writer/envelopes.rs b/crates/eps/src/ai_editing/writer/envelopes.rs index dcd7274291..bf01c276de 100644 --- a/crates/eps/src/ai_editing/writer/envelopes.rs +++ b/crates/eps/src/ai_editing/writer/envelopes.rs @@ -8,13 +8,14 @@ //! contents in an `X=` … `X+` block (while they are edited, the grid) as `AIDeformArtKey`, and the //! kind (`AIDeformStyle`: 1 a warp, 2 a mesh, 3 a top object); then the distorted art on plain //! lines, and the plugin's `XP` after it. A top object's outline is the edge of the grid written -//! for it, one grid edge to each segment of its sides. An envelope the reader would open in another -//! shape is written as its distorted art. +//! for it, one grid edge to each segment of its sides, a side with fewer segments than the side +//! across from it split into more. An envelope the reader would open in another shape is written as +//! its distorted art. use std::fmt::Write; use std::sync::Arc; -use kurbo::{Point, Vec2}; +use kurbo::{CubicBez, ParamCurve, Point, Vec2}; use vectorcraft_doc::live::{ Coons, EnvelopeKind, EnvelopeOptions, GradientMesh, H_DOWN, H_LEFT, H_RIGHT, H_UP, MeshPoint, PreserveShape, WARP_NET, envelope_content_boxes, envelope_grid, envelope_stroke_margin, warp_grid, @@ -173,11 +174,44 @@ fn reads_as_written(rows: usize, cols: usize, pts: &[GridPoint]) -> bool { envelope_upright(&mesh).is_some_and(|(r, c, read)| (r as usize, c as usize) == (rows, cols) && read == pts) } +/// `pieces`, one side of a top object, split into `n` pieces: each piece is split into equal spans +/// of its curve parameter, the extra pieces going one at a time to the piece with the longest +/// control polygon for each of its spans. `None` where `n` is fewer pieces than it has, more than +/// [`MAX_GRID`], or a split leaves a piece of no length. +fn split_to(pieces: &[CubicBez], n: usize) -> Option> { + if n < pieces.len() || n > MAX_GRID { + return None; + } + if n == pieces.len() { + return Some(pieces.to_vec()); + } + let polygon = |c: &CubicBez| c.p0.distance(c.p1) + c.p1.distance(c.p2) + c.p2.distance(c.p3); + let lengths: Vec = pieces.iter().map(polygon).collect(); + let mut parts = vec![1usize; pieces.len()]; + for _ in pieces.len()..n { + let most = (0..pieces.len()).max_by(|a, b| (lengths[*a] / parts[*a] as f64).total_cmp(&(lengths[*b] / parts[*b] as f64)))?; + parts[most] += 1; + } + let mut out = Vec::with_capacity(n); + for (c, p) in pieces.iter().zip(parts) { + for j in 0..p { + let piece = c.subsegment(j as f64 / p as f64..(j + 1) as f64 / p as f64); + if [piece.p1, piece.p2, piece.p3].iter().all(|q| q.distance(piece.p0) <= 1e-9) { + return None; + } + out.push(piece); + } + } + Some(out) +} + /// The grid a top object with outline `path` and corners `corners` is written with: its edge runs -/// through the outline's anchors clockwise from the top left corner, one grid edge to a segment, so -/// the top and bottom sides need as many segments as each other, as do the left and right sides; -/// the points inside blend the sides. `None` where that grid's edge, read back as the top object's -/// outline, doesn't give the same patch ([`Coons::from_top_object`]) at every place checked. +/// through the outline's anchors clockwise from the top left corner, one grid edge to a segment, +/// the points inside blending the sides. The top and bottom sides need as many grid edges as each +/// other, as do the left and right sides, so where one side of a pair has fewer segments than the +/// other, its segments are split ([`split_to`]) without changing its shape. `None` where that +/// grid's edge, read back as the top object's outline, doesn't give the same patch +/// ([`Coons::from_top_object`]) at every place checked. fn top_object_grid(path: &PathData, corners: Option<[u32; 4]>) -> Option<(usize, usize, Vec)> { let coons = Coons::from_top_object(path, corners)?; // The outline, as the patch takes it: the largest subpath. @@ -214,10 +248,35 @@ fn top_object_grid(path: &PathData, corners: Option<[u32; 4]>) -> Option<(usize, } }; let gap = |k: usize| (at[(k + 1) % 4] + count - at[k]) % count; - let (cols, rows) = (gap(0), gap(1)); - if gap(2) != cols || gap(3) != rows || cols > MAX_GRID || rows > MAX_GRID { + if (0..4).any(|k| gap(k) > MAX_GRID) { return None; } + // Each side's segments from its corner on, a straight one as a cubic with its handles a third of + // the way along, which the patch spreads evenly along, as it does a straight segment. + let side = |k: usize| -> Vec { + (0..gap(k)) + .map(|j| { + let i = (at[k] + j) % count; + let seg = sp.segment(i); + if sp.segment_is_line(i) { + CubicBez::new(seg.p0, seg.p0.lerp(seg.p3, 1.0 / 3.0), seg.p0.lerp(seg.p3, 2.0 / 3.0), seg.p3) + } else { + seg + } + }) + .collect() + }; + let mut sides = [side(0), side(1), side(2), side(3)]; + // Opposite sides take as many segments as each other: the one with fewer has some split. + for k in 0..2 { + let n = sides[k].len().max(sides[k + 2].len()); + for s in [k, k + 2] { + sides[s] = split_to(&sides[s], n)?; + } + } + let (cols, rows) = (sides[0].len(), sides[1].len()); + let edge: Vec = sides.concat(); + let total = edge.len(); let width = cols + 1; let mut pts: Vec = vec![(Point::ZERO, [Vec2::ZERO; 4]); (rows + 1) * width]; // Where the `k`th anchor from the top left corner goes, and the slots of the handles of the @@ -233,20 +292,15 @@ fn top_object_grid(path: &PathData, corners: Option<[u32; 4]>) -> Option<(usize, (rows - (k - 2 * cols - rows), 0, H_UP, H_DOWN) } }; - for k in 0..count { - let i = (at[0] + k) % count; - let seg = sp.segment(i); - // A straight segment's patch spreads evenly along it, as a cubic's with its handles a third - // of the way does. - let (p1, p2) = if sp.segment_is_line(i) { (seg.p0.lerp(seg.p3, 1.0 / 3.0), seg.p0.lerp(seg.p3, 2.0 / 3.0)) } else { (seg.p1, seg.p2) }; + for (k, seg) in edge.iter().enumerate() { let (r, c, out, into) = place(k); - let (nr, nc, _, _) = place((k + 1) % count); + let (nr, nc, _, _) = place((k + 1) % total); if let Some(q) = pts.get_mut(r * width + c) { q.0 = seg.p0; - q.1[out] = p1 - seg.p0; + q.1[out] = seg.p1 - seg.p0; } if let Some(q) = pts.get_mut(nr * width + nc) { - q.1[into] = p2 - seg.p3; + q.1[into] = seg.p2 - seg.p3; } } // The points inside, blended from the sides. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 296bd84b2b..342eb492b2 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1639,6 +1639,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur SVG import keeps attributes in other applications' XML namespaces (`plotter:pen="2"`) on the objects and layers they belong to, as it keeps their `data-*` attributes: copies, duplicates and `.vectorcraft` files keep them, and SVG export writes them back with each namespace declared once on the root (the rest of #522; `crates/svg/tests/it/foreign_attributes.rs`, `tests/attributes.rs`). A PDF-compatible `.ai` saved with Include Editing Data for Other Apps defines every symbol in its editing copy with its art, registration point and place in the Symbols panel's order, and writes its instances as symbol instances, which VectorCraft's `.ai` reader opens as symbols and instances again (`symbols_and_their_instances_are_read_back` in `crates/eps/src/ai_editing/tests.rs`; [file-format-parity.md](file-format-parity.md) lists the instances written as their art). A PDF-compatible `.ai` saved with Include Editing Data for Other Apps keeps envelopes made with a warp, a mesh or a top object editable: its editing copy holds each envelope's contents, shape, Fidelity, Envelope Options and Edit Contents state in the form VectorCraft's `.ai` envelope reader reads, beside its distorted art, so the file opens with the same live envelope, Edit Contents still switching between the contents and the envelope ([Edit the contents of envelopes](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/edit-the-contents-of-envelopes.html)). Envelopes of type, envelopes in opacity masks, pattern tiles or another envelope's contents, and envelopes the reader would open with another shape or other settings (a Fidelity that isn't a whole number, bounds margins kept with Distort Appearance off), and envelopes after the first 256 in a file are written as their distorted art, with a warning (`envelopes_are_read_back_live` and `envelopes_the_reader_cannot_open_live_are_written_as_their_art` in `crates/eps/src/ai_editing/tests.rs`). + A top object whose top and bottom sides, or left and right sides, have different numbers of segments is also saved live: the side with fewer segments is split into as many as the side across from it has (each segment into equal spans of its curve, a straight one at even steps), up to the 50 a side the grid holds, so the file opens with the same corners and the same mapping of the contents, its outline holding the added anchors. Where the split side would share out the contents another way, as a side of two curves that aren't one curve can, or a side has more than 50 segments, the envelope is written as its distorted art, with the warning (`a_top_object_with_uneven_sides_is_read_back_live`, `a_top_object_split_to_the_grids_limit_is_read_back_live` and `a_top_object_whose_split_side_would_map_another_way_is_written_as_its_art` in `crates/eps/src/ai_editing/tests.rs`). A PDF-compatible `.ai` saved with Include Editing Data for Other Apps writes eligible paths, compound paths and groups with live effects with an art style when the writer supports their appearance and effects (not a filled path that uses the even-odd rule, and none on a large canvas), which VectorCraft's `.ai` reader opens as the same appearance with its effects live (`objects_with_live_effects_are_read_back` and `every_effect_setting_written_on_an_object_is_read_back` in `crates/eps/src/ai_editing/tests.rs`; [file-format-parity.md](file-format-parity.md) lists the objects written without one and the effects they lose). The same holds for the objects of an opacity mask's art, a mask inside another mask's art and a hidden art root included, so a mask whose art has Offset Path opens drawing the image it masks as it did, with the effect live; geometry effects that no art style holds on a mask's art, such as Roughen, are written applied to the art, which opens drawing what it drew (`a_masked_image_with_live_offset_path_round_trips`, `hidden_and_nested_mask_art_keep_live_offset_path` and `a_masks_art_with_an_effect_no_style_holds_is_written_as_it_draws` in `crates/eps/src/ai_editing/tests.rs`). A PDF-compatible `.ai` saved with Include Editing Data for Other Apps writes point type, area type and type on a path as type in its editing copy, and objects next to it keep their own transparency (`crates/eps/src/ai_editing/tests.rs`, `cmd::fileio::tests_pdfedit::type_comes_back_from_the_editing_copy`; [file-format-parity.md](file-format-parity.md) lists what is written as outlines). From 9b65454983acae58ba214e02650cdf3f60ec1d29 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 21:25:54 -0800 Subject: [PATCH 08/52] Graphs: aligned rows given with a design's art stay rows of its stack The Appearance and effect commands leave the art of a graph design in a series out, and aligned rows were then matched against the first object left over. With the art given first, appearance.removeItem with index 1 and aligned: true on art [red fill, blue fill] and a mark [blue fill, stroke] removed the mark's stroke instead of its blue fill. When the first object given is design art, rows and effects given with aligned: true are now rows of the art's stack, and each other object edits the row or effect equal to it. An object without one is left as it is. This covers appearance.setItem, appearance.removeItem, appearance.duplicateItem, transparency.set with an item, effect.apply, appearance.addEffect and the effect commands that edit or remove an effect. When no object has one, appearance.setItem and appearance.removeItem change nothing, as they do for a row no object has, and the other commands are an error. appearance.moveItem, effect.move and appearance.duplicateItem with `to` compare the art's stack too, and are an error when it differs from the other objects'. The effect dialog reads the effects of such a row from the art's stack, and passes the art on with the other objects. docs/mcp.md states the rule. --- crates/engine/src/cmd/appearance.rs | 81 +++++++++++-- crates/engine/src/cmd/effectcmd.rs | 71 +++++++++--- crates/engine/src/cmd/graph/tests.rs | 165 +++++++++++++++++++++++++++ crates/engine/src/cmd/paint.rs | 13 ++- docs/mcp.md | 10 +- 5 files changed, 307 insertions(+), 33 deletions(-) diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 5896d39fd8..306ebc8557 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -84,7 +84,7 @@ pub fn specs() -> Vec { "Appearance Item", [], None, - "{index: paint-order item index, ids?|id? (default: the selection), target?: \"object\"|\"contents\", opacity?: 0..100, blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill), tint?: 0..100, keepModel?: bool (both as paint.setFill), aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} edit one fill/stroke of each target object's own appearance stack. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", + "{index: paint-order item index, ids?|id? (default: the selection), target?: \"object\"|\"contents\", opacity?: 0..100, blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill), tint?: 0..100, keepModel?: bool (both as paint.setFill), aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection; with the art of a graph design in a series given first, rows of the art's stack: docs/mcp.md)} edit one fill/stroke of each target object's own appearance stack. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", has_doc, set_item ), @@ -93,7 +93,7 @@ pub fn specs() -> Vec { "Remove Item", [], None, - "{index | indices: [..] (paint-order item indices), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} remove those fills/strokes from each target object's own stack. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", + "{index | indices: [..] (paint-order item indices), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection; with the art of a graph design in a series given first, rows of the art's stack: docs/mcp.md)} remove those fills/strokes from each target object's own stack. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", has_doc, remove_item ), @@ -111,7 +111,7 @@ pub fn specs() -> Vec { "Duplicate Item", ["Window", "Appearance"], None, - "{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (with several, of the lowest copy: they land together in their order; default: each right above its item; not with aligned rows of a mixed selection), contents?: count (with `to`: how many items paint below the Contents (Characters) row afterwards; default: it stays between the same items), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} copy fills/strokes with their effects, as Alt-dragging rows places them. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", + "{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (with several, of the lowest copy: they land together in their order; default: each right above its item; not with aligned rows of a mixed selection), contents?: count (with `to`: how many items paint below the Contents (Characters) row afterwards; default: it stays between the same items), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection; with the art of a graph design in a series given first, rows of the art's stack: docs/mcp.md)} copy fills/strokes with their effects, as Alt-dragging rows places them. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", has_doc, duplicate_item ), @@ -120,7 +120,7 @@ pub fn specs() -> Vec { "Reorder Appearance Item", [], None, - "{from: paint-order item index | \"contents\", indices?: [..] (several items instead of from: they move as one block in their order, the lowest landing at `to`; a block landing next to the Contents row keeps its lowest item's side), to, contents?: count, ids?|id?, target?, aligned?: bool (refused when the targets' appearances differ)} move fill/stroke `from` to paint-order index `to`; on a group, layer or type the Contents (Characters) row stays between the same items (a moved item landing next to it keeps its side) unless `contents` says how many items paint below it afterwards. from \"contents\": move that row so `to` items paint below the members (characters) and the rest above. On a path directly in a graph series (a series group's id stands for those paths, except with from \"contents\", which moves the group's own row), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {index: where the item landed | contents: the row's slot}", + "{from: paint-order item index | \"contents\", indices?: [..] (several items instead of from: they move as one block in their order, the lowest landing at `to`; a block landing next to the Contents row keeps its lowest item's side), to, contents?: count, ids?|id?, target?, aligned?: bool (refused when the targets' appearances differ, or differ from that of a graph design's art given first)} move fill/stroke `from` to paint-order index `to`; on a group, layer or type the Contents (Characters) row stays between the same items (a moved item landing next to it keeps its side) unless `contents` says how many items paint below it afterwards. from \"contents\": move that row so `to` items paint below the members (characters) and the rest above. On a path directly in a graph series (a series group's id stands for those paths, except with from \"contents\", which moves the group's own row), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions, and the art of a graph design in a series, which is left out: docs/mcp.md); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {index: where the item landed | contents: the row's slot}", has_doc, move_item ), @@ -186,11 +186,18 @@ impl Session { let mut ids = drop_design_art(&self.active()?.doc, appearance_targets(self, params).ok()?); let item = item_target(self, params, "effect.apply").ok()?; let anchor = item.anchor(self, &ids); - let node = self.active()?.doc.node(*ids.get(anchor)?)?; + // Aligned effects given with a graph design's art first are effects of the art's stack, which stays first; the + // Appearance panel's active row is a row of the first selected object's. + let of = match item { + ItemTarget::Item { explicit: false, .. } => None, + _ => design_reference(self, params, "effect.apply").ok()?, + }; + let own = *ids.get(anchor)?; + let node = self.active()?.doc.node(of.unwrap_or(own))?; let at = item.effects_item(&node.appearance, "effect.apply").ok()?; let effects = node.appearance.effects_at(at)?; ids.swap(0, anchor); - let ids: Vec = ids.into_iter().map(|id| id.0).collect(); + let ids: Vec = with_reference(of, &ids).into_iter().map(|id| id.0).collect(); let aligned = match item { ItemTarget::Item { aligned, .. } => aligned, ItemTarget::Top => aligned(params, "effect.apply").ok()?, @@ -304,6 +311,21 @@ impl ItemTarget { .collect() } + /// [`ItemTarget::per_target`], with an explicit aligned item a row of the stack of `of` (see [`design_reference`]), + /// which is not among `ids`, when there is one: each target edits the item equal to it there, and a target without + /// an equal item is left out (`None`). + pub(crate) fn per_target_of(self, s: &Session, of: Option, ids: &[NodeId]) -> Vec> { + let ItemTarget::Item { index, explicit: true, aligned: true } = self else { return self.per_target(s, ids) }; + let Some(d) = s.active().map(|st| &st.doc) else { return self.per_target(s, ids) }; + let Some(r) = of.and_then(|of| d.node(of)) else { return self.per_target(s, ids) }; + ids.iter() + .map(|id| { + let index = SharedStack::of(&[&*r.appearance, &*d.node(*id)?.appearance]).item_in(index, 1)?; + Some(ItemTarget::Item { index, explicit: true, aligned: true }) + }) + .collect() + } + /// Where among `ids` (the targets, in paint order) the object whose stack this item is a row of /// is: the first selected object (or the compound path holding it) for the active item, which /// need not paint first; the first target otherwise, or when that object is not among them. @@ -388,6 +410,32 @@ pub(crate) fn items_per_target(d: &vectorcraft_doc::Document, ids: &[NodeId], id } } +/// The art of a graph design in a graph series whose stack the aligned rows of `p` (`aligned: true`) are rows of: the +/// first of the [`appearance_targets`] when it is such art, which the edit leaves out ([`without_design_art`]). A +/// command reads it after its own target checks. +pub(crate) fn design_reference(s: &Session, p: &Value, cmd: &str) -> Result> { + if !aligned(p, cmd)? { + return Ok(None); + } + let ids = appearance_targets(s, p)?; + let d = &s.doc()?.doc; + Ok(ids.first().copied().filter(|id| super::graph::is_design_art(d, *id))) +} + +/// `ids` after `of` (see [`design_reference`]), when there is one. +pub(crate) fn with_reference(of: Option, ids: &[NodeId]) -> Vec { + of.into_iter().chain(ids.iter().copied()).collect() +} + +/// [`items_per_target`] of `ids`, with the aligned rows `idx` taken from the stack of `of` (see [`design_reference`]) +/// when there is one: a row a target has no equal of is left out of that target. +pub(crate) fn rows_per_target(d: &Document, of: Option, ids: &[NodeId], idx: &[usize], aligned: bool) -> Vec> { + match of { + Some(of) => items_per_target(d, &with_reference(Some(of), ids), idx, true).into_iter().skip(1).collect(), + None => items_per_target(d, ids, idx, aligned), + } +} + /// Whether the `target` param asks for the contents of groups and layers (see the module docs). fn targets_contents(p: &Value) -> Result { match p.get("target") { @@ -1057,7 +1105,8 @@ fn set_item(s: &mut Session, p: &Value) -> Result { // When every object comes out as it was (for example, when none has item `index`), the document // and its undo history stay as they are. let before = s.doc()?.doc.clone(); - let per = items_per_target(&before, &ids, &[idx], aligned(p, "appearance.setItem")?); + let of = design_reference(s, p, "appearance.setItem")?; + let per = rows_per_target(&before, of, &ids, &[idx], aligned(p, "appearance.setItem")?); super::overprint::edit_counted(s, "Appearance", |d| { let mut edited = Vec::new(); for (id, at) in ids.iter().zip(&per) { @@ -1144,7 +1193,8 @@ fn remove_item(s: &mut Session, p: &Value) -> Result { // When no object has an item at any of the indices, the document and its undo history stay as // they are. let before = s.doc()?.doc.clone(); - let per = items_per_target(&before, &ids, &idx, aligned(p, "appearance.removeItem")?); + let of = design_reference(s, p, "appearance.removeItem")?; + let per = rows_per_target(&before, of, &ids, &idx, aligned(p, "appearance.removeItem")?); super::overprint::edit_counted(s, "Remove Item", |d| { for (id, idx) in ids.iter().zip(&per) { if let Some(n) = d.node_mut(*id) { @@ -1185,12 +1235,19 @@ fn duplicate_item(s: &mut Session, p: &Value) -> Result { let mut first_at = None; let active = s.active_appearance_item.as_ref().and_then(|a| a.objects.first().copied()); let mut active_at = None; - // Aligned rows of a mixed selection are copied right above themselves in each object. - if aligned(p, C)? && to.is_some() && shared_stack(&s.doc()?.doc, &ids).is_some() { + // Aligned rows of a mixed selection are copied right above themselves in each object. Rows of a graph design's art, + // which is left out, go to `to` in an object whose stack is the same as the art's. + let of = design_reference(s, p, C)?; + if aligned(p, C)? + && to.is_some() + && (shared_stack(&s.doc()?.doc, &ids).is_some() || (of.is_some() && appearances_differ(&s.doc()?.doc, &with_reference(of, &ids)))) + { return Err(bad(C, "`to` takes the rows of one object's stack, not aligned rows")); } let before = s.doc()?.doc.clone(); - let per = items_per_target(&before, &ids, &idx, aligned(p, C)?); + // With `to`, the art's stack is the same as the object's, so its rows are the object's, each of which must exist. + let per = + if to.is_some() { items_per_target(&before, &ids, &idx, aligned(p, C)?) } else { rows_per_target(&before, of, &ids, &idx, aligned(p, C)?) }; s.edit("Duplicate Item", |d, _| { let mut any = false; // Copies that became the topmost visible row of their kind: the series stores their paint even when the object @@ -1305,7 +1362,7 @@ fn move_item(s: &mut Session, p: &Value) -> Result { } let ids = require_targets(s, p, C)?; // Where an aligned row would go in each of several different stacks isn't one place. - if aligned && appearances_differ(&s.doc()?.doc, &ids) { + if aligned && appearances_differ(&s.doc()?.doc, &with_reference(design_reference(s, p, C)?, &ids)) { return Err(bad(C, "rows of objects whose appearances differ can't be reordered together")); } if p.get("indices").is_some() { diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 9d86449bb9..7b3c205113 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -13,7 +13,9 @@ use vectorcraft_doc::{Document, Effect, Node, NodeKind, StrokeLayer}; use vectorcraft_geom::{FillRule, PathData}; use vectorcraft_render::effects; -use super::appearance::{ItemTarget, aligned, appearance_targets, edit_targets, index_param, item_indices, item_target, item_target_at}; +use super::appearance::{ + ItemTarget, aligned, appearance_targets, design_reference, edit_targets, index_param, item_indices, item_target, item_target_at, with_reference, +}; use super::expand::has_seeded; use super::rasterfx; use super::{CommandSpec, EngineError, NodeId, Result, Session, always, bad, check_switches, cmd, fileio, has_doc, str_param, switch_or}; @@ -67,7 +69,7 @@ fn effect_specs() -> Vec { "Remove Effect", [], None, - "{index: int (position in the effect list), indices?: [int] (several positions in the same list instead of index, removed in one undo step; every one must exist), item?: appearance item index|null (that fill/stroke's effects; omitted: the active item, else the object's), ids?|id?: [..], target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} → {ids}", + "{index: int (position in the effect list), indices?: [int] (several positions in the same list instead of index, removed in one undo step; every one must exist), item?: appearance item index|null (that fill/stroke's effects; omitted: the active item, else the object's), ids?|id?: [..], target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection; with the art of a graph design in a series given first, rows of the art's stack: docs/mcp.md)} → {ids}", has_doc, remove ), @@ -76,7 +78,7 @@ fn effect_specs() -> Vec { "Effect Options", [], None, - "{index: int, params?: {…} (merged into the current parameters), warpStyle?: string (an existing Warp style suffix, e.g. arc or flag; changes only an applied Warp in place; params are checked against the new style), threeDType?: plane|extrude|revolve|inflate (the 3D and Materials panel's object type: changes only an applied 3D effect in place into Rotate, Extrude & Bevel, Revolve or Inflate; the settings the new effect also takes keep their values, rotation angles wrapped into -180..180 outside Revolve, and the others start at its defaults; params then merge into the new effect's; the type the effect already has, with nothing else given, changes nothing and records no undo step), visible?: bool (any other value but null is refused, when a step plays too), item?: appearance item index|null (as effect.remove), ids?|id?: [..], target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} → {ids}", + "{index: int, params?: {…} (merged into the current parameters), warpStyle?: string (an existing Warp style suffix, e.g. arc or flag; changes only an applied Warp in place; params are checked against the new style), threeDType?: plane|extrude|revolve|inflate (the 3D and Materials panel's object type: changes only an applied 3D effect in place into Rotate, Extrude & Bevel, Revolve or Inflate; the settings the new effect also takes keep their values, rotation angles wrapped into -180..180 outside Revolve, and the others start at its defaults; params then merge into the new effect's; the type the effect already has, with nothing else given, changes nothing and records no undo step), visible?: bool (any other value but null is refused, when a step plays too), item?: appearance item index|null (as effect.remove), ids?|id?: [..], target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection; with the art of a graph design in a series given first, rows of the art's stack: docs/mcp.md)} → {ids}", has_doc, set_params ), @@ -85,7 +87,7 @@ fn effect_specs() -> Vec { "3D Lighting Preset", [], None, - "{index: int, preset: standard|diffuse|topLeft|right, item?: appearance item index|null (omitted: the active item, else the object's), ids?|id?: [..], target?: object|contents, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} replace an applied 3D effect's light rig and ambient with an original lighting preset; preserve shading, geometry and view; all targets must have a 3D effect at index other than Extrude & Bevel (Classic), Revolve (Classic) or Rotate (Classic), which have no lighting presets; one undo step → {ids}", + "{index: int, preset: standard|diffuse|topLeft|right, item?: appearance item index|null (omitted: the active item, else the object's), ids?|id?: [..], target?: object|contents, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection; with the art of a graph design in a series given first, rows of the art's stack: docs/mcp.md)} replace an applied 3D effect's light rig and ambient with an original lighting preset; preserve shading, geometry and view; all targets must have a 3D effect at index other than Extrude & Bevel (Classic), Revolve (Classic) or Rotate (Classic), which have no lighting presets; one undo step → {ids}", has_doc, lighting_preset ), @@ -103,7 +105,7 @@ fn effect_specs() -> Vec { "Duplicate Effect", [], None, - "{index: int, indices?: [int] (several positions in the same list instead of index; every one must exist), item?: appearance item index|null (as effect.remove), ids?|id?: [..], target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} insert a copy of each effect right after it, in one undo step → {ids}", + "{index: int, indices?: [int] (several positions in the same list instead of index; every one must exist), item?: appearance item index|null (as effect.remove), ids?|id?: [..], target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection; with the art of a graph design in a series given first, rows of the art's stack: docs/mcp.md)} insert a copy of each effect right after it, in one undo step → {ids}", has_doc, duplicate ), @@ -112,7 +114,7 @@ fn effect_specs() -> Vec { "Move Effect", [], None, - "{from: int|[int] (position in the source list; a list moves several effects of that list, which keep their order and land together), to: int (the position of the (first) moved effect in the destination list afterwards), fromItem?: appearance item index|null (the source list: that fill/stroke's effects, null the object's; omitted: the active item, else the object's), toItem?: item index|null (the destination list; default: the source list), copy?: bool (copy instead of move, as Alt-dragging the row), ids?|id?: [..], target?: \"object\"|\"contents\", aligned?: bool (refused when the targets' appearances differ)} reorder an effect or move it between the object and its fills/strokes, as one undo step → {ids, index, item}", + "{from: int|[int] (position in the source list; a list moves several effects of that list, which keep their order and land together), to: int (the position of the (first) moved effect in the destination list afterwards), fromItem?: appearance item index|null (the source list: that fill/stroke's effects, null the object's; omitted: the active item, else the object's), toItem?: item index|null (the destination list; default: the source list), copy?: bool (copy instead of move, as Alt-dragging the row), ids?|id?: [..], target?: \"object\"|\"contents\", aligned?: bool (refused when the targets' appearances differ, or differ from that of a graph design's art given first)} reorder an effect or move it between the object and its fills/strokes, as one undo step → {ids, index, item}", has_doc, move_effect ), @@ -296,14 +298,20 @@ fn ids_json(ids: &[NodeId]) -> Value { /// effects it edits (`None`: the object's own) and the effect's index there. With `aligned` (see /// [`ItemTarget::per_target`]) `item` and `index` are the first target's, and each other target /// edits its equal item, or its equal object-level effect, each target matched on its own whatever -/// the other targets have; in a target without them, `item` and `index` stand for themselves. +/// the other targets have; in a target without them, `item` and `index` stand for themselves. When the first object +/// given is the art of a graph design in a series ([`design_reference`]), which is left out, they are rows of its stack, +/// and a target without them is left out. pub(crate) fn effect_targets(s: &Session, p: &Value, item: ItemTarget, index: usize, cmd: &str) -> Result, usize)>> { let roots = edit_targets(s, p, cmd)?; + let of = reference_of(s, p, item, cmd)?; let d = &s.doc()?.doc; - let per = item.per_target(s, &roots); - let first = roots.first().and_then(|f| d.node(*f)); + let per = item.per_target_of(s, of, &roots); + let first = of.or_else(|| roots.first().copied()).and_then(|f| d.node(f)); // Whether `item` names an item in the stack it is a row of (else the edit is object-level there). - let anchor = roots.get(item.anchor(s, &roots)).and_then(|a| d.node(*a)); + let anchor = match of { + Some(of) => d.node(of), + None => roots.get(item.anchor(s, &roots)).and_then(|a| d.node(*a)), + }; let anchor_level = anchor.map(|a| item.effects_item(&a.appearance, cmd)).transpose()?.flatten(); let aligned = aligned(p, cmd)?; let mut out = Vec::with_capacity(roots.len()); @@ -312,7 +320,11 @@ pub(crate) fn effect_targets(s: &Session, p: &Value, item: ItemTarget, index: us let at = item.effects_item(&n.appearance, cmd)?; // An object-level effect this target shares with the first is where it is in this target. let index = match (first.filter(|_| aligned), at, anchor_level) { - (Some(f), None, None) => SharedStack::of(&[&*f.appearance, &*n.appearance]).effect_in(index, 1).unwrap_or(index), + (Some(f), None, None) => match SharedStack::of(&[&*f.appearance, &*n.appearance]).effect_in(index, 1) { + Some(i) => i, + None if of.is_some() && index != APPENDED => continue, + None => index, + }, _ => index, }; out.push((*id, at, index)); @@ -320,6 +332,19 @@ pub(crate) fn effect_targets(s: &Session, p: &Value, item: ItemTarget, index: us Ok(out) } +/// The effect index `effect.apply` gives [`effect_targets`] for the effect it appends, which names no effect the targets +/// have. +const APPENDED: usize = usize::MAX; + +/// The [`design_reference`] of an effect edit aimed at `item`: none for the Appearance panel's active row, which is a +/// row of the first selected object's stack. +fn reference_of(s: &Session, p: &Value, item: ItemTarget, cmd: &str) -> Result> { + if matches!(item, ItemTarget::Item { explicit: false, .. }) { + return Ok(None); + } + design_reference(s, p, cmd) +} + #[allow(clippy::too_many_arguments)] fn edit_effects( s: &mut Session, @@ -452,13 +477,18 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result { return Ok(json!({ "ids": [gid.0], "index": 0, "item": null, "grouped": true })); } let item = item_target(s, p, C)?; + // The item as each target has it, read before the effect changes their rows: a row of a graph design's art, which is + // left out of the targets, is the row equal to it there, and a target without one is left out. + let targets = edit_targets(s, p, C)?; + let of = reference_of(s, p, item, C)?; + let per: Vec<(NodeId, Option)> = targets.iter().copied().zip(item.per_target_of(s, of, &targets)).collect(); if effects::is_three_d(id) { - let targets = edit_targets(s, p, C)?; - for (target, at) in targets.iter().copied().zip(item.per_target(s, &targets)) { + for &(target, at) in &per { + let Some(at) = at else { continue }; let d = &s.doc()?.doc; let n = d.node(target).ok_or(EngineError::NoNode(target))?; // A new effect goes after the item's (or the object's) others. - let on = at.and_then(|at| at.effects_item(&n.appearance, C).ok()).flatten(); + let on = at.effects_item(&n.appearance, C).ok().flatten(); let on = on.and_then(|i| Some((Some(i), n.appearance.items.get(i)?.effects().len()))); let on = on.unwrap_or((None, n.appearance.effects.len())); effects::validate_three_d_at_in(Some(d), n, id, &effect.params, on).map_err(|e| bad(C, e))?; @@ -473,14 +503,19 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result { } } } - let ids = edit_effects(s, p, item, C, &label, "Apply Effect: select objects", 0, |fx, _| { + let ids = edit_effects(s, p, item, C, &label, "Apply Effect: select objects", APPENDED, |fx, _| { fx.push(effect.clone()); true })?; // The item the effect landed on in the object whose row the item is (the first selected object // for the active item, which applies where it fits), and the effect's index in that object. - let first = ids.get(item.anchor(s, &ids)).and_then(|id| s.doc().ok()?.doc.node(*id)); - let landed = first.and_then(|n| item.effects_item(&n.appearance, C).ok().flatten()); + let k = item.anchor(s, &ids); + let first = ids.get(k).and_then(|id| s.doc().ok()?.doc.node(*id)); + let at = match of { + Some(_) => ids.get(k).and_then(|id| per.iter().find(|(t, _)| t == id)).and_then(|(_, at)| *at).unwrap_or(item), + None => item, + }; + let landed = first.and_then(|n| at.effects_item(&n.appearance, C).ok().flatten()); let index = first.and_then(|n| n.appearance.effects_at(landed)).map_or(0, |fx| fx.len().saturating_sub(1)); Ok(json!({ "ids": ids_json(&ids), "index": index, "item": landed })) } @@ -609,7 +644,7 @@ fn move_effect(s: &mut Session, p: &Value) -> Result { let copy = switch_or(p, "copy", false, C)?; let roots = edit_targets(s, p, C)?; // Where an aligned effect would go in each of several different stacks isn't one place. - if aligned(p, C)? && super::appearance::appearances_differ(&s.doc()?.doc, &roots) { + if aligned(p, C)? && super::appearance::appearances_differ(&s.doc()?.doc, &with_reference(design_reference(s, p, C)?, &roots)) { return Err(bad(C, "effects of objects whose appearances differ can't be moved together")); } // Where the effect landed in each object it moved in, in the order of `ids`. diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index 7b7f9974af..f8157a9fe4 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -667,6 +667,171 @@ fn appearance_commands_edit_the_objects_given_with_a_graph_designs_art() { assert_eq!(rows(&s, mark).items.len(), mark_was.items.len() + 1); } +/// `design_art()` with the design's art painted with a red and a blue fill and series 1's first mark with a blue fill and +/// a black stroke, so that row 1 of the art (its blue fill) is row 0 of the mark: the graph, the art and the mark. +fn aligned_design_art() -> (Session, NodeId, NodeId, NodeId) { + let (mut s, id, _, art, mark) = design_art(); + let (red, blue) = (Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0)), Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 1.0))); + let mark_look = vectorcraft_doc::Appearance::basic(blue, Paint::solid(vectorcraft_color::Color::BLACK), 1.0); + let mut art_look = mark_look.clone(); + art_look.items = vec![art_look.items[0].clone(), art_look.items[0].clone()]; + *art_look.items[0].paint_mut() = red; + s.edit("Looks", |d, _| { + d.node_mut(art).unwrap().appearance = vectorcraft_doc::Shared::new(art_look.clone()); + d.node_mut(mark).unwrap().appearance = vectorcraft_doc::Shared::new(mark_look.clone()); + Ok(()) + }) + .unwrap(); + (s, id, art, mark) +} + +/// Aligned rows given with the art of a graph design in a series first are rows of that art's stack, which the commands +/// leave as it is: each other object edits the row equal to the given one, never the row at the same index. +#[test] +fn aligned_rows_of_a_graph_designs_art_edit_the_equal_rows_of_the_other_objects() { + let rows = |s: &Session, id: NodeId| (*s.doc().unwrap().doc.node(id).unwrap().appearance).clone(); + let green = Paint::solid(vectorcraft_color::Color::rgb(0.0, 1.0, 0.0)); + let (mut s, _, art, mark) = aligned_design_art(); + let (art_was, mark_was) = (rows(&s, art), rows(&s, mark)); + s.execute("appearance.removeItem", &json!({"ids": [art.0, mark.0], "index": 1, "aligned": true})).unwrap(); + assert_eq!(rows(&s, art), art_was); + assert_eq!(rows(&s, mark).items, mark_was.items[1..], "the mark loses its blue fill and keeps its stroke"); + s.execute("edit.undo", &json!({})).unwrap(); + + s.execute("appearance.setItem", &json!({"ids": [art.0, mark.0], "index": 1, "aligned": true, "color": "#00ff00"})).unwrap(); + assert_eq!(rows(&s, art), art_was); + assert_eq!((rows(&s, mark).items[0].paint(), &rows(&s, mark).items[1]), (&green, &mark_was.items[1])); + s.execute("edit.undo", &json!({})).unwrap(); + + s.execute("appearance.duplicateItem", &json!({"ids": [art.0, mark.0], "index": 1, "aligned": true})).unwrap(); + assert_eq!(rows(&s, art), art_was); + let fills: Vec = rows(&s, mark).items.iter().map(|it| it.is_fill()).collect(); + assert_eq!(fills, [true, true, false], "the mark's blue fill is copied, not its stroke"); + s.execute("edit.undo", &json!({})).unwrap(); + + s.execute("transparency.set", &json!({"ids": [art.0, mark.0], "item": 1, "aligned": true, "opacity": 40})).unwrap(); + assert_eq!(rows(&s, art), art_was); + assert_eq!((rows(&s, mark).items[0].opacity(), rows(&s, mark).items[1].opacity()), (0.4, 1.0)); + + // A row of the art past the end of the mark's stack. + let (mut s, _, art, mark) = aligned_design_art(); + s.edit("Rows", |d, _| { + let ap = &mut d.node_mut(art).unwrap().appearance; + let red = ap.items[0].clone(); + ap.items.insert(0, red); + Ok(()) + }) + .unwrap(); + s.execute("transparency.set", &json!({"ids": [art.0, mark.0], "item": 2, "aligned": true, "opacity": 40})).unwrap(); + assert_eq!((rows(&s, mark).items[0].opacity(), rows(&s, mark).items[1].opacity()), (0.4, 1.0)); + + // A row of the art (its red fill) that the mark has no equal of: transparency.set is an error, and appearance.setItem + // and appearance.removeItem leave the document as it is. + let mark_was = rows(&s, mark); + let e = s.execute("transparency.set", &json!({"ids": [art.0, mark.0], "item": 0, "aligned": true, "opacity": 40})).unwrap_err(); + assert!(e.to_string().contains("no appearance item 0"), "{e}"); + s.execute("appearance.setItem", &json!({"ids": [art.0, mark.0], "index": 0, "aligned": true, "color": "#00ff00"})).unwrap(); + s.execute("appearance.removeItem", &json!({"ids": [art.0, mark.0], "index": 0, "aligned": true})).unwrap(); + assert_eq!(rows(&s, mark), mark_was); +} + +/// Aligned effects given with the art of a graph design in a series first go on, and come off, the rows equal to the art's +/// row in the other objects; an object without an equal row is left as it is. +#[test] +fn aligned_effects_of_a_graph_designs_art_edit_the_equal_rows_of_the_other_objects() { + let rows = |s: &Session, id: NodeId| (*s.doc().unwrap().doc.node(id).unwrap().appearance).clone(); + let effects = |s: &Session, id: NodeId| rows(s, id).items.iter().map(|it| it.effects().len()).collect::>(); + let (mut s, id, art, mark) = aligned_design_art(); + // A second mark of series 1 with a green fill, which no row of the art equals, and the same black stroke. + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let other = series(&n, 1).children().unwrap()[1].id; + let mut other_look = rows(&s, mark); + *other_look.items[0].paint_mut() = Paint::solid(vectorcraft_color::Color::rgb(0.0, 1.0, 0.0)); + s.edit("Look", |d, _| { + d.node_mut(other).unwrap().appearance = vectorcraft_doc::Shared::new(other_look.clone()); + Ok(()) + }) + .unwrap(); + let (art_was, other_was) = (rows(&s, art), rows(&s, other)); + let r = s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [art.0, mark.0, other.0], "item": 1, "aligned": true})).unwrap(); + assert_eq!(rows(&s, art), art_was); + assert_eq!(effects(&s, mark), [1, 0], "the shadow goes on the mark's blue fill"); + assert_eq!(rows(&s, other), other_was, "the other mark has no blue fill"); + assert_eq!((&r["ids"], &r["item"], &r["index"]), (&json!([mark.0]), &json!(0), &json!(0)), "the reply names the mark's row"); + + // The art's blue fill with the same shadow as the mark's: removing it from the art's row takes it off the mark's. + s.edit("Shadow", |d, _| { + let row = d.node(mark).unwrap().appearance.items[0].clone(); + d.node_mut(art).unwrap().appearance.items[1] = row; + Ok(()) + }) + .unwrap(); + let art_was = rows(&s, art); + // The effect dialog reads the shadow from the art's row and passes the art on first. + let (target, fx) = s.appearance_effect_target(&json!({"ids": [art.0, mark.0], "item": 1, "aligned": true})).unwrap(); + assert_eq!((fx.len(), &target["ids"], &target["item"]), (1, &json!([art.0, mark.0]), &json!(1))); + s.execute("effect.remove", &json!({"ids": [art.0, mark.0], "item": 1, "index": 0, "aligned": true})).unwrap(); + assert_eq!(rows(&s, art), art_was); + assert_eq!(effects(&s, mark), [0, 0]); + + // An object-level effect of the art that the mark does not have: a new object-level effect still goes on the mark. + s.edit("Effect", |d, _| { + let shadow = d.node(art).unwrap().appearance.items[1].effects()[0].clone(); + d.node_mut(art).unwrap().appearance.effects.push(shadow); + Ok(()) + }) + .unwrap(); + let art_was = rows(&s, art); + s.execute("effect.apply", &json!({"effect": "distort.roughen", "ids": [art.0, mark.0], "item": null, "aligned": true})).unwrap(); + assert_eq!(rows(&s, art), art_was); + assert_eq!(rows(&s, mark).effects.len(), 1); + // The art's object-level shadow is not the mark's roughen: the effect dialog reads the art's, and removing it leaves + // the mark as it is. + let (_, fx) = s.appearance_effect_target(&json!({"ids": [art.0, mark.0], "item": null, "aligned": true})).unwrap(); + assert_eq!(fx.iter().map(|e| e.id.to_string()).collect::>(), ["stylize.dropShadow"]); + let mark_was = rows(&s, mark); + assert!(s.execute("effect.remove", &json!({"ids": [art.0, mark.0], "item": null, "index": 0, "aligned": true})).is_err()); + assert_eq!((rows(&s, art), rows(&s, mark)), (art_was.clone(), mark_was)); + // An object-level effect index past the end of the art's list names no effect of the mark either. + s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [mark.0], "item": null})).unwrap(); + let mark_was = rows(&s, mark); + assert!(s.execute("effect.remove", &json!({"ids": [art.0, mark.0], "item": null, "index": 1, "aligned": true})).is_err()); + assert_eq!((rows(&s, art), rows(&s, mark)), (art_was, mark_was)); +} + +/// Aligned rows of a graph design's art and of another object whose stack differs are not one place in both stacks, so +/// they are not reordered, and `to` is refused for their copies; with the same stacks, the copies go to `to`. +#[test] +fn aligned_rows_of_a_graph_designs_art_and_a_different_stack_are_not_moved() { + let rows = |s: &Session, id: NodeId| (*s.doc().unwrap().doc.node(id).unwrap().appearance).clone(); + let (mut s, _, art, mark) = aligned_design_art(); + let mark_was = rows(&s, mark); + assert!(s.execute("appearance.moveItem", &json!({"ids": [art.0, mark.0], "from": 1, "to": 0, "aligned": true})).is_err()); + assert_eq!(rows(&s, mark), mark_was); + assert!(s.execute("appearance.duplicateItem", &json!({"ids": [art.0, mark.0], "index": 1, "to": 0, "aligned": true})).is_err()); + assert_eq!(rows(&s, mark), mark_was); + s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [mark.0], "item": null})).unwrap(); + s.execute("effect.apply", &json!({"effect": "distort.roughen", "ids": [mark.0], "item": null})).unwrap(); + let mark_was = rows(&s, mark); + assert!(s.execute("effect.move", &json!({"ids": [art.0, mark.0], "from": 1, "to": 0, "aligned": true})).is_err()); + assert_eq!(rows(&s, mark), mark_was); + + // The mark with the art's stack. + let (mut s, _, art, mark) = aligned_design_art(); + let art_was = rows(&s, art); + s.edit("Look", |d, _| { + d.node_mut(mark).unwrap().appearance = vectorcraft_doc::Shared::new(art_was.clone()); + Ok(()) + }) + .unwrap(); + assert!(s.execute("appearance.duplicateItem", &json!({"ids": [art.0, mark.0], "indices": [1, 9], "to": 0, "aligned": true})).is_err()); + assert_eq!(rows(&s, mark), art_was, "a row that does not exist is refused"); + s.execute("appearance.duplicateItem", &json!({"ids": [art.0, mark.0], "index": 1, "to": 0, "aligned": true})).unwrap(); + assert_eq!(rows(&s, art), art_was); + let paints: Vec<_> = rows(&s, mark).items.iter().map(|it| it.paint().clone()).collect(); + assert_eq!(paints, [art_was.items[1].paint().clone(), art_was.items[0].paint().clone(), art_was.items[1].paint().clone()]); +} + /// The paint commands leave the art of a graph design in a series out, a copy of the design given by id with an item /// included. #[test] diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index 1f25f5b56f..02875517f7 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -81,7 +81,7 @@ pub fn specs() -> Vec { "Transparency", ["Window", "Transparency"], None, - "{ids?|id?, opacity?: 0..100, blend?: name, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, item?: appearance item index|null, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} for `ids`, the selection, or the object whose opacity mask is being edited; opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects; a gradient mesh point last clicked with the Mesh tool or Direct Selection takes the opacity itself). With an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, opacity and blend change that object for this instance only (isolate, knockout, knockoutShape, aligned and target are refused). On a path directly in a graph series (for a fill or stroke item, a series group's id stands for those paths), the graph also stores the opacity and blend mode of the object and of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores the label's object opacity and blend mode for every label of its kind, and with `item` its topmost visible fill and stroke rows too", + "{ids?|id?, opacity?: 0..100, blend?: name, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, item?: appearance item index|null, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection; with the art of a graph design in a series given first, rows of the art's stack: docs/mcp.md)} for `ids`, the selection, or the object whose opacity mask is being edited; opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects; a gradient mesh point last clicked with the Mesh tool or Direct Selection takes the opacity itself). With an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, opacity and blend change that object for this instance only (isolate, knockout, knockoutShape, aligned and target are refused). On a path directly in a graph series (for a fill or stroke item, a series group's id stands for those paths), the graph also stores the opacity and blend mode of the object and of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores the label's object opacity and blend mode for every label of its kind, and with `item` its topmost visible fill and stroke rows too", has_doc, transparency ), @@ -1106,12 +1106,21 @@ fn transparency(s: &mut Session, p: &Value) -> Result { }; // Opacity and blend belong to the targeted fill/stroke; isolate/knockout/knockoutShape stay object-level. let item_ids = if editing_mask { ids } else { row_targets(s, p, C)? }; + // The art of a graph design whose stack an explicit aligned item is a row of goes first: appearance.setItem leaves + // it as it is and matches the other objects' rows against it. + let of = if editing_mask || !explicit { None } else { super::appearance::design_reference(s, p, C)? }; if explicit { let d = &s.doc()?.doc; - if !item_ids.iter().any(|id| d.node(*id).is_some_and(|n| index < n.appearance.items.len())) { + // With such art, an object has the item when it has a row equal to the art's. + let found = match of { + Some(_) => super::appearance::rows_per_target(d, of, &item_ids, &[index], true).iter().any(|r| !r.is_empty()), + None => item_ids.iter().any(|id| d.node(*id).is_some_and(|n| index < n.appearance.items.len())), + }; + if !found { return Err(bad(C, format!("no appearance item {index}"))); } } + let item_ids = super::appearance::with_reference(of, &item_ids); let (opacity, blend) = (q.remove("opacity"), q.remove("blend")); if let Some(b) = &blend && b.as_str().and_then(BlendMode::parse).is_none() diff --git a/docs/mcp.md b/docs/mcp.md index e186501302..7b13e9eb68 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -826,7 +826,15 @@ given), `threeD.mapGraphic`, `paint.setFill`, `paint.setStroke`, `paint.swap`, `paint.default`, `paint.invert` and `paint.complement` with an `item`, `stroke.set`, `stroke.weightUp`, `stroke.weightDown`, `stroke.setAdvanced`, `paint.editGradient`, `paint.setGradientGeom` and `object.setOverprint` leave the art out without an error. When the first selected object is design art, the commands -do not use the Appearance panel's active row. To change the art, edit the design with `graph.design`. +do not use the Appearance panel's active row. When the first object given is design art, rows and effects given with +`aligned: true` are rows of its stack in `appearance.setItem`, `appearance.removeItem`, `appearance.duplicateItem`, +`transparency.set` with an `item`, `effect.apply`, `appearance.addEffect` and the other `effect.*` commands that edit, +move or remove an effect: each other object edits the row or effect equal to it, and an object without one is left as +it is. When no object has one, `appearance.setItem` and `appearance.removeItem` leave the document as it is, as they do +for a row no object has, and the other commands are an error. `appearance.moveItem`, `effect.move` and +`appearance.duplicateItem` with `to` are an error when the art's stack differs from the other objects', and +`appearance.duplicateItem` with `to` is also an error when two or more other objects are given, as it is without the +art. To change the art, edit the design with `graph.design`. The graph also stores a paint or stroke weight that one of these commands changes on the topmost visible fill or stroke of a path directly in a series: a weight set with `stroke.set`, `stroke.weightUp` or `stroke.weightDown`; a gradient From f4281d3cc0bb536dc471839ee75df6dabe9a4c86 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 04:18:45 -0800 Subject: [PATCH 09/52] Graphs: test the effect edits on rows of a design's art, document aligned on effect.apply effect.apply and appearance.addEffect take `aligned`, and their params text now says so, with the rule for a graph design's art given first. The tests add appearance.addEffect, a 3D effect.apply with an object that has no equal row, effect.setParams and effect.duplicate on a row of the art, and the removal of an object-level effect equal to the art's. They also check that appearance.setItem and appearance.removeItem on a row no other object has leave the art as it is and record no undo step. --- crates/engine/src/cmd/appearance.rs | 2 +- crates/engine/src/cmd/effectcmd.rs | 2 +- crates/engine/src/cmd/graph/tests.rs | 33 +++++++++++++++++++++++++--- 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 306ebc8557..09b03f2d63 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -102,7 +102,7 @@ pub fn specs() -> Vec { "Add Effect", [], None, - "{…as effect.apply} same as effect.apply (an alias): {effect: id, params?: {…}, item?: appearance item index|null, ids?, target?} → {ids, index, item}", + "{…as effect.apply} same as effect.apply (an alias): {effect: id, params?: {…}, item?: appearance item index|null, ids?, target?, aligned?} → {ids, index, item}", has_doc, super::effectcmd::apply ), diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 7b3c205113..7619444c74 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -51,7 +51,7 @@ fn effect_specs() -> Vec { "Apply Effect", [], None, - "{effect: id (see effect.list, e.g. \"stylize.dropShadow\", \"distort.roughen\", \"warp.arc\", or \"plugin.\" for an installed effect plug-in), params?: {…} (missing keys take the dialog defaults), item?: appearance item index|null (apply to that fill/stroke only; omitted: the Appearance panel's active item, else the whole object), ids?|id?: [..] (layers too), target?: \"object\"|\"contents\" (contents: the objects inside groups and layers)} append a live effect to each selected object's appearance (a group's or layer's apply to its members as one piece: one combined shadow; a Pathfinder effect, which combines a group's contents, groups several loose selected objects first, in the same undo step); the art of a graph design in a graph series is left out, and objects that are all such art are an error (docs/mcp.md) → {ids, index, item, grouped?}", + "{effect: id (see effect.list, e.g. \"stylize.dropShadow\", \"distort.roughen\", \"warp.arc\", or \"plugin.\" for an installed effect plug-in), params?: {…} (missing keys take the dialog defaults), item?: appearance item index|null (apply to that fill/stroke only; omitted: the Appearance panel's active item, else the whole object), ids?|id?: [..] (layers too), target?: \"object\"|\"contents\" (contents: the objects inside groups and layers), aligned?: bool (item is a row of the first target's stack: each other target gets the effect on its equal one, as the Appearance panel does for a mixed selection; with the art of a graph design in a series given first, a row of the art's stack: docs/mcp.md)} append a live effect to each selected object's appearance (a group's or layer's apply to its members as one piece: one combined shadow; a Pathfinder effect, which combines a group's contents, groups several loose selected objects first, in the same undo step); the art of a graph design in a graph series is left out, and objects that are all such art are an error (docs/mcp.md) → {ids, index, item, grouped?}", has_doc, apply ), diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index f8157a9fe4..df002ae526 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -727,12 +727,14 @@ fn aligned_rows_of_a_graph_designs_art_edit_the_equal_rows_of_the_other_objects( // A row of the art (its red fill) that the mark has no equal of: transparency.set is an error, and appearance.setItem // and appearance.removeItem leave the document as it is. - let mark_was = rows(&s, mark); + let (art_was, mark_was) = (rows(&s, art), rows(&s, mark)); + let steps = s.doc().unwrap().history.undo.len(); let e = s.execute("transparency.set", &json!({"ids": [art.0, mark.0], "item": 0, "aligned": true, "opacity": 40})).unwrap_err(); assert!(e.to_string().contains("no appearance item 0"), "{e}"); s.execute("appearance.setItem", &json!({"ids": [art.0, mark.0], "index": 0, "aligned": true, "color": "#00ff00"})).unwrap(); s.execute("appearance.removeItem", &json!({"ids": [art.0, mark.0], "index": 0, "aligned": true})).unwrap(); - assert_eq!(rows(&s, mark), mark_was); + assert_eq!((rows(&s, art), rows(&s, mark)), (art_was, mark_was)); + assert_eq!(s.doc().unwrap().history.undo.len(), steps, "no undo step is recorded"); } /// Aligned effects given with the art of a graph design in a series first go on, and come off, the rows equal to the art's @@ -758,6 +760,13 @@ fn aligned_effects_of_a_graph_designs_art_edit_the_equal_rows_of_the_other_objec assert_eq!(effects(&s, mark), [1, 0], "the shadow goes on the mark's blue fill"); assert_eq!(rows(&s, other), other_was, "the other mark has no blue fill"); assert_eq!((&r["ids"], &r["item"], &r["index"]), (&json!([mark.0]), &json!(0), &json!(0)), "the reply names the mark's row"); + s.execute("edit.undo", &json!({})).unwrap(); + // A 3D effect is checked on the rows it goes on only. + s.execute("effect.apply", &json!({"effect": "threeD.extrude", "ids": [art.0, mark.0, other.0], "item": 1, "aligned": true})).unwrap(); + assert_eq!((rows(&s, art), effects(&s, mark), rows(&s, other)), (art_was.clone(), vec![1, 0], other_was.clone())); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("appearance.addEffect", &json!({"effect": "stylize.dropShadow", "ids": [art.0, mark.0, other.0], "item": 1, "aligned": true})).unwrap(); + assert_eq!((rows(&s, art), effects(&s, mark), rows(&s, other)), (art_was, vec![1, 0], other_was)); // The art's blue fill with the same shadow as the mark's: removing it from the art's row takes it off the mark's. s.edit("Shadow", |d, _| { @@ -770,6 +779,14 @@ fn aligned_effects_of_a_graph_designs_art_edit_the_equal_rows_of_the_other_objec // The effect dialog reads the shadow from the art's row and passes the art on first. let (target, fx) = s.appearance_effect_target(&json!({"ids": [art.0, mark.0], "item": 1, "aligned": true})).unwrap(); assert_eq!((fx.len(), &target["ids"], &target["item"]), (1, &json!([art.0, mark.0]), &json!(1))); + // Edits of the art's row effect change the mark's equal row. + s.execute("effect.setParams", &json!({"ids": [art.0, mark.0], "item": 1, "index": 0, "visible": false, "aligned": true})).unwrap(); + assert_eq!(rows(&s, art), art_was); + assert!(!rows(&s, mark).items[0].effects()[0].visible, "the mark's shadow is hidden"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("effect.duplicate", &json!({"ids": [art.0, mark.0], "item": 1, "index": 0, "aligned": true})).unwrap(); + assert_eq!((rows(&s, art), effects(&s, mark)), (art_was.clone(), vec![2, 0])); + s.execute("edit.undo", &json!({})).unwrap(); s.execute("effect.remove", &json!({"ids": [art.0, mark.0], "item": 1, "index": 0, "aligned": true})).unwrap(); assert_eq!(rows(&s, art), art_was); assert_eq!(effects(&s, mark), [0, 0]); @@ -796,7 +813,17 @@ fn aligned_effects_of_a_graph_designs_art_edit_the_equal_rows_of_the_other_objec s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [mark.0], "item": null})).unwrap(); let mark_was = rows(&s, mark); assert!(s.execute("effect.remove", &json!({"ids": [art.0, mark.0], "item": null, "index": 1, "aligned": true})).is_err()); - assert_eq!((rows(&s, art), rows(&s, mark)), (art_was, mark_was)); + assert_eq!((rows(&s, art), rows(&s, mark)), (art_was.clone(), mark_was)); + // The mark with the art's object-level shadow after its roughen: removing the art's effect 0 takes off the mark's 1. + s.edit("Shadow", |d, _| { + let shadow = d.node(art).unwrap().appearance.effects[0].clone(); + d.node_mut(mark).unwrap().appearance.effects[1] = shadow; + Ok(()) + }) + .unwrap(); + s.execute("effect.remove", &json!({"ids": [art.0, mark.0], "item": null, "index": 0, "aligned": true})).unwrap(); + assert_eq!(rows(&s, art), art_was); + assert_eq!(rows(&s, mark).effects.iter().map(|e| e.id.to_string()).collect::>(), ["distort.roughen"]); } /// Aligned rows of a graph design's art and of another object whose stack differs are not one place in both stacks, so From 9c787d2cdc88f98668e59d29dfe135a2ced60a93 Mon Sep 17 00:00:00 2001 From: ruchirguitar <113586074+ruchirguitar@users.noreply.github.com> Date: Sat, 14 Nov 2026 19:00:49 +0530 Subject: [PATCH 10/52] Brushes: built-in Tech Circuit brush library G5: add original procedural circuit art and pattern brushes, library regression coverage, translations, and progress documentation. --- ROADMAP.md | 5 +- crates/engine/src/cmd/brushlib.rs | 54 ++++++++++++ crates/engine/src/tests/brushlib.rs | 84 ++++++++++++++++++- crates/ui-egui/src/i18n/bg.tsv | 6 ++ crates/ui-egui/src/i18n/bs.tsv | 6 ++ crates/ui-egui/src/i18n/ca.tsv | 6 ++ crates/ui-egui/src/i18n/cs.tsv | 6 ++ crates/ui-egui/src/i18n/da.tsv | 6 ++ crates/ui-egui/src/i18n/de.tsv | 6 ++ crates/ui-egui/src/i18n/el.tsv | 6 ++ crates/ui-egui/src/i18n/es.tsv | 6 ++ crates/ui-egui/src/i18n/fi.tsv | 6 ++ crates/ui-egui/src/i18n/fr.tsv | 6 ++ crates/ui-egui/src/i18n/gu.tsv | 6 ++ crates/ui-egui/src/i18n/hi.tsv | 6 ++ crates/ui-egui/src/i18n/hu.tsv | 6 ++ crates/ui-egui/src/i18n/id.tsv | 6 ++ crates/ui-egui/src/i18n/it.tsv | 6 ++ crates/ui-egui/src/i18n/ja.tsv | 6 ++ crates/ui-egui/src/i18n/ka.tsv | 6 ++ crates/ui-egui/src/i18n/ko.tsv | 6 ++ crates/ui-egui/src/i18n/nl.tsv | 6 ++ crates/ui-egui/src/i18n/pl.tsv | 6 ++ crates/ui-egui/src/i18n/pt-br.tsv | 6 ++ crates/ui-egui/src/i18n/ru.tsv | 6 ++ crates/ui-egui/src/i18n/sv.tsv | 6 ++ crates/ui-egui/src/i18n/tests.rs | 2 +- crates/ui-egui/src/i18n/tr.tsv | 6 ++ crates/ui-egui/src/i18n/uk.tsv | 6 ++ crates/ui-egui/src/i18n/vi.tsv | 6 ++ crates/ui-egui/src/i18n/zh-hans.tsv | 6 ++ crates/ui-egui/src/i18n/zh-hant.tsv | 6 ++ .../src/panels/tests_brush_libraries.rs | 9 +- docs/gaps.md | 12 +-- docs/mcp.md | 7 +- docs/roadmap.md | 5 +- docs/target-app-parity.md | 13 +-- docs/ui-parity.md | 6 +- 38 files changed, 344 insertions(+), 21 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 346c532277..b5f2d1c0fa 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: Raster effects 85% → 88%, gallery thumbnails and in-dialog preview in; headline numbers unchanged; Symbols area ~87% → ~88%: dynamic instance parts take opacity, blend, gradient and pattern on the canvas; G5 docs sync for Overlap and Pattern auto corners; previously minor: Tools stays beside the canvas with left-side panels; Hindi catalog draft complete under the translation tests; contributor loanword terminology reviewed; wider proofreading welcome; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-14 · **Change:** minor (Tech Circuit brush library, G5; previously: docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: Raster effects 85% → 88%, gallery thumbnails and in-dialog preview in; headline numbers unchanged; Symbols area ~87% → ~88%: dynamic instance parts take opacity, blend, gradient and pattern on the canvas; G5 docs sync for Overlap and Pattern auto corners; previously minor: Tools stays beside the canvas with left-side panels; Hindi catalog draft complete under the translation tests; contributor loanword terminology reviewed; wider proofreading welcome; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON @@ -124,6 +124,8 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap ## Progress log +- 2026-11-14: Built-in Tech Circuit brush library: Circuit Trace (chamfered art with 45-degree elbows and terminal pads), Bus Lines (parallel conductor pattern) and Terminal Nodes (repeating solder pads, test points and leads), all original geometry generated in code. Ten built-in brush libraries; G5 remaining estimate 4–14 h (estimated), headline numbers unchanged. + - 2026-11-05: Window › Toolbars › Keep Tools Beside the Canvas, off by default, docks the Tools panel between left-side panels and the canvas instead of at the window's left edge, and the canvas's left edge then also docks a floating Tools panel (`window.toolsBesideCanvas`, #5395). Workspaces keep the setting; workspaces saved without it keep Tools at the window's edge. An optional VectorCraft layout choice; headline numbers unchanged. - 2026-11-12: Stencil Bridges in the Cutting Tools add-on (Object › Plug-ins › Cutting Tools): strips of a set width are cut out of the selected art so that every island of a stencil cut from it (the inside of an O, a hole in a shape) stays attached to the sheet, by the shortest crossings first, vertical, horizontal or the shortest among 12 directions 15° apart, with up to 16 bridges per island; type is measured by its glyph outlines and stays type, and the original goes to a hidden layer or is replaced. One undo step (`addon.cutting.bridges`). A VectorCraft addition: headline numbers unchanged. - 2026-11-13: Banner Hems and Grommets in the Cutting Tools add-on (Object › Plug-ins › Cutting Tools): on the chosen sides of the selected art's bounds, or of each artboard, a hem line at a set depth inside the edge and grommet marks (a circle with a cross) at a set inset, one at each end of a side and the rest spread evenly at most a set spacing apart, stroked in a chosen swatch; a dialog with Preview, one undo step (`addon.cutting.banner`). A VectorCraft addition: headline numbers unchanged. @@ -221,6 +223,7 @@ The full earlier record of what landed is kept in [docs/roadmap.md](docs/roadmap | Date | Change | Summary | |---|---|---| +| 2026-11-14 | minor | Tech Circuit adds three original procedural brushes (Circuit Trace, Bus Lines, Terminal Nodes); ten built-in brush libraries. G5 remaining estimate 4–14 h (estimated); parity scores and headline totals unchanged. | | 2026-11-14 | minor | Gujarati (#6613): contributor proofreading completed for all 387 additions, with 11 wording corrections; 4,673 reviewed catalog rows. Status/error messages remain partial; web selection requires a bundled Gujarati face. Scores and estimates unchanged. | | 2026-11-10 | minor | Tidy-up, no content change: the unheaded change bullets at the top of the file and a revision row inside the headline-numbers table move to this table under their commit dates. Scores and estimates unchanged. | | 2026-11-10 | minor | Raster effects: Effect Gallery thumbnails and an in-dialog preview (an original drawing through each filter). Features-table Raster effects 85% → 88%, hours 1–2 → none. Headline numbers unchanged, so the progress log is unchanged. | diff --git a/crates/engine/src/cmd/brushlib.rs b/crates/engine/src/cmd/brushlib.rs index 16ab1c76d4..34becd7c02 100644 --- a/crates/engine/src/cmd/brushlib.rs +++ b/crates/engine/src/cmd/brushlib.rs @@ -178,6 +178,7 @@ pub const BUILTIN: &[BuiltinBrushLibrary] = &[ 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 }, + BuiltinBrushLibrary { id: "tech-circuit", name: "Tech Circuit", make: tech_circuit }, ]; /// Built-in library `id`, made once. @@ -397,6 +398,59 @@ fn border(name: &str, side: Node, corner: Option, fit: PatternFit) -> Brus Brush { name: name.into(), kind } } +/// Tech Circuit: original conductor geometry, drawn as filled paths so widths and chamfers +/// survive brush warping. Pattern tiles meet at x = 0 and 24 with identical lead cross-sections. +fn tech_circuit() -> BrushLibrary { + let pad = |x: f64| { + black(polygon(&[ + (x - 4.0, -2.0), + (x - 2.0, -4.0), + (x + 2.0, -4.0), + (x + 4.0, -2.0), + (x + 4.0, 2.0), + (x + 2.0, 4.0), + (x - 2.0, 4.0), + (x - 4.0, 2.0), + ])) + }; + // 45-degree jogs lift the conductor to a parallel run and bring it back. + let trace = group(vec![ + black(polygon(&[ + (4.0, -1.0), + (23.0, -1.0), + (33.0, -11.0), + (61.0, -11.0), + (71.0, -1.0), + (96.0, -1.0), + (96.0, 1.0), + (70.0, 1.0), + (60.0, -9.0), + (34.0, -9.0), + (24.0, 1.0), + (4.0, 1.0), + ])), + pad(4.0), + pad(96.0), + ]); + let bus = group([-4.0, 0.0, 4.0].into_iter().map(|y| black(shapes::rectangle(Rect::new(0.0, y - 0.75, 24.0, y + 0.75)))).collect()); + let terminals = group(vec![ + // Overlap the ring's rim without filling the test point's hole. + black(shapes::rectangle(Rect::new(0.0, -0.6, 15.5, 0.6))), + black(shapes::rectangle(Rect::new(18.5, -0.6, 24.0, 0.6))), + pad(6.0), + ring(Point::new(17.0, 0.0), 3.0, 1.4), + ]); + built( + "Tech Circuit", + Document::new(100.0, 100.0), + vec![ + art("Circuit Trace", trace, Colorization::Tints), + border("Bus Lines", bus, None, PatternFit::Stretch), + border("Terminal Nodes", terminals, None, PatternFit::Stretch), + ], + ) +} + /// An invisible rectangle that fixes a tile's width. fn spacer(r: Rect) -> Node { Node::path(NodeId(0), shapes::rectangle(r), Appearance::default()) diff --git a/crates/engine/src/tests/brushlib.rs b/crates/engine/src/tests/brushlib.rs index 98a577abed..4f0b739951 100644 --- a/crates/engine/src/tests/brushlib.rs +++ b/crates/engine/src/tests/brushlib.rs @@ -115,7 +115,8 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() { "scatter-shapes", "game-trails", "energy-beams", - "dry-media" + "dry-media", + "tech-circuit" ] ); assert!(libs.iter().all(|l| l["category"] == "builtIn")); @@ -217,6 +218,87 @@ fn black_stroke() -> vectorcraft_doc::StrokeLayer { vectorcraft_doc::StrokeLayer::new(vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), 1.0) } +/// Original circuit art and repeat tiles survive adding, applying and saving a library. +#[test] +fn the_tech_circuit_library_constructs_adds_and_applies_each_brush() { + let mut s = session(); + let listed = run(&mut s, "brush.libraries", json!({})); + let entry = listed["libraries"].as_array().unwrap().iter().find(|l| l["id"] == "tech-circuit").unwrap(); + assert_eq!((entry["name"].as_str(), entry["category"].as_str(), entry["count"].as_u64()), (Some("Tech Circuit"), Some("builtIn"), Some(3))); + let (_, lib) = cmd::brushlib::library(&s, "Tech Circuit").unwrap(); + let expected = [("Circuit Trace", "art"), ("Bus Lines", "pattern"), ("Terminal Nodes", "pattern")]; + assert_eq!(lib.brushes.iter().map(|b| (b.name.as_str(), b.kind.type_id())).collect::>(), expected); + let path = run(&mut s, "shape.line", json!({"x1": 10, "y1": 20, "x2": 210, "y2": 20}))["id"].as_u64().unwrap(); + let curve = { + let mut p = line_to(40.0); + p.curve_to((60.0, -40.0), (140.0, 40.0), (200.0, 0.0)); + p + }; + let corner = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, 80.0, 60.0)).to_bezpath(); + for b in &lib.brushes { + let mut sanitized = b.clone(); + sanitized.sanitize(); + assert_eq!(&sanitized, b, "{} changes on load", b.name); + for tile in b.art() { + let pts = anchors(&[tile]); + assert!(!pts.is_empty() && pts.len() <= 100, "{}", b.name); + assert!(pts.iter().all(|p| p.x.is_finite() && p.y.is_finite())); + tile.walk(&mut |n| { + if let Some(p) = n.path_data() { + assert!(p.subpaths.iter().all(|s| s.closed)); + } + }); + } + if let BrushKind::Pattern(p) = &b.kind { + assert_eq!(p.colorization, vectorcraft_brush::Colorization::Tints); + assert_eq!(p.spacing, 0.0, "conductors join adjacent tiles"); + if b.name == "Bus Lines" { + let lanes = p.side.children().unwrap(); + assert_eq!(lanes.len(), 3); + let boxes: Vec<_> = lanes.iter().map(|n| n.path_data().unwrap().bounds().unwrap()).collect(); + for r in &boxes { + assert_eq!((r.x0, r.x1), (0.0, 24.0)); + } + assert!(boxes.windows(2).all(|r| r[0].y1 < r[1].y0), "lanes stay separate"); + } + if b.name == "Terminal Nodes" { + let mut rings = 0; + p.side.walk(&mut |n| { + if matches!(n.kind, NodeKind::Compound { rule: vectorcraft_geom::FillRule::EvenOdd, .. }) { + rings += 1; + } + }); + assert_eq!(rings, 1, "a hollow circular test point per tile"); + } + } + for p in [line_to(200.0), line_to(2.0), curve.clone(), corner.clone()] { + let pieces = vectorcraft_brush::stroke_pieces(&lib.doc, b, &p, &black_stroke()); + let pts = anchors(&pieces.iter().collect::>()); + assert!(!pts.is_empty() && pts.iter().all(|p| p.x.is_finite() && p.y.is_finite()), "{}", b.name); + } + let before = undo_len(&s); + let r = run(&mut s, "brush.addFromLibrary", json!({"library": "tech-circuit", "name": b.name, "apply": true, "ids": [path]})); + assert_eq!(strs(&r["added"]), [b.name.as_str()]); + assert_eq!(r["applied"], json!([path])); + assert_eq!(undo_len(&s), before + 1); + assert_eq!(brush(&s, &b.name), *b); + assert_eq!(doc(&s).node(NodeId(path)).unwrap().appearance.stroke().unwrap().brush.as_deref(), Some(b.name.as_str())); + run(&mut s, "edit.undo", json!({})); + assert!(vectorcraft_brush::find(doc(&s), &b.name).is_none()); + run(&mut s, "edit.redo", json!({})); + let before = undo_len(&s); + let again = run(&mut s, "brush.addFromLibrary", json!({"library": "tech-circuit", "name": b.name})); + assert!(strs(&again["added"]).is_empty()); + assert_eq!(strs(&again["existing"]), [b.name.as_str()]); + assert_eq!(undo_len(&s), before); + } + let names: Vec<&str> = expected.iter().map(|(name, _)| *name).collect(); + let saved = run(&mut s, "brush.saveLibrary", json!({"names": names})); + let bytes = vectorcraft_format::base64_decode(saved["dataBase64"].as_str().unwrap()).unwrap(); + let reopened = vectorcraft_format::load(&bytes).unwrap(); + assert_eq!(vectorcraft_brush::library(&reopened), lib.brushes); +} + /// Ink and Wash: a tapered ink art brush, a brush pen whose size follows the pen pressure and two /// translucent washes, with names of their own and a few paths of art each. #[test] diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index f0ab11fc4a..5f18cf1905 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -6886,3 +6886,9 @@ breakText Glyphs Глифи Grommets Капси Grommet Inset Отстъп на капсите от ръба Diameter Диаметър + +# Original procedural circuit brushes. + Tech Circuit Технологични схеми + Circuit Trace Проводяща писта + Bus Lines Шинни линии + Terminal Nodes Крайни възли diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index f73b5a407f..ec1ef5dbc7 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -6939,3 +6939,9 @@ shape Open Otvoren Grommets Ušice Grommet Inset Udaljenost ušica od ruba Diameter Prečnik + +# Original procedural circuit brushes. + Tech Circuit Tehnički sklopovi + Circuit Trace Vod sklopa + Bus Lines Linije sabirnice + Terminal Nodes Priključni čvorovi diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index ddfed44ad0..efbeafa352 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -7479,3 +7479,9 @@ breakText Glyphs Glifs Grommets Ullets Grommet Inset Distància dels ullets a la vora Diameter Diàmetre + +# Original procedural circuit brushes. + Tech Circuit Circuits tecnològics + Circuit Trace Pista de circuit + Bus Lines Línies de bus + Terminal Nodes Nodes terminals diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 3b46d0600a..9c1cf25c80 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -9311,3 +9311,9 @@ breakText Glyphs Glyfy @msg turn on at least one side zapněte alespoň jednu stranu @msg with no hem and no grommets there is nothing to mark bez lemu a bez průchodek není co označit @msg {saving} failed: {e} {saving} se nezdařilo: {e} + +# Original procedural circuit brushes. + Tech Circuit Technické obvody + Circuit Trace Vodivá cesta + Bus Lines Sběrnicové linky + Terminal Nodes Koncové uzly diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 81b6195bdd..8bc75321dc 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -6901,3 +6901,9 @@ breakText Glyphs Glyffer Grommets Maljer Grommet Inset Maljernes afstand fra kanten Diameter Diameter + +# Original procedural circuit brushes. + Tech Circuit Tekniske kredsløb + Circuit Trace Kredsløbsbane + Bus Lines Buslinjer + Terminal Nodes Terminalpunkter diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 8f6bc1aeeb..39bf7b6771 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -8899,3 +8899,9 @@ breakText Glyphs Glyphen Grommets Ösen Grommet Inset Randabstand der Ösen Diameter Durchmesser + +# Original procedural circuit brushes. + Tech Circuit Technische Schaltungen + Circuit Trace Leiterbahn + Bus Lines Busleitungen + Terminal Nodes Anschlussknoten diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 7b61589c18..ca4b2ecb34 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -7451,3 +7451,9 @@ breakText Glyphs Γλύφοι Grommets Τρουκς Grommet Inset Απόσταση τρουκς από την άκρη Diameter Διάμετρος + +# Original procedural circuit brushes. + Tech Circuit Τεχνολογικά κυκλώματα + Circuit Trace Διαδρομή κυκλώματος + Bus Lines Γραμμές διαύλου + Terminal Nodes Τερματικοί κόμβοι diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 89ea2c088c..cd0da036e2 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -7442,3 +7442,9 @@ breakText Glyphs Glifos Grommets Ojales Grommet Inset Distancia de los ojales al borde Diameter Diámetro + +# Original procedural circuit brushes. + Tech Circuit Circuitos tecnológicos + Circuit Trace Pista de circuito + Bus Lines Líneas de bus + Terminal Nodes Nodos terminales diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 655936f6c2..9d2d54e441 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -7533,3 +7533,9 @@ breakText Glyphs Merkit Grommets Purjerenkaat Grommet Inset Purjerenkaiden etäisyys reunasta Diameter Halkaisija + +# Original procedural circuit brushes. + Tech Circuit Tekniset virtapiirit + Circuit Trace Piirijohdin + Bus Lines Väylälinjat + Terminal Nodes Liitäntäpisteet diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 2396640141..debc9cc3ac 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -9297,3 +9297,9 @@ breakText Glyphs Glyphes Grommets Œillets Grommet Inset Distance des œillets au bord Diameter Diamètre + +# Original procedural circuit brushes. + Tech Circuit Circuits techniques + Circuit Trace Piste de circuit + Bus Lines Lignes de bus + Terminal Nodes Nœuds terminaux diff --git a/crates/ui-egui/src/i18n/gu.tsv b/crates/ui-egui/src/i18n/gu.tsv index 716d851655..2d9da396f1 100644 --- a/crates/ui-egui/src/i18n/gu.tsv +++ b/crates/ui-egui/src/i18n/gu.tsv @@ -4680,3 +4680,9 @@ swatch [Registration] [રજિસ્ટ્રેશન] Repeat Objects પુનરાવર્તિત ઑબ્જેક્ટ્સ @msg `{path}` must be one of {_1}, not {got} `{path}` એ {_1}માંથી એક હોવું જોઈએ, {got} નહીં Path Length પાથની લંબાઈ + +# Original procedural circuit brushes. + Tech Circuit ટેક સર્કિટ + Circuit Trace સર્કિટ પથ + Bus Lines બસ રેખાઓ + Terminal Nodes ટર્મિનલ નોડ્સ diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 4a11fad73d..88c326c315 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -8889,3 +8889,9 @@ theme Appearance अपीयरेंस Grommets आईलेट Grommet Inset किनारे से आईलेट की दूरी Diameter व्यास + +# Original procedural circuit brushes. + Tech Circuit टेक सर्किट + Circuit Trace सर्किट पथ + Bus Lines बस रेखाएँ + Terminal Nodes टर्मिनल नोड diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 925670bbc3..40367ce58e 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -7482,3 +7482,9 @@ breakText Glyphs Glifák Grommets Ringlik Grommet Inset Ringlik távolsága a széltől Diameter Átmérő + +# Original procedural circuit brushes. + Tech Circuit Műszaki áramkörök + Circuit Trace Vezetősáv + Bus Lines Buszvonalak + Terminal Nodes Csatlakozási pontok diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index f8069f6a71..15defcd4e4 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -6901,3 +6901,9 @@ breakText Glyphs Glif Grommets Mata ayam Grommet Inset Jarak mata ayam dari tepi Diameter Diameter + +# Original procedural circuit brushes. + Tech Circuit Sirkuit teknologi + Circuit Trace Jalur sirkuit + Bus Lines Jalur bus + Terminal Nodes Simpul terminal diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index af875b4e42..8f2bbb06f5 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -8932,3 +8932,9 @@ breakText Glyphs Glifi Grommets Occhielli Grommet Inset Distanza degli occhielli dal bordo Diameter Diametro + +# Original procedural circuit brushes. + Tech Circuit Circuiti tecnologici + Circuit Trace Pista del circuito + Bus Lines Linee del bus + Terminal Nodes Nodi terminali diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index e6d40c9ae5..78fce27c04 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -7194,3 +7194,9 @@ breakText Glyphs 字形 Grommets ハトメ Grommet Inset 端からハトメまでの距離 Diameter 直径 + +# Original procedural circuit brushes. + Tech Circuit 電子回路 + Circuit Trace 回路配線 + Bus Lines バス配線 + Terminal Nodes 端子ノード diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index fc649cd87a..6b106f971a 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -7612,3 +7612,9 @@ font-script Japanese იაპონური Grommets ლუვერები Grommet Inset ლუვერების დაშორება კიდიდან Diameter დიამეტრი + +# Original procedural circuit brushes. + Tech Circuit ტექნიკური სქემები + Circuit Trace სქემის გამტარი + Bus Lines სალტის ხაზები + Terminal Nodes ტერმინალური კვანძები diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 71a45e307d..57a34e6f42 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -8903,3 +8903,9 @@ breakText Glyphs 글리프 @msg {what} would print more than {MAX_COPIES_PER_SHEET} copies on a sheet: scale it up {what}은(는) 한 장에 {MAX_COPIES_PER_SHEET}개보다 많이 인쇄됩니다: 크기를 키우세요 @msg {what} would print on more than {MAX_TILES} tiles: scale it down {what}은(는) 타일 {MAX_TILES}개보다 많이 나뉘어 인쇄됩니다: 크기를 줄이세요 @msg {what} {i} out of range {what} {i}이(가) 범위를 벗어났습니다 + +# Original procedural circuit brushes. + Tech Circuit 전자 회로 + Circuit Trace 회로 배선 + Bus Lines 버스 배선 + Terminal Nodes 단자 노드 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index a0953094fa..0d27abcbd9 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -7525,3 +7525,9 @@ breakText Glyphs Glyphs Grommets Zeilringen Grommet Inset Afstand van zeilringen tot de rand Diameter Diameter + +# Original procedural circuit brushes. + Tech Circuit Technische schakelingen + Circuit Trace Printspoor + Bus Lines Buslijnen + Terminal Nodes Aansluitpunten diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 5b7b3e60ac..d9364a341c 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -9165,3 +9165,9 @@ breakText Glyphs Glify @msg {what} can't be intertwined {what} nie można przeplatać @msg {what} doesn't fit the paper with its bleed and marks {what} nie mieści się na papierze wraz ze spadem i znacznikami @msg {what} would print on more than {MAX_TILES} tiles: scale it down {what}: wydruk zająłby ponad {MAX_TILES} kafelków: zmniejsz skalę + +# Original procedural circuit brushes. + Tech Circuit Obwody techniczne + Circuit Trace Ścieżka obwodu + Bus Lines Linie magistrali + Terminal Nodes Węzły końcowe diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 90825f967f..43b60ffd34 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -9383,3 +9383,9 @@ swatch Yellow Green Verde-amarelado @msg turn on at least one side ative pelo menos um lado @msg with no hem and no grommets there is nothing to mark sem bainha e sem ilhoses não há nada para marcar @msg {saving} failed: {e} {saving} falhou: {e} + +# Original procedural circuit brushes. + Tech Circuit Circuitos tecnológicos + Circuit Trace Trilha de circuito + Bus Lines Linhas de barramento + Terminal Nodes Nós terminais diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index a235e2c9b3..0ab13230a4 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -7703,3 +7703,9 @@ shape Open Открытый @msg {width} × {height} pixels is too large for JPEG (at most 65535 pixels a side): lower the resolution {width} × {height} пикселей — слишком большой размер для JPEG (не более 65535 пикселей по стороне): уменьшите разрешение @msg {width} × {height} pixels is too large for Targa (at most 65535 pixels a side): lower the resolution {width} × {height} пикселей — слишком большой размер для Targa (не более 65535 пикселей по стороне): уменьшите разрешение @msg {w} × {h} pixels is too large for PSD (at most {MAX_SIDE} pixels a side): lower the resolution {w} × {h} пикселей — слишком большой размер для PSD (не более {MAX_SIDE} пикселей по стороне): уменьшите разрешение + +# Original procedural circuit brushes. + Tech Circuit Технические схемы + Circuit Trace Дорожка схемы + Bus Lines Линии шины + Terminal Nodes Контактные узлы diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index b491d33649..e15d91dd80 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -8973,3 +8973,9 @@ digits Hindi Hindi Grommets Öljetter Grommet Inset Öljetternas avstånd från kanten Diameter Diameter + +# Original procedural circuit brushes. + Tech Circuit Tekniska kretsar + Circuit Trace Kretsbana + Bus Lines Bussledningar + Terminal Nodes Anslutningspunkter diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 4f9f03e5f6..5dab646c88 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -389,7 +389,7 @@ fn built_in_library_brush_names_are_in_complete_catalogs() { .collect(); names.sort(); names.dedup(); - assert_eq!(names.len(), 43, "{names:?}"); + assert_eq!(names.len(), 46, "{names:?}"); let mut bad = vec![]; for lang in LANGUAGES.iter().filter(|lang| lang.complete_menus) { let missing: Vec<&String> = names.iter().filter(|n| lang.catalog().plain(n).is_none()).collect(); diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 0ecfe1dd0a..1bf602077a 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -7454,3 +7454,9 @@ breakText Glyphs Glifler Grommets Kuşgözleri Grommet Inset Kuşgözlerinin kenara uzaklığı Diameter Çap + +# Original procedural circuit brushes. + Tech Circuit Teknik devreler + Circuit Trace Devre izi + Bus Lines Veriyolu hatları + Terminal Nodes Bağlantı düğümleri diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index c375bedfa2..d98c716f3b 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -8919,3 +8919,9 @@ breakText Glyphs Гліфи @msg turn on at least one side увімкніть принаймні одну сторону @msg with no hem and no grommets there is nothing to mark без підгину й люверсів немає чого розмічати @msg {saving} failed: {e} {saving}: не вдалося: {e} + +# Original procedural circuit brushes. + Tech Circuit Технічні схеми + Circuit Trace Доріжка схеми + Bus Lines Лінії шини + Terminal Nodes Контактні вузли diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 442690385b..0a3d4c17e4 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -7109,3 +7109,9 @@ breakText Glyphs Glyph Grommets Khoen Grommet Inset Khoảng cách từ mép đến khoen Diameter Đường kính + +# Original procedural circuit brushes. + Tech Circuit Mạch công nghệ + Circuit Trace Đường mạch + Bus Lines Đường bus + Terminal Nodes Nút đầu cuối diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 810b1b4463..a5680844a7 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -9267,3 +9267,9 @@ breakText Glyphs 字形 @msg {width} × {height} pixels is too large for JPEG (at most 65535 pixels a side): lower the resolution {width} × {height} 像素对 JPEG 而言过大 (每边最多 65535 像素):请降低分辨率 @msg {width} × {height} pixels is too large for Targa (at most 65535 pixels a side): lower the resolution {width} × {height} 像素对 Targa 而言过大 (每边最多 65535 像素):请降低分辨率 @msg {w} × {h} pixels is too large for PSD (at most {MAX_SIDE} pixels a side): lower the resolution {w} × {h} 像素对 PSD 而言过大 (每边最多 {MAX_SIDE} 像素):请降低分辨率 + +# Original procedural circuit brushes. + Tech Circuit 电子电路 + Circuit Trace 电路走线 + Bus Lines 总线 + Terminal Nodes 端子节点 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 19c572e3b3..3658e174b0 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -8959,3 +8959,9 @@ breakText Glyphs 字符 @msg {what} can't be intertwined {what} 無法交織 @msg {what} doesn't fit the paper with its bleed and marks {what} 連同出血和標記無法容納於紙張 @msg {what} would print on more than {MAX_TILES} tiles: scale it down {what} 會列印在超過 {MAX_TILES} 個拼貼上:請縮小 + +# Original procedural circuit brushes. + Tech Circuit 電子電路 + Circuit Trace 電路走線 + Bus Lines 匯流排 + Terminal Nodes 端子節點 diff --git a/crates/ui-egui/src/panels/tests_brush_libraries.rs b/crates/ui-egui/src/panels/tests_brush_libraries.rs index 6da376c7ca..87fe34953f 100644 --- a/crates/ui-egui/src/panels/tests_brush_libraries.rs +++ b/crates/ui-egui/src/panels/tests_brush_libraries.rs @@ -200,7 +200,8 @@ fn the_window_menu_and_the_brushes_panel_open_brush_libraries() { json!("scatter-shapes"), json!("game-trails"), json!("energy-beams"), - json!("dry-media") + json!("dry-media"), + json!("tech-circuit") ] ); let dry = entries.iter().find(|e| under(e) && e.params["library"] == "dry-media").map(|e| e.label.as_str()); @@ -225,6 +226,12 @@ fn the_window_menu_and_the_brushes_panel_open_brush_libraries() { 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); + let r = app.run("window.brushLibrary", json!({"library": "Tech Circuit"})).unwrap(); + assert_eq!((r["open"].as_str(), r["count"].as_u64()), (Some("tech-circuit"), Some(3))); + let undo = undo_len(&app); + click(&mut app, &ctx, "Circuit Trace", Modifiers::NONE, 2.0); + assert!(has(&app, "Circuit Trace") && !has(&app, "Bus Lines")); + 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 8aea143a47..9f5a151ac9 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-13 · **Change:** minor (3D: the seams of white-light Extrude & Bevel, Inflate and Rotate, and the face seams of the 3D (Classic) effects, have round joins instead of miters; previously: PDF import: preserve Type 3 font text runs as named groups , or as live text in TextAs::Text mode if ToUnicode exists on non-colour fonts (TextAs::Outlines produces grouped outlines), #6740; scores and estimates unchanged; previously: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: oversized editing artboard import; Glyphs panel: Alternates for Current Selection applies an OpenType feature value (`salt=2`) to the selected letter alone, so an Arabic word stays joined, and Entire Font lists the unencoded glyphs, #5514; #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-14 · **Change:** minor (Tech Circuit brush library, G5; previously: 3D: the seams of white-light Extrude & Bevel, Inflate and Rotate, and the face seams of the 3D (Classic) effects, have round joins instead of miters; previously: PDF import: preserve Type 3 font text runs as named groups , or as live text in TextAs::Text mode if ToUnicode exists on non-colour fonts (TextAs::Outlines produces grouped outlines), #6740; scores and estimates unchanged; previously: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: oversized editing artboard import; Glyphs panel: Alternates for Current Selection applies an OpenType feature value (`salt=2`) to the selected letter alone, so an Arabic word stays joined, and Entire Font lists the unencoded glyphs, #5514; #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: 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 @@ -34,6 +34,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-14 | minor | G5: Tech Circuit adds three original procedural brushes (Circuit Trace, Bus Lines, Terminal Nodes); ten built-in brush libraries. G5 remaining estimate 4–14 h (estimated); parity scores and headline totals unchanged. | | 2026-11-14 | minor | G12: Layers panel row context menu (Options…, Duplicate, Delete, Lock Others/Show All/Hide Others/Unlock All, Locate Object, Collect for Export) with selection handling and undo tracking. Scores and estimates unchanged. | | 2026-11-13 | minor | PDF import: preserve Type 3 font text runs as named groups ``, or as live text in `TextAs::Text` mode if `/ToUnicode` exists on non-colour fonts (`TextAs::Outlines` produces grouped outlines) (#6740); scores and estimates unchanged. | | 2026-11-08 | minor | #3380, deletion only: preserve gradient placement and exact surviving geometry; consume owned subpaths with a shared original-node snapshot; consolidate status/evidence and document command/helper contracts. Compound behavior and the existing M2 status entry are retained. Endpoint highlighting remains open; scores and estimates unchanged. | @@ -648,8 +649,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 (nine original libraries generated in code: Art Ribbons, Ink and Wash, Calligraphic Nibs, Bristle Brushes (one - bristle brush for each tip shape), 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 + its own (ten original libraries generated in code: Art Ribbons, Ink and Wash, Calligraphic Nibs, Bristle Brushes (one + bristle brush for each tip shape), Pattern Borders, Scatter Shapes, Game Trails, Energy Beams, Dry Media (grainy chalk, pencil, crayon and charcoal art brushes) and Tech Circuit (Circuit Trace, Bus Lines and Terminal Nodes); 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 @@ -670,10 +671,10 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi Fine Grain, Canvas Weave, Burlap Sack, Cracked Glaze, Mosaic Floor, Quilted Squares, Leaded Glass and Damp Paper, painted with the texture effects; User Defined; Other Library…; `graphicStyle.libraries`, `graphicStyle.addFromLibrary`). -- **Missing:** more built-in brush libraries beyond the eight shipped (and further graphic-style libraries beyond the ten shipped, and further symbol libraries beyond Basic +- **Missing:** more built-in brush libraries beyond the ten shipped (and further graphic-style libraries beyond the ten 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 rotated copies as matches. -- **Estimate:** 5–15 h. +- **Estimate:** 4–14 h (estimated). ## G3 3D and Materials @@ -876,6 +877,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | Date | Change | Summary | |---|---|---| +| 2026-11-14 | minor | Tech Circuit adds three original procedural brushes (Circuit Trace, Bus Lines, Terminal Nodes); ten built-in brush libraries. G5 remaining estimate 4–14 h (estimated); parity scores and headline totals unchanged. | | 2026-11-12 | minor | 3D: The seams of Extrude & Bevel, Inflate and Rotate under a white light, and of the faces of Extrude & Bevel (Classic), Revolve (Classic) and Rotate (Classic), went from miter to round joins (the seams of Revolve and of colored lights, and 3D (Classic) Wireframe edges, were already round): no thorns on edge-on facets, and facets with no neighbour now have rounded convex corners ([three-d.md](three-d.md#seams)). Scores and estimates unchanged. | | 2026-11-12 | minor | Explicit editing-data import keeps a single artboard beyond a capped 14,400 pt PDF page (#6865), with a warning; automatic mode stays unchanged. Scores and estimates unchanged. | | 2026-11-09 | minor | G4 review (#6084): positional variants no supported feature draws can be applied as glyph choices in both Glyphs views; unlisted glyphs still respect name ownership. OpenType style options keep only a valid last on-value when enabling a set. Existing contextual/ligature lookup and unsupported-feature gaps remain; estimate unchanged. | diff --git a/docs/mcp.md b/docs/mcp.md index ca77311c58..05188c05c5 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1,6 +1,6 @@ # VectorCraft MCP server -> **Last updated:** 2026-11-13 · **Change:** Snap to Point with Smart Guides off draws no Smart Guides labels or lines, #7250; no response-shape change. Previously: Envelopes: a top object's side whose pieces are all one curve split at anchors follows that curve's parameter (#7097); existing response shapes retained. +> **Last updated:** 2026-11-14 · **Change:** Tech Circuit brush library (G5); Snap to Point with Smart Guides off draws no Smart Guides labels or lines, #7250; no response-shape change. Previously: Envelopes: a top object's side whose pieces are all one curve split at anchors follows that curve's parameter (#7097); 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 @@ -4356,9 +4356,9 @@ error. ## Brush libraries -Brush libraries are read-only sets of brushes: nine generated in code (Art Ribbons, Ink and Wash, Calligraphic Nibs, Bristle +Brush libraries are read-only sets of brushes: ten generated in code (Art Ribbons, Ink and Wash, Calligraphic Nibs, Bristle Brushes with one bristle brush for each tip shape, 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 +Charcoal Dust art brushes, id `dry-media`, and Tech Circuit with Circuit Trace (art), Bus Lines and Terminal Nodes (pattern), id `tech-circuit`), 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). @@ -11697,6 +11697,7 @@ the error text. The interactive fallback applies only to ordinary File > Save, n | Date | Change | |---|---| +| 2026-11-14 | Built-in Tech Circuit brush library (id `tech-circuit`): Circuit Trace, Bus Lines and Terminal Nodes; ten built-in libraries. | | 2026-11-13 | Snap to Point with Smart Guides off draws no Smart Guides labels or lines (#7250). | | 2026-11-13 | Envelopes: a top object's side whose pieces are all one curve split at anchors follows that curve's parameter (#7097). | | 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 9fa76dc027..101f32f82f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-13 · **Change:** minor (Graphic Styles list fills a taller panel, #2992; scores and estimates unchanged; previously: PDF import: glyphs in a span with an empty ActualText come in as outlines, #6971; scores and estimates unchanged; previously: library panels' lists fill a taller panel, #2992; scores and estimates unchanged; previously: Swatches list fills a taller panel, #1638; scores and estimates unchanged; previously: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: explicitly requested oversized editing artboard; Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-14 · **Change:** minor (Tech Circuit brush library, G5; previously: Graphic Styles list fills a taller panel, #2992; scores and estimates unchanged; previously: PDF import: glyphs in a span with an empty ActualText come in as outlines, #6971; scores and estimates unchanged; previously: library panels' lists fill a taller panel, #2992; scores and estimates unchanged; previously: Swatches list fills a taller panel, #1638; scores and estimates unchanged; previously: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: explicitly requested oversized editing artboard; Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: 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) > > Oversized artboard import update (2026-11-12): Explicit editing-data import retains a single artboard beyond a capped 14,400 pt PDF page (#6865). > @@ -165,7 +165,7 @@ Gujarati interface follow-up (2026-11-14): the catalog and zero/one plural rule | 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 (feature-valued alternates and unencoded glyphs applied in one Undo step while keeping Arabic joining, #5514), 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, nine generated libraries including Ink and Wash, Bristle Brushes and 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 | +| 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, ten generated libraries including Tech Circuit, Ink and Wash, Bristle Brushes and 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); Live Paint Bucket fills faces crossed in one drag (#6373) | 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 | @@ -207,6 +207,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-11-14 | minor | Tech Circuit adds three original procedural brushes (Circuit Trace, Bus Lines, Terminal Nodes); ten built-in brush libraries. G5 remaining estimate 4–14 h (estimated); parity scores and headline totals unchanged. | | 2026-11-14 | minor | Gujarati (#6613): contributor proofreading completed for all 387 additions, with 11 wording corrections; 4,673 reviewed catalog rows. Status/error messages remain partial; web selection requires a bundled Gujarati face. Scores and estimates unchanged. | | 2026-11-12 | minor | Gujarati (`gu`): 4,286-row machine-assisted catalog reviewed by a native speaker, zero/one singular plurals, desktop font fallback and optional Gujarati craft-fonts fallback; all 72 translation tests pass; a web font and about 2,600 untranslated messages remain; scores and estimates unchanged | | 2026-11-12 | minor | Explicit editing-data import keeps a single artboard beyond a capped 14,400 pt PDF page (#6865), with a warning; automatic mode stays unchanged. Scores and estimates unchanged. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7e36c7f318..85cfd36ed4 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-14 · **Change:** minor (a closed Pencil stroke closes with a straight segment; previously: Snap to Point with Smart Guides off draws no Smart Guides labels or lines, #7250; previously: 3D: the seams of Extrude & Bevel, Inflate and Rotate under a white light, and of the faces of Extrude & Bevel (Classic), Revolve (Classic) and Rotate (Classic), went from miter to round joins, so white-light 3D output is expected to differ from the 2026-10-17 comparison at facet corners (not measured: the scenes were not exported again); previously: `.ai` and EPS import: in layers that don't draw as the page, art that differs away from an envelope of type no longer costs it its editable type; it keeps its drawn art where, in the pixels it and its drawn art ink, the layers with it differ from the page more than its own check allows, or past the envelopes' share of the page check, #7141; Character panel: the Set Font Height Reference dropdown shows an icon before each reference, #6304; scores and estimates unchanged; previously: Envelopes: a top object's side whose pieces are all one curve split at anchors follows that curve's parameter, so a top object saved with its sides' curves in pieces draws as its `.ai` drawn art, #7097; scores and estimates unchanged; previously: Graphic Styles list fills a taller panel, #2992; scores and estimates unchanged; previously: PDF import: glyphs in a span with an empty ActualText come in as outlines, #6971; scores and estimates unchanged; previously: library panels' lists fill a taller panel, #2992; scores and estimates unchanged; previously: Swatches list fills a taller panel, #1638; scores and estimates unchanged; previously minor: Object › Rasterize with the Grayscale or Bitmap colour model makes a grayscale image, #6969; scores and estimates unchanged; previously minor: `.ai` and EPS import: type in an envelope with Distort Appearance is also tried mapped from the box of what it paints, miter tips and moved strokes included, after its glyphs grown by half its strokes' weight (#6824; #6617: Swatches panel menu: Show Recent Colors; scores unchanged; previously minor: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: explicitly requested oversized editing artboard; macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Type tool: kerning at a caret at the start of a line moves the character after it, #5111; scores and estimates unchanged; previously minor: Rulers: the zero point dragged out of the rulers' corner snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Envelopes: with Distort Appearance, type's strokes paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them, which changes how VectorCraft's own envelopes of stroked type draw (#6644; scores and estimates unchanged); previously minor: G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-14 · **Change:** minor (Tech Circuit brush library, G5; previously: a closed Pencil stroke closes with a straight segment; previously: Snap to Point with Smart Guides off draws no Smart Guides labels or lines, #7250; previously: 3D: the seams of Extrude & Bevel, Inflate and Rotate under a white light, and of the faces of Extrude & Bevel (Classic), Revolve (Classic) and Rotate (Classic), went from miter to round joins, so white-light 3D output is expected to differ from the 2026-10-17 comparison at facet corners (not measured: the scenes were not exported again); previously: `.ai` and EPS import: in layers that don't draw as the page, art that differs away from an envelope of type no longer costs it its editable type; it keeps its drawn art where, in the pixels it and its drawn art ink, the layers with it differ from the page more than its own check allows, or past the envelopes' share of the page check, #7141; Character panel: the Set Font Height Reference dropdown shows an icon before each reference, #6304; scores and estimates unchanged; previously: Envelopes: a top object's side whose pieces are all one curve split at anchors follows that curve's parameter, so a top object saved with its sides' curves in pieces draws as its `.ai` drawn art, #7097; scores and estimates unchanged; previously: Graphic Styles list fills a taller panel, #2992; scores and estimates unchanged; previously: PDF import: glyphs in a span with an empty ActualText come in as outlines, #6971; scores and estimates unchanged; previously: library panels' lists fill a taller panel, #2992; scores and estimates unchanged; previously: Swatches list fills a taller panel, #1638; scores and estimates unchanged; previously minor: Object › Rasterize with the Grayscale or Bitmap colour model makes a grayscale image, #6969; scores and estimates unchanged; previously minor: `.ai` and EPS import: type in an envelope with Distort Appearance is also tried mapped from the box of what it paints, miter tips and moved strokes included, after its glyphs grown by half its strokes' weight (#6824; #6617: Swatches panel menu: Show Recent Colors; scores unchanged; previously minor: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: explicitly requested oversized editing artboard; macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Type tool: kerning at a caret at the start of a line moves the character after it, #5111; scores and estimates unchanged; previously minor: Rulers: the zero point dragged out of the rulers' corner snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Envelopes: with Distort Appearance, type's strokes paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them, which changes how VectorCraft's own envelopes of stroked type draw (#6644; scores and estimates unchanged); previously minor: G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > > Oversized artboard import update (2026-11-12): Explicit editing-data import retains a single artboard beyond a capped 14,400 pt PDF page (#6865). > @@ -58,6 +58,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-14 | minor | G5: Tech Circuit adds three original procedural brushes (Circuit Trace, Bus Lines, Terminal Nodes); ten built-in brush libraries. G5 remaining estimate 4–14 h (estimated); parity scores and headline totals unchanged. | | 2026-11-14 | minor | Pencil and Paintbrush: a closed stroke (Alt at the release, or ending within Close Paths When Ends Are Within of its start) is fitted as the open stroke would be and closed by a straight segment back to its start, instead of curving on past both ends (`pencil_alt_closes_a_stroke_straight_back_to_its_start` in `crates/engine/src/tests/draw2.rs`). Scores and estimates unchanged. | | 2026-11-13 | minor | Snap to Point: with Smart Guides off it still pulls drawn and dragged points onto anchors and ruler guides, but draws none of the Smart Guides labels ("anchor") or lines (#7250; `guides::tests::snap_to_point_without_smart_guides_shows_no_guides`). Scores and estimates unchanged. | | 2026-11-13 | minor | Graphic Styles: in a panel made taller the list fills the space above the footer instead of stopping at 220 pt (#2992; `the_list_fills_a_tall_panel_above_its_footer` in `crates/ui-egui/src/panels/graphic_styles.rs`). Scores and estimates unchanged. | @@ -238,7 +239,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; the Pen, Smart Pen and Curvature tool show the measurement readout with the rubber band off (#7043), score, gaps 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); Live Paint Bucket face drags, including narrow faces and one Undo, regression-tested (#6373) | | 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; a point on a gradient mesh's outline locates on its edge on every side (#7006: `mesh_locate_takes_a_point_on_any_side_of_the_outline`, `mesh_add_line_exactly_on_the_edge_needs_no_tolerance`), score and hours unchanged | -| Strokes, brushes, width profiles | 5 | 88% | Shift-double-click adjoining adjustment is implemented; brush libraries are done (nine generated libraries including Ink and Wash, Bristle Brushes, 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) | +| Strokes, brushes, width profiles | 5 | 88% | Shift-double-click adjoining adjustment is implemented; brush libraries are done (ten generated libraries including Tech Circuit, Ink and Wash, Bristle Brushes, 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 (Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12); every 3D seam has round joins (2026-11-12, [three-d.md](three-d.md#seams)), score, gaps and hours unchanged | | 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`) | @@ -507,7 +508,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% | Library panels' lists (swatch, Color Book, brush, symbol and graphic style libraries) fill a taller panel above its footer (#2992; scores and estimates unchanged); Swatches list fills a taller panel above its footer (#1638; `the_list_fills_a_tall_panel_above_its_footer`; scores and estimates unchanged); 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 (nine generated in code including Ink and Wash, Bristle Brushes, 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 | +| Strokes, brushes, width profiles | 5 | 90% | more built-in brush libraries (ten generated in code including Tech Circuit, Ink and Wash, Bristle Brushes, 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% | Graphic Styles list fills a taller panel above its footer (#2992; scores and estimates unchanged); 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, Rotate (Classic), Map Art and more complete visibility remain | 25–45 | @@ -559,7 +560,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 nine (Art Ribbons, Ink and Wash, Calligraphic Nibs, Bristle Brushes, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams, Dry Media) 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 ten (Art Ribbons, Ink and Wash, Calligraphic Nibs, Bristle Brushes, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams, Dry Media, Tech Circuit) 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 | @@ -960,8 +961,9 @@ 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, Ink and Wash, Calligraphic Nibs, Bristle Brushes, 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; Ink and Wash adds ink and wash brushes (the [help page](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/about-brushes.html) lists an Ink brush library of strokes with varied thickness and texture; the names, shapes and opacities are VectorCraft's own): Tapered Ink, an art brush broad near its start that thins to a fine tip; Brush Pen, a calligraphic brush whose size follows the pen pressure; and Soft Wash and Pooling Wash, art brushes of translucent gray layers that take the stroke color as tints; 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); Dry Media adds grainy art brushes (Chalk Grain, Graphite Pencil, Wax Crayon, Charcoal Dust). 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, Ink and Wash, Calligraphic Nibs, Bristle Brushes, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams, Dry Media, Tech Circuit) 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; Ink and Wash adds ink and wash brushes (the [help page](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/about-brushes.html) lists an Ink brush library of strokes with varied thickness and texture; the names, shapes and opacities are VectorCraft's own): Tapered Ink, an art brush broad near its start that thins to a fine tip; Brush Pen, a calligraphic brush whose size follows the pen pressure; and Soft Wash and Pooling Wash, art brushes of translucent gray layers that take the stroke color as tints; 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); Dry Media adds grainy art brushes (Chalk Grain, Graphite Pencil, Wax Crayon, Charcoal Dust). A click on a brush adds it to the document's Brushes as one undo step with every resource its art uses (patterns, symbols, swatches, graphic styles, images), makes it the current brush and, with paths selected, applies it to them in the same step; a brush the document already has is not added again and a different brush of the same name comes in numbered ("Wave 2"). Shift/Cmd-clicks select brushes for Add to Brushes; a brush dragged onto a path is added and applied to it, and one dragged onto the Brushes panel is added. Save Brushes… / the Brushes panel's Save Brush Library… write the document's brushes (or the selected ones) as a `.vectorcraft` library into the `Brushes` folder next to the preferences, listed under User Defined, or to a file; Other Library… reads the brushes of any document VectorCraft opens. Thumbnails, in the library panels, the Brushes panel and Brush Options, render brush art with the patterns and symbols it uses. Copying brushed art now carries the patterns and symbols its brush's art uses too. Tests: `crates/engine/src/tests/brushlib.rs`, `crates/ui-egui/src/panels/tests_brush_libraries.rs`, `panels::brushes::tests::library_brush_previews_show_the_patterns_their_art_uses`, `canvas::tests::a_library_brush_dropped_on_a_path_is_added_and_applied_in_one_step`. - **Bristle Brushes library** ([What's new](https://helpx.adobe.com/illustrator/desktop/new-features/whats-new.html), [About brushes](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/about-brushes.html)): Window › Brush Libraries › Bristle Brushes holds ten bristle brushes, one for each tip shape (Fine Liner, Pointed Flat, Stubby Round, Dry Scrub, Soft Dome, Filbert, Angled Round, Angled Flat, Fan Blender, Grass Fan). The brushes, their names and their values are VectorCraft's own, and every value lies inside the range the bristle brush keeps (`brush.libraries`, `brush.addFromLibrary`; `the_bristle_brushes_library_covers_every_tip_shape_within_the_bristle_ranges`). + - **Tech Circuit library:** Circuit Trace is a chamfered art brush with 45-degree elbows and terminal pads; Bus Lines repeats three parallel conductor lanes; Terminal Nodes repeats solder pads, circular test points and connecting leads. Original vector geometry generated in code, with Tints colorization and continuous pattern tile edges. Evidence: `the_tech_circuit_library_constructs_adds_and_applies_each_brush` in `crates/engine/src/tests/brushlib.rs` covers listing, finite geometry on straight, curved, short and closed paths, add/apply, undo/redo, deduplication and saved-library round trips. G5 remaining estimate 4–14 h (estimated); area scores unchanged. - **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`). - **Brush names in library panels**: a built-in brush library's panel shows its brushes' names in the UI language, in its list rows and tooltips, and its Find field matches the name shown or the English name. This is VectorCraft's own rule. A User Defined or Other Library… library's brushes, and the document's Brushes panel, show names as they are stored. A click, a drag, Add to Brushes and `brush.addFromLibrary` still use the English name, so the brush comes into the document under that name (`built_in_library_brushes_are_listed_in_the_ui_language`, `built_in_library_brush_names_are_in_complete_catalogs`). - 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. @@ -2379,6 +2381,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| +| 2026-11-14 | minor | Tech Circuit adds three original procedural brushes (Circuit Trace, Bus Lines, Terminal Nodes); ten built-in brush libraries. G5 remaining estimate 4–14 h (estimated); parity scores and headline totals unchanged. | | 2026-11-14 | minor | Gujarati (#6613): contributor proofreading completed for all 387 additions, with 11 wording corrections; 4,673 reviewed catalog rows. Status/error messages remain partial; web selection requires a bundled Gujarati face. Scores and estimates unchanged. | | 2026-11-12 | minor | Gujarati (`gu`): 4,286-row machine-assisted catalog reviewed by a native speaker, zero/one singular plurals, desktop font fallback and optional Gujarati craft-fonts fallback; all 72 translation tests pass; a web font and about 2,600 untranslated messages remain; scores and estimates unchanged | | 2026-11-13 | minor | File formats: in a `.ai` or EPS opened from its editing data whose layers don't draw as the page even with the drawn art of every live envelope put back, and are used all the same (trusted, their objects that differ coming in as the page's art, or opened alone), art that differs away from an envelope of type no longer costs it its editable type: it keeps its drawn art where, in the pixels it and its drawn art ink, the layers with it differ from the page more than its own check allows, or past the envelopes' share of the page check, and where a clip, mask, group appearance, another envelope, a repeat or a blend holds it (#7141); evidence updated; scores and hours unchanged. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 9bcce16a0b..ccde9b2824 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-13 · **Change:** minor (Graphic Styles list fills a taller panel, #2992; scores and estimates unchanged; previously: library panels' lists fill a taller panel, #2992; scores and estimates unchanged; previously: Swatches list fills a taller panel, #1638; scores and estimates unchanged; previously: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Remove Background in the Contextual Task Bar, Properties › Quick Actions and the Control bar; previously minor: Ruler origin: the corner drag snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Outline view: images and gradient meshes are picked on what Outline draws, so a click inside them reaches the path behind, #6437; previously minor: a middle-click on a document tab closes it, #6506; previously minor: Blob Brush: a round tip's preview is the outline of the area it adds, round at both ends, as the stroke it commits (#4343); scores and estimates unchanged; previously:; ruler double-click adds a guide at the pointer, #5239; G5: Inventory Icons built-in symbol library (four built-ins); previously minor: files that record no units open in Preferences › Units › General, #5820; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-14 · **Change:** minor (Tech Circuit brush library, G5; previously: Graphic Styles list fills a taller panel, #2992; scores and estimates unchanged; previously: library panels' lists fill a taller panel, #2992; scores and estimates unchanged; previously: Swatches list fills a taller panel, #1638; scores and estimates unchanged; previously: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Remove Background in the Contextual Task Bar, Properties › Quick Actions and the Control bar; previously minor: Ruler origin: the corner drag snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Outline view: images and gradient meshes are picked on what Outline draws, so a click inside them reaches the path behind, #6437; previously minor: a middle-click on a document tab closes it, #6506; previously minor: Blob Brush: a round tip's preview is the outline of the area it adds, round at both ends, as the stroke it commits (#4343); scores and estimates unchanged; previously:; ruler double-click adds a guide at the pointer, #5239; G5: Inventory Icons built-in symbol library (four built-ins); previously minor: files that record no units open in Preferences › Units › General, #5820; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > > Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369). > @@ -51,6 +51,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-14 | minor | G5: Tech Circuit adds three original procedural brushes (Circuit Trace, Bus Lines, Terminal Nodes); ten built-in brush libraries. G5 remaining estimate 4–14 h (estimated); parity scores and headline totals unchanged. | | 2026-11-14 | minor | Layers panel: right-clicking a layer/group/object row shows a context menu with Options…, Duplicate, Delete, Lock/Hide Others, Locate Object, and Collect for Export — matching standard desktop layer panel conventions. Scores unchanged. | | 2026-11-13 | minor | Graphic Styles: in a panel made taller the list fills the space above the footer instead of stopping at 220 pt (#2992; `the_list_fills_a_tall_panel_above_its_footer` in `crates/ui-egui/src/panels/graphic_styles.rs`). Scores and estimates unchanged. | | 2026-11-13 | minor | Library panels (swatch, Color Book, brush, symbol and graphic style libraries): in a panel made taller the list fills the space above the footer instead of scrolling inside 220 or 280 pt (#2992; `the_list_fills_a_tall_panel_above_its_footer` in `crates/ui-egui/src/panels/library_panel/tests.rs`). Scores and estimates unchanged. | @@ -427,7 +428,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po | Attributes fill-rule controls with a compound member selected | done | Edits the compound's rule; direction still edits only the member; rendered-hole and Undo/Redo regression tests (#1197) | | Make Compound Path on a group (such as outlined type) keeps its backmost path's paint; compound paths joined into one keep their holes | done | #1537 | | Symbol libraries: Window › Symbol Libraries (Basic Shapes, UI Frames, Map Markers, Inventory Icons, Skill Icons, User Defined, Other Library…, Save Symbols…) and the Symbols panel's Symbol Libraries Menu button and its menu's Open Symbol Library / Save Symbol Library…; a click on a library symbol adds it to the document's Symbols (one undo step, it becomes the current symbol), Shift/Cmd-clicks select for Add to Symbols, and a symbol dragged onto the canvas is added and placed where it is dropped | done | [public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-or-import-symbol-libraries.html), [Symbols panel options](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html), [placing from a library](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-and-place-symbols.html); `window.symbolLibrary`, `window.symbolLibrary.other`, `ui.saveSymbolLibrary` (dialog `saveSymbolLibrary`), `window.userSymbolLibrary1`…`10`; `panels::tests_symbol_libraries`; five built-in libraries (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons, Skill Icons; original, generated in code) | -| Brush libraries: Window › Brush Libraries (Art Ribbons, Ink and Wash, Calligraphic Nibs, Bristle Brushes, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams, Dry Media, User Defined, Other Library…, Save Brushes…) and the Brushes panel's Brush Libraries Menu button and its menu's Open Brush Library / Save Brush Library…; a click on a library brush adds it to the document's Brushes (one undo step, it becomes the current brush, and selected paths take it in the same step), Shift/Cmd-clicks select 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; thumbnails render with the library's patterns and symbols | done | [public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/create-new-brush-libraries.html), [import brushes](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/import-brushes-from-brush-libraries-or-other-files.html), [Brushes panel overview](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/brushes-panel-overview.html); `window.brushLibrary`, `window.brushLibrary.other`, `ui.saveBrushLibrary` (dialog `saveBrushLibrary`), `window.userBrushLibrary1`…`10`; `panels::tests_brush_libraries`; nine built-in libraries (original, generated in code) | +| Brush libraries: Window › Brush Libraries (Art Ribbons, Ink and Wash, Calligraphic Nibs, Bristle Brushes, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams, Dry Media, Tech Circuit, User Defined, Other Library…, Save Brushes…) and the Brushes panel's Brush Libraries Menu button and its menu's Open Brush Library / Save Brush Library…; a click on a library brush adds it to the document's Brushes (one undo step, it becomes the current brush, and selected paths take it in the same step), Shift/Cmd-clicks select 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; thumbnails render with the library's patterns and symbols | done | [public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/create-new-brush-libraries.html), [import brushes](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/import-brushes-from-brush-libraries-or-other-files.html), [Brushes panel overview](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/brushes-panel-overview.html); `window.brushLibrary`, `window.brushLibrary.other`, `ui.saveBrushLibrary` (dialog `saveBrushLibrary`), `window.userBrushLibrary1`…`10`; `panels::tests_brush_libraries`; ten built-in libraries (original, generated in code) | | Symbols panel: New Symbol opens Symbol Options (Name) before making the symbol; Alt-click skips it | partial | #1560 (#1566); Registration 9-point grid in Symbol Options (`symbol.new` / `symbol.place`); Export Type (Graphic / Movie Clip) and Enable Guides for 9-Slice Scaling (scaled instances keep their corners; in symbol editing mode the guides are drawn over the art and the Selection tool drags them); Symbol Type (Dynamic Symbol / Static Symbol, new symbols dynamic; #4938) | | Dynamic symbols: an instance changes the fill, stroke, opacity and blend of objects in its symbol's art and stays linked; Reset in the Properties panel's Quick Actions and the Symbols panel menu (Reset Symbol Instance) | partial | #4938; `symbol.instance.setAppearance` (by child-index path) and `symbol.instance.reset`; redefining keeps the changes, Break Link bakes them; Direct Selection of a path, compound path or type object inside an instance, with the Fill/Stroke controls setting its solid fill or stroke, is on canvas; on-canvas editing beyond a solid fill and stroke, and `.ai` read and write, remain | | Pathfinder Trim, Merge and Crop remove strokes; Merge joins same-fill objects whatever their strokes | done | #1570 (#1572) | @@ -538,6 +539,7 @@ before release (#6373). [Paint faces and edges](https://helpx.adobe.com/illustra | Date | Change | Summary | |---|---|---| +| 2026-11-14 | minor | Tech Circuit adds three original procedural brushes (Circuit Trace, Bus Lines, Terminal Nodes); ten built-in brush libraries. G5 remaining estimate 4–14 h (estimated); parity scores and headline totals unchanged. | | 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-12 | minor | Remove Background for a selected image in the Contextual Task Bar, Properties › Quick Actions and the Control bar, which runs at once with its defaults. Scores and estimates unchanged. | | 2026-11-12 | minor | Live Paint Bucket fills faces crossed in one drag (#6373). Scores and estimates unchanged. | From bb1b7e4f6675898709ba9235e70aed4b48275f91 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 08:47:35 -0800 Subject: [PATCH 11/52] SVG import: image data URLs are decoded once each, within 512 MB per import usvg decodes an image data URL every time it converts the element that holds it, a `` copy, a filter or a nested SVG image included, before the image resolver is called, and nothing bounded the bytes decoded per import. Before usvg reads the imported SVG and each SVG shown as an image, every `href` and `xlink:href` of an `` or `` that is a data URL is replaced with a key. The resolver decodes each URL once, the first time its key comes, and every later use gets the same image. All data URLs of one import decode to at most 512 MB together (MAX_DATA_URL_BYTES): decoding stops where a URL would pass it, and that image is left out with a warning. A link spelled like a key gets a key too and is still read as that link. --- Cargo.lock | 1 + crates/svg/Cargo.toml | 2 + crates/svg/src/import.rs | 2 +- crates/svg/src/import/files.rs | 411 +++++++++++++++++++++++--- crates/svg/src/import/prepass.rs | 10 +- crates/svg/src/lib.rs | 5 +- crates/svg/tests/it/import_details.rs | 15 + docs/target-app-parity.md | 1 + 8 files changed, 410 insertions(+), 37 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f3e85a08a7..96ece9d808 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6812,6 +6812,7 @@ version = "0.24.1" dependencies = [ "craft-docpath", "craft-svg", + "data-url", "flate2", "image", "kurbo 0.13.1", diff --git a/crates/svg/Cargo.toml b/crates/svg/Cargo.toml index 36744c8de5..e7d869e6f6 100644 --- a/crates/svg/Cargo.toml +++ b/crates/svg/Cargo.toml @@ -28,6 +28,8 @@ serde_json = { workspace = true } # SVGZ (gzip, pure-Rust backend: builds for wasm). flate2 = { workspace = true } image = { workspace = true } +# Image data URLs decoded as usvg decodes them, within a byte limit (MIT OR Apache-2.0; already used by usvg). +data-url = "0.3.2" # Colour / transform / length parsing for the fallback (MIT OR Apache-2.0; already used by usvg). svgtypes = "0.16" simplecss = "0.2.2" diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index 087512bf90..1aa571a6ed 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -20,7 +20,7 @@ use text::TextSlots; pub use depth::MAX_ELEMENT_DEPTH; pub(crate) use depth::xml_too_deep; -pub use files::{MAX_NESTED_SVG_IMAGES, MAX_NESTED_SVG_READS, MAX_NESTED_SVG_TEXT}; +pub use files::{MAX_DATA_URL_BYTES, MAX_NESTED_SVG_IMAGES, MAX_NESTED_SVG_READS, MAX_NESTED_SVG_TEXT}; pub(crate) use fx::BLEND; /// Most objects an imported SVG may expand to: a guard against `` and nested-image diff --git a/crates/svg/src/import/files.rs b/crates/svg/src/import/files.rs index d2b5504f9d..fe3bf05e8f 100644 --- a/crates/svg/src/import/files.rs +++ b/crates/svg/src/import/files.rs @@ -11,6 +11,12 @@ //! A raster image, linked or a data URL, whose orientation (EXIF, or a TIFF's Orientation tag) //! turns or flips its pixels is turned before usvg gets it, so it is sized, fitted and stored as //! it is shown. +//! +//! usvg decodes a data URL each time it converts the element holding it, a `` copy +//! included, before the resolver sees it. So before usvg reads an SVG (the imported one, and +//! each SVG image), each `` and `` href that is a data URL is changed to a key, +//! and the resolver decodes the URL once, the first time a key comes, within what the data URLs +//! may decode to together ([`MAX_DATA_URL_BYTES`]); every later use of it gets the same image. use std::collections::HashMap; use std::path::Path; @@ -34,6 +40,13 @@ pub const MAX_NESTED_SVG_READS: usize = 1024; /// Most bytes of nested SVG image text an imported SVG may read, decoded or not, all reads together. pub const MAX_NESTED_SVG_TEXT: u64 = 512 << 20; +/// Most bytes the image data URLs an imported SVG shows may decode to, those in its SVG images +/// included, all together; each URL is decoded once, however often it is shown. +pub const MAX_DATA_URL_BYTES: u64 = 512 << 20; + +/// What a data URL's key starts with, the URL's number following it. +const DATA_URL_KEY: &str = "vectorcraft-data-url-"; + /// The files an SVG's images link to. #[derive(Default)] pub(in crate::import) struct Files { @@ -74,6 +87,38 @@ pub(in crate::import) struct Files { /// The CMYK TIFF stored in place of a turned CMYK image, by the address of the PNG usvg got /// (kept here too, so the address stays its own). cmyk: Mutex>, + /// The data URLs changed to keys before usvg read them. + data: Mutex, + /// The bytes the image data URLs decoded to so far, whether or not they are images. + data_bytes: AtomicU64, + /// Images were left out for their data URLs decoding past [`MAX_DATA_URL_BYTES`]. + too_much_data: AtomicBool, +} + +/// The hrefs changed to keys, by number: an href's key is [`DATA_URL_KEY`] and its number. +#[derive(Default)] +struct DataUrls { + /// The number of each href. + numbers: HashMap, usize>, + /// Each href: a data URL, then what it decoded to, or a link. + urls: Vec, +} + +enum DataUrl { + Encoded(Arc), + Decoded(Decoded), + /// A link that starts as a key does, looked up as it is. + Link(Arc), +} + +/// What a data URL decoded to, as usvg gets it. +#[derive(Clone)] +enum Decoded { + Raster(RasterKind, Bytes), + /// SVG text (unpacked when it was SVGZ), read where it is shown ([`Files::svg`]). + Svg(Arc), + /// Not an image VectorCraft reads, or left out. + Nothing, } /// Encoded image bytes, shared. @@ -245,15 +290,46 @@ fn key(bytes: &Arc>) -> usize { impl Files { /// Read the files the `` elements of `xml` (the text `svg`) link to. Placeholders /// stretch to the image's box (`preserveAspectRatio="none"`), which the file fills once - /// relinked. + /// relinked. The data URLs of its images are changed to keys ([`Files::take_data_urls`]). pub(in crate::import) fn read(svg: &str, xml: &roxmltree::Document, opts: &ImportOptions, edits: &mut Edits, warnings: &mut Vec) -> Self { let mut files = Files::default(); let mut reading = Reading { opts, warnings, placeholder: None, by_file: HashMap::new(), active: vec![], placed: false, svg_reads: 0, svg_text: 0 }; files.sources = files.links_of(svg, xml, opts.folder, None, edits, &mut reading); + files.take_data_urls(xml, edits); files } + /// Change each href of the `` and `` elements of `xml` that usvg would decode + /// as a data URL to that URL's key, numbering the URLs not seen before: usvg gives a key to + /// the resolver as it is ([`Files::data_url`]). An href that starts as a key does, a link, + /// gets a key too, which the resolver reads as that link. + fn take_data_urls(&self, xml: &roxmltree::Document, edits: &mut Edits) { + let Ok(mut data) = self.data.lock() else { return }; + let hrefs = xml + .descendants() + .filter(|n| n.is_element() && matches!(n.tag_name().name(), "image" | "feImage")) + .flat_map(|n| n.attributes()) + .filter(|a| a.name() == "href" && matches!(a.namespace(), None | Some("http://www.w3.org/1999/xlink"))); + for a in hrefs { + let url = data_url::DataUrl::process(a.value()).is_ok(); + if !url && !a.value().starts_with(DATA_URL_KEY) { + continue; + } + let n = match data.numbers.get(a.value()) { + Some(n) => *n, + None => { + let href: Arc = Arc::from(a.value()); + let n = data.urls.len(); + data.numbers.insert(href.clone(), n); + data.urls.push(if url { DataUrl::Encoded(href) } else { DataUrl::Link(href) }); + n + } + }; + edits.set_value(a, &format!("{DATA_URL_KEY}{n}")); + } + } + /// What usvg gets for each href the `` elements of `xml` (the text `svg`) link to, /// relative paths taken from `folder`. `inside` is how deep the SVG images `xml` shows nest in /// it and the SVGs around it, measured when an SVG file is read (`None` for `xml` itself). @@ -476,6 +552,9 @@ impl Files { MAX_NESTED_SVG_TEXT >> 20 )); } + if self.too_much_data.load(Ordering::Relaxed) { + notes.push(format!("images were left out: together their data URLs decode to more than {} MB", MAX_DATA_URL_BYTES >> 20)); + } notes } @@ -485,47 +564,138 @@ impl Files { } } - /// usvg's image resolver, for an SVG whose elements nest `height` deep: data URLs as usvg - /// reads them, other hrefs only from the files read. SVG images are read here - /// ([`Files::svg_data`], [`Files::svg`]), never by usvg's own resolver, which caps neither how - /// deep they nest nor what they unpack to. + /// usvg's image resolver, for an SVG whose elements nest `height` deep: data URLs by their + /// keys ([`Files::data_url`]), and those usvg decoded itself as it reads them, within + /// [`MAX_DATA_URL_BYTES`]; other hrefs only from the files read. SVG images are read here + /// ([`Files::svg`]), never by usvg's own resolver, which caps neither how deep they nest nor + /// what they unpack to. pub(in crate::import) fn resolver(&self, height: usize) -> ImageHrefResolver<'_> { self.depth.store(height, Ordering::Relaxed); ImageHrefResolver { - // By type as usvg reads them, and TIFF; one without a type (`text/plain`) by its - // bytes: one of those rasters, else SVG. A raster as [`shown`] gives it. resolve_data: Box::new(move |mime, bytes, opts| { - use image::ImageFormat as F; - let format = match mime { - "image/jpg" | "image/jpeg" => F::Jpeg, - "image/png" => F::Png, - "image/gif" => F::Gif, - "image/webp" => F::WebP, - "image/tiff" => F::Tiff, - "image/svg+xml" => return self.svg_data(&bytes, opts), - "text/plain" => match image::guess_format(&bytes).ok().filter(|f| raster(*f).is_some() || *f == F::Tiff) { - Some(format) => format, - None => return self.svg_data(&bytes, opts), - }, - _ => return None, - }; - let (kind, bytes) = self.keep(shown(bytes, format)?); - Some(kind(bytes)) + let spent = self.data_bytes.load(Ordering::Relaxed).saturating_add(bytes.len() as u64); + if spent > MAX_DATA_URL_BYTES { + return leave_out(&self.too_much_data, true); + } + self.data_bytes.store(spent, Ordering::Relaxed); + self.show(self.decoded(mime, bytes), opts) }), - resolve_string: Box::new(move |h, opts| match self.find(h)? { - Source::Raster(kind, bytes) => Some(kind(bytes)), - Source::Svg(file) => self.svg(&file.text.clone(), Some(file), opts), + resolve_string: Box::new(move |h, opts| { + let link = match self.data_number(h).map(|n| self.data_url(n, opts)) { + Some(Ok(image)) => return image, + Some(Err(link)) => link, + None => Arc::from(h), + }; + match self.find(&link)? { + Source::Raster(kind, bytes) => Some(kind(bytes)), + Source::Svg(file) => self.svg(&file.text.clone(), Some(file), opts), + } }), } } - /// The SVG image of data `bytes` (SVG or SVGZ), as [`Files::svg`] reads it. Unpacked here, - /// within what the SVGZ images have left, so usvg unpacks nothing itself; past it, `None`, - /// noted. - fn svg_data(&self, bytes: &[u8], opts: &usvg::Options) -> Option { + /// The number of the href whose key is `h`. + fn data_number(&self, h: &str) -> Option { + let n: usize = h.strip_prefix(DATA_URL_KEY)?.parse().ok()?; + let known = n < self.data.lock().ok()?.urls.len(); + (known && h.len() == DATA_URL_KEY.len() + n.to_string().len()).then_some(n) + } + + /// The image of data URL `n`: decoded the first time ([`Files::decode`]), then kept; the link + /// when href `n` is one. + fn data_url(&self, n: usize, opts: &usvg::Options) -> Result, Arc> { + // The lock is let go before the image is shown: an SVG image takes it again. + let state = match self.data.lock().ok().as_deref().and_then(|d| d.urls.get(n)) { + Some(DataUrl::Decoded(decoded)) => Ok(decoded.clone()), + Some(DataUrl::Encoded(url)) => Err(url.clone()), + Some(DataUrl::Link(link)) => return Err(link.clone()), + None => return Ok(None), + }; + let url = match state { + Ok(decoded) => return Ok(self.show(decoded, opts)), + Err(url) => url, + }; + let decoded = self.decode(&url); + if let Ok(mut data) = self.data.lock() + && let Some(slot) = data.urls.get_mut(n) + { + *slot = DataUrl::Decoded(decoded.clone()); + } + Ok(self.show(decoded, opts)) + } + + /// What data URL `url` decodes to, as usvg decodes it, in what is left of + /// [`MAX_DATA_URL_BYTES`]: decoding stops where it would go past, the image left out, noted. + fn decode(&self, url: &str) -> Decoded { + let Ok(parsed) = data_url::DataUrl::process(url) else { return Decoded::Nothing }; + let spent = self.data_bytes.load(Ordering::Relaxed); + let room = MAX_DATA_URL_BYTES.saturating_sub(spent); + let mut body = Vec::new(); + let decoded = parsed.decode(|bytes| { + if (body.len() as u64).saturating_add(bytes.len() as u64) > room { + return Err(()); + } + body.extend_from_slice(bytes); + Ok(()) + }); + self.data_bytes.store(spent.saturating_add(body.len() as u64), Ordering::Relaxed); + match decoded { + Ok(_) => {} + Err(data_url::forgiving_base64::DecodeError::WriteError(())) => { + self.too_much_data.store(true, Ordering::Relaxed); + return Decoded::Nothing; + } + Err(_) => return Decoded::Nothing, + } + let mime = format!("{}/{}", parsed.mime_type().type_, parsed.mime_type().subtype); + self.decoded(&mime, Arc::new(body)) + } + + /// What usvg gets for data `bytes` of type `mime`: by type as usvg reads them, and TIFF; one + /// without a type (`text/plain`) by its bytes: one of those rasters, else SVG. A raster as + /// [`shown`] gives it; SVG or SVGZ as [`Files::svg_text`] gives it. + fn decoded(&self, mime: &str, bytes: Bytes) -> Decoded { + use image::ImageFormat as F; + let format = match mime { + "image/jpg" | "image/jpeg" => F::Jpeg, + "image/png" => F::Png, + "image/gif" => F::Gif, + "image/webp" => F::WebP, + "image/tiff" => F::Tiff, + "image/svg+xml" => return self.svg_text(&bytes), + "text/plain" => match image::guess_format(&bytes).ok().filter(|f| raster(*f).is_some() || *f == F::Tiff) { + Some(format) => format, + None => return self.svg_text(&bytes), + }, + _ => return Decoded::Nothing, + }; + match shown(bytes, format) { + Some(shown) => { + let (kind, bytes) = self.keep(shown); + Decoded::Raster(kind, bytes) + } + None => Decoded::Nothing, + } + } + + /// The text of SVG image data `bytes` (SVG or SVGZ). Unpacked here, within what the SVGZ + /// images have left, so usvg unpacks nothing itself; past it, nothing, noted. + fn svg_text(&self, bytes: &[u8]) -> Decoded { match crate::text_within(bytes, &self.unpacked) { - Ok(text) => self.svg(&text, None, opts), - Err(e) => leave_out(&self.too_big, matches!(e, SvgError::TooBig(_))), + Ok(text) => Decoded::Svg(Arc::from(text.as_ref())), + Err(e) => { + leave_out(&self.too_big, matches!(e, SvgError::TooBig(_))); + Decoded::Nothing + } + } + } + + /// The image usvg gets for `decoded`: an SVG image read here, where it is shown. + fn show(&self, decoded: Decoded, opts: &usvg::Options) -> Option { + match decoded { + Decoded::Raster(kind, bytes) => Some(kind(bytes)), + Decoded::Svg(text) => self.svg(&text, None, opts), + Decoded::Nothing => None, } } @@ -569,6 +739,9 @@ impl Files { let Some(height) = depth::height(&xml, left) else { return leave_out(&self.too_deep, true) }; // usvg's parser would overflow on it, as on the SVG holding it (`import`). super::convolve_matrices_ok(&xml).ok()?; + let mut edits = Edits::default(); + self.take_data_urls(&xml, &mut edits); + let keyed = edits.apply(text); self.depth.store(used + height, Ordering::Relaxed); let within = self.within.lock().ok().map(|mut w| w.push(file)).is_some(); // usvg's `Tree::from_data_nested`, except that hrefs to files go to the resolver. @@ -587,7 +760,7 @@ impl Files { }, ..usvg::Options::default() }; - let tree = usvg::Tree::from_str(text, &nested); + let tree = usvg::Tree::from_str(&keyed, &nested); if within && let Ok(mut w) = self.within.lock() { w.pop(); } @@ -980,6 +1153,178 @@ mod tests { assert_eq!(files.left_out().len(), 2, "{:?}", files.left_out()); } + /// A base64 data URL of `bytes` of type `mime`. + fn data(mime: &str, bytes: &[u8]) -> String { + format!("data:{mime};base64,{}", crate::base64_encode(bytes)) + } + + /// A PNG of `w` × 1. + fn png_of(w: u32) -> Vec { + let mut out = vec![]; + image::RgbaImage::new(w, 1).write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + out + } + + /// SVG text of 2 × 2 holding `body`. + fn doc(body: &str) -> String { + format!(r#"{body}"#) + } + + /// `svg` read as the import reads it, its data URLs changed to keys first, `spent` bytes of + /// [`MAX_DATA_URL_BYTES`] already decoded: the files, and the PNG bytes of the images usvg + /// made, filters' included, in order. + fn read_keyed(svg: &str, spent: u64) -> (Files, Vec>>) { + let xml = super::super::parse(svg).unwrap(); + let mut edits = Edits::default(); + let files = Files::read(svg, &xml, &ImportOptions::default(), &mut edits, &mut vec![]); + files.data_bytes.store(spent, Ordering::Relaxed); + let text = edits.apply(svg).into_owned(); + let tree = { + let height = depth::height(&xml, MAX_ELEMENT_DEPTH).unwrap(); + let opts = usvg::Options { image_href_resolver: files.resolver(height), ..usvg::Options::default() }; + usvg::Tree::from_str(&text, &opts).unwrap() + }; + // Filters' images and SVG images' trees are subroots. + fn pngs(g: &usvg::Group, out: &mut Vec>>) { + for n in g.children() { + match n { + usvg::Node::Group(g) => pngs(g, out), + usvg::Node::Image(im) => { + if let ImageKind::PNG(b) = im.kind() { + out.push(b.clone()); + } + } + _ => {} + } + n.subroots(|g| pngs(g, out)); + } + } + let mut out = vec![]; + pngs(tree.root(), &mut out); + (files, out) + } + + /// A data URL shown by many `` elements is decoded once, and every use gets the same + /// image, where usvg decoded it again for each. + #[test] + fn a_data_url_is_decoded_once_however_often_it_is_used() { + let png = png(); + let uses = r##""##.repeat(64); + let svg = doc(&format!(r#"{uses}"#, data("image/png", &png))); + let (files, shown) = read_keyed(&svg, 0); + assert_eq!(shown.len(), 64); + assert!(shown.iter().all(|b| Arc::ptr_eq(b, &shown[0]) && **b == png)); + assert_eq!(files.data_bytes.load(Ordering::Relaxed), png.len() as u64); + assert!(files.left_out().is_empty(), "{:?}", files.left_out()); + } + + /// The data URLs inside an SVG image get keys too: each is decoded once, together with the + /// SVG image's own URL. + #[test] + fn data_urls_in_svg_images_are_decoded_once() { + let png = png(); + let uses = r##""##.repeat(16); + let inner = doc(&format!(r#"{uses}"#, data("image/png", &png))); + let svg = doc(&format!(r#""#, data("image/svg+xml", inner.as_bytes()))); + let (files, shown) = read_keyed(&svg, 0); + assert_eq!(shown.len(), 16); + assert_eq!(files.data_bytes.load(Ordering::Relaxed), (inner.len() + png.len()) as u64); + } + + /// An ``'s data URL gets a key too. + #[test] + fn fe_image_data_urls_are_decoded_once() { + let png = png(); + let rects: String = (1..=8).map(|i| format!(r#""#)).collect(); + let svg = doc(&format!(r#"{rects}"#, data("image/png", &png))); + let (files, shown) = read_keyed(&svg, 0); + assert_eq!(shown.len(), 8); + assert_eq!(files.data_bytes.load(Ordering::Relaxed), png.len() as u64); + } + + /// Both hrefs get keys, and usvg still takes the plain `href` over `xlink:href`, in either + /// order: only its URL is decoded. + #[test] + fn the_plain_href_still_wins_over_xlink_href() { + let (one, two) = (png_of(1), png_of(2)); + let (one_url, two_url) = (data("image/png", &one), data("image/png", &two)); + for image in [ + format!(r#""#), + format!(r#""#), + ] { + let (files, shown) = read_keyed(&doc(&image), 0); + assert_eq!(shown.len(), 1); + assert_eq!(*shown[0], one); + assert_eq!(files.data_bytes.load(Ordering::Relaxed), one.len() as u64); + } + } + + /// An image whose data URL is written in an entity, and so in every element the entity + /// makes, gets a key too. + #[test] + fn data_urls_written_in_entities_are_decoded_once() { + let png = png(); + let uses = r##""##.repeat(16); + let svg = format!( + r#"">]>{}"#, + data("image/png", &png), + doc(&format!("&img;{uses}")) + ); + let (files, shown) = read_keyed(&svg, 0); + assert_eq!(shown.len(), 16); + assert_eq!(files.data_bytes.load(Ordering::Relaxed), png.len() as u64); + } + + /// The data URLs decode to at most [`MAX_DATA_URL_BYTES`] together: a URL that fits what is + /// left is decoded, and decoding stops where a URL would go past it, that image left out and + /// noted. + #[test] + fn data_urls_past_the_limit_are_left_out() { + let (small, big) = (png(), png_of(64)); + assert!(small.len() < big.len()); + let svg = doc(&format!( + r#""#, + data("image/png", &small), + data("image/png", &big) + )); + let spent = MAX_DATA_URL_BYTES - (small.len() + big.len()) as u64 + 1; + let (files, shown) = read_keyed(&svg, spent); + assert_eq!(shown.len(), 1); + assert_eq!(*shown[0], small); + assert!(files.data_bytes.load(Ordering::Relaxed) <= MAX_DATA_URL_BYTES); + assert_eq!(files.left_out(), [format!("images were left out: together their data URLs decode to more than {} MB", MAX_DATA_URL_BYTES >> 20)]); + // A data URL usvg decodes itself counts too. + let files = Files::default(); + files.data_bytes.store(MAX_DATA_URL_BYTES, Ordering::Relaxed); + let resolver = files.resolver(1); + assert!((resolver.resolve_data)("image/png", Arc::new(small.clone()), &usvg::Options::default()).is_none()); + drop(resolver); + assert_eq!(files.left_out().len(), 1); + } + + /// A link that looks like a key, spelled plainly or with character references, in the + /// imported SVG or in an SVG image, stays that link: it never shows a data URL's image. + #[test] + fn links_that_look_like_keys_stay_links() { + let png = png(); + let url = data("image/png", &png); + let inner = doc(&format!(r#""#)); + let svg = doc(&format!( + r#""#, + &DATA_URL_KEY[1..], + data("image/svg+xml", inner.as_bytes()) + )); + let (files, shown) = read_keyed(&svg, 0); + // The data URL's image, then the placeholder of the missing file, twice; the SVG image's + // link names no file there, so it shows nothing. + assert_eq!(shown.len(), 3); + assert_eq!(*shown[0], png); + assert!(shown[1..].iter().all(|b| **b != png && files.links.get(&key(b)).is_some_and(|l| l.missing))); + assert_eq!(files.data_number(&format!("{DATA_URL_KEY}00")), None); + assert_eq!(files.data_number(&format!("{DATA_URL_KEY}+0")), None); + assert_eq!(files.data_number(&format!("{DATA_URL_KEY}9")), None); + } + /// The nested SVG images read, decoded or not, take at most [`MAX_NESTED_SVG_TEXT`] bytes of /// text together: an image whose text goes past it is left out and noted. A broken image's /// text counts too, and its decoded place is given back. diff --git a/crates/svg/src/import/prepass.rs b/crates/svg/src/import/prepass.rs index 3d1b04ad60..e541963561 100644 --- a/crates/svg/src/import/prepass.rs +++ b/crates/svg/src/import/prepass.rs @@ -95,6 +95,11 @@ impl Edits { } } + /// Set the value of attribute `a` to `value` (XML-escaped already). + pub(crate) fn set_value(&mut self, a: roxmltree::Attribute, value: &str) { + self.list.push((a.range_value(), value.to_string())); + } + /// Add the declaration `decl` (XML-escaped already) to the `style` of element `n` (in `svg`), /// after the ones already there (so it wins over them and over style sheet rules). fn style(&mut self, svg: &str, n: XNode, decl: &str) { @@ -163,7 +168,8 @@ fn named_made_up_ids(xml: &roxmltree::Document) -> HashSet { /// * so do `` and `` elements, recorded: usvg makes their arcs into a varying /// number of curves, and they come back as the ellipse VectorCraft draws (not in a file whose /// style sheet has an attribute selector); -/// * linked files are read ([`files`]); +/// * linked files are read, and the data URLs of `` and `` elements are each +/// changed to a key that the resolver decodes once ([`files`]); /// * percentage lengths in a `` with a `viewBox` become numbers ([`symbol_units`]); /// * what style sheet rules with a `:not()` set goes in `style` attributes ([`not_rules`]). /// @@ -190,7 +196,7 @@ pub(super) fn prepass<'s>( && !events && !foreign_attrs && !ellipses - && ![" bool) -> Resu } pub use glyph::glyph_art; -pub use import::{MAX_ELEMENT_DEPTH, MAX_FOREIGN, MAX_FOREIGN_VALUE, MAX_NESTED_SVG_IMAGES, MAX_NESTED_SVG_READS, MAX_NESTED_SVG_TEXT, MAX_NODES}; +pub use import::{ + MAX_DATA_URL_BYTES, MAX_ELEMENT_DEPTH, MAX_FOREIGN, MAX_FOREIGN_VALUE, MAX_NESTED_SVG_IMAGES, MAX_NESTED_SVG_READS, MAX_NESTED_SVG_TEXT, + MAX_NODES, +}; /// Import an SVG document reading the files it links to as `opts` says, also returning warnings. pub fn import_with(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec), SvgError> { diff --git a/crates/svg/tests/it/import_details.rs b/crates/svg/tests/it/import_details.rs index 69e5cafa21..9a048e0825 100644 --- a/crates/svg/tests/it/import_details.rs +++ b/crates/svg/tests/it/import_details.rs @@ -862,6 +862,21 @@ fn nested_svg_images_past_count_cap_are_left_out() { assert!(warnings.iter().any(|w| w.contains(&format!("more than {MAX_NESTED_SVG_IMAGES} nested SVG images")))); } +/// A data URL shown by `` elements, in the SVG and in an SVG image of it, imports as one +/// image shown at each use. +#[test] +fn data_url_images_shown_by_uses_import_at_each_use() { + let png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGD4DwABBAEAwS2OUAAAAABJRU5ErkJggg=="; + let image = format!(r#""#); + let uses = r##""##.repeat(3); + let inner = format!(r#"{image}{uses}"#); + let (d, w) = import_with_report(&svg(&format!("{image}{uses}{}", image_of("image/svg+xml", &inner)))).unwrap(); + assert!(w.is_empty(), "{w:?}"); + let found = images(&d); + assert_eq!(found.len(), 6); + assert!(found.iter().all(|(_, im)| im.key == found[0].1.key && (im.width, im.height) == (1, 1))); +} + /// Nested SVG images that can't be read: text that isn't XML, and an SVG without a size. const BROKEN_SVG_IMAGES: [&str; 2] = [r#""#]; diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9ba8866a8c..f6dd2ae146 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1821,6 +1821,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - An image that Open and Place store again keeps the ICC profile its file embeds and its 16-bit samples. A turned PNG, JPEG, WebP or TIFF stored as PNG, and a TIFF the image decoder reads, keep their gray or RGB samples at 8 or 16 bits with their gray or RGB profile in the PNG's `iCCP` chunk ([PNG specification, iCCP](https://www.w3.org/TR/png-3/#11iCCP)). A CMYK TIFF stored with one plane per ink and a turned CMYK JPEG or TIFF keep their CMYK profile as TIFF tag 34675, a JPEG's put together from all its APP2 segments ([ICC image-format guide](https://github.com/InternationalColorConsortium/iccDEV/blob/master/iccdev-mcp/docs/icc-format-guide.md)). A profile whose color space differs from the stored samples' is left out. The profile is kept in the stored image; how CMYK inks are drawn and exported to PDF does not change (`crates/engine/src/cmd/fileio/tests.rs`). - SVG import shows a raster `` whose EXIF orientation or TIFF Orientation tag turns or flips it upright, linked or as a data URL, and inside an SVG shown as an image: the pixels are stored turned, the width and height swap, and the `` box fits the turned image under `preserveAspectRatio`, as CSS `image-orientation: from-image` describes (). A turned CMYK JPEG or TIFF keeps its inks as a CMYK TIFF, as does a CMYK TIFF that is not turned. TIFF data URLs (`image/tiff`, or untyped TIFF bytes) are read, and TIFFs with an alpha or other extra channel, CCITT Group 3 TIFFs and CMYK TIFFs stored one plane per ink are read as Open and Place read them. The pixels are decoded within the decode allowance, and a TIFF claiming more strips or tiles than the decoder can count is refused before it is decoded (`crates/svg/tests/it/image_orientation.rs`). - SVG import leaves out an SVG shown as an image that it can't read (text that isn't XML, an SVG without a size) and goes on with the images after it, as SVG 2's error processing describes (). Only images that decode count toward the 256 nested SVG images an SVG may show, so an image after many broken ones is imported. Every nested SVG image read, decoded or not, counts toward VectorCraft's own limits of 1,024 reads and 512 MB of text per import; past them, nested SVG images are left out with a warning (`crates/svg/tests/it/import_details.rs`). + - SVG import decodes each image data URL (RFC 2397, ) of an `` or `` once, in the imported SVG and in the SVGs shown as its images, however many `` elements, filters or SVG images show it; every use gets the same image. The data URLs of one import decode to at most 512 MB together, VectorCraft's own limit: decoding stops where a URL would pass it, and that image is left out with a warning (`crates/svg/src/import/files.rs`, `crates/svg/tests/it/import_details.rs`). - SVG import reads the files that a linked SVG file's own `` elements link to, relative paths from that file's folder (), as it reads the imported SVG's links: a raster stays linked, an SVG file imports as vector art, and one that can't be read gets the placeholder, its link and a warning. SVG 2 tells a viewer not to load these links (); reading them is VectorCraft's own rule. The same href in SVG files of two folders names two files. A link that leads back to an SVG file showing it gets the placeholder and a warning, and reading the links of linked SVG files stops at the nesting depth cap and at 1,024 reads and 512 MB per import, the same limits decoded nested SVG images have, counted separately from them. The text of every linked SVG file read counts, and so does every file read for the links of a linked SVG file, raster or SVG; rasters the imported SVG links to don't. Once either limit is passed, no file linked from a linked SVG file is read and its image is left out with a warning, and an SVG file the imported SVG links to is still shown, without the files its own links name, with a warning. Another href to an SVG file already read, spelled differently or in another SVG file, does not read it again, unless a link back or the depth cap changes what it shows there (`crates/svg/tests/it/import_details.rs`, `crates/svg/src/import/files.rs`). - 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. From 90a2bd0494ed1997e4f169baad01747e7b10161e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 08:54:28 -0800 Subject: [PATCH 12/52] Graphs: aligned paint and stroke rows given with a design's art are rows of its stack paint.setFill, paint.setStroke, paint.invert and paint.complement with an item, stroke.set, stroke.setAdvanced, paint.editGradient, paint.setGradientGeom and the paint.freeform edits read an item given with aligned: true and the art of a graph design in a series first as a row of the art's stack, as appearance.setItem and transparency.set do. Each other object edits the row equal to it and an object without one is left as it is. A row the art does not have, a row of the other kind, or a row no other object has is an error, also when the art is the only object given, and the document and its undo history stay as they are. Without stroke, the gradient commands edit the kind of the art's row. The objects and ids the commands report, a gradient spanning the selection, and the last color and last gradient count only the objects whose row was edited. --- crates/engine/src/cmd/appearance.rs | 63 ++++++++-- crates/engine/src/cmd/effectcmd.rs | 12 +- crates/engine/src/cmd/freeform.rs | 8 +- crates/engine/src/cmd/gradient.rs | 20 +++- crates/engine/src/cmd/graph/tests.rs | 167 +++++++++++++++++++++++++++ crates/engine/src/cmd/paint.rs | 35 ++++-- crates/engine/src/cmd/stroke.rs | 11 +- docs/mcp.md | 10 +- 8 files changed, 280 insertions(+), 46 deletions(-) diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 09b03f2d63..439661b209 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -422,6 +422,15 @@ pub(crate) fn design_reference(s: &Session, p: &Value, cmd: &str) -> Result Result> { + if matches!(item, ItemTarget::Item { explicit: false, .. }) { + return Ok(None); + } + design_reference(s, p, cmd) +} + /// `ids` after `of` (see [`design_reference`]), when there is one. pub(crate) fn with_reference(of: Option, ids: &[NodeId]) -> Vec { of.into_iter().chain(ids.iter().copied()).collect() @@ -606,16 +615,22 @@ fn require_targets(s: &Session, p: &Value, cmd: &str) -> Result> { /// What the edit changes on the topmost visible fill and stroke of graph series marks is stored on /// their graphs in the same step ([`capture_series_changes`]). Nothing targeted is not an edit, and /// when `f` leaves every target as it was, the document and its undo history stay as they are. +/// With `of` (see [`reference_of`]), an explicit aligned `item` is a row of the stack of `of`, which +/// is not among `ids`: it must be there and of the edited kind, each target edits the row equal to +/// it, a target without one is left as it is, and no target with one is an error. Returns the +/// targets `f` was aimed at, in the order of `ids`. +#[allow(clippy::too_many_arguments)] pub(crate) fn edit_items( s: &mut Session, ids: &[NodeId], item: ItemTarget, + of: Option, cmd: &str, label: &str, fill: bool, f: impl FnMut(&mut Node, Option) -> Result<()>, -) -> Result<()> { - edit_items_then(s, ids, item, cmd, label, fill, f, |before, d, edited| Ok(capture_series_changes(before, d, edited))) +) -> Result> { + edit_items_then(s, ids, item, of, cmd, label, fill, f, |before, d, edited| Ok(capture_series_changes(before, d, edited))) } /// [`edit_items`], then `after` on the document as it was before the edit, the edited document and @@ -627,17 +642,29 @@ pub(crate) fn edit_items_then( s: &mut Session, ids: &[NodeId], item: ItemTarget, + of: Option, cmd: &str, label: &str, fill: bool, mut f: impl FnMut(&mut Node, Option) -> Result<()>, mut after: impl FnMut(&vectorcraft_doc::Document, &mut vectorcraft_doc::Document, &[NodeId]) -> Result, -) -> Result<()> { - if ids.is_empty() { - return Ok(()); - } +) -> Result> { + let art = of.and_then(|of| s.doc().ok()?.doc.node(of).map(|n| n.appearance.clone())); + let per = match (item, art) { + // Checked even when the art is the only object given, which leaves `ids` empty. + (ItemTarget::Item { index, explicit: true, aligned: true }, Some(art)) => { + item.resolve(&art, fill, cmd)?; + let per = item.per_target_of(s, of, ids); + if per.iter().all(Option::is_none) { + return Err(bad(cmd, format!("no appearance item {index}"))); + } + per + } + _ if ids.is_empty() => return Ok(Vec::new()), + _ => item.per_target(s, ids), + }; + let aimed: Vec = ids.iter().zip(&per).filter(|(_, item)| item.is_some()).map(|(id, _)| *id).collect(); let before = s.doc()?.doc.clone(); - let per = item.per_target(s, ids); super::overprint::edit_counted(s, label, |d| { let mut edited = Vec::new(); // The objects whose rows `f` edited: on type, no row means its characters. @@ -656,7 +683,13 @@ pub(crate) fn edit_items_then( let labels = super::graph::capture_label_rows(d, &rows); Ok(count_changed(d, &before, &edited) + usize::from(more || labels)) })?; - Ok(()) + Ok(aimed) +} + +/// The first of `ids` that [`edit_items`] aimed at `item` with `of` changes, and the item it changes there. Read it +/// before the edit: once edited, a row that was equal to a row of the art of `of` no longer is. +pub(crate) fn first_edited(s: &Session, item: ItemTarget, of: Option, ids: &[NodeId]) -> Option<(NodeId, ItemTarget)> { + ids.iter().zip(item.per_target_of(s, of, ids)).find_map(|(id, it)| Some((*id, it?))) } /// How many of `ids` differ in `d` from `before`. @@ -665,8 +698,8 @@ pub(crate) fn count_changed(d: &vectorcraft_doc::Document, before: &vectorcraft_ } /// Whether an edit aimed at `item` changes a stroke: the `stroke` param, else the kind of the -/// targeted item (on the object whose row it is, see [`ItemTarget::anchor`]), else `default`. A `stroke` other than true, false or -/// null is an error of command `cmd`. +/// targeted item (on the object whose row it is, see [`ItemTarget::anchor`] and [`reference_of`]), else `default`. A `stroke` other than +/// true, false or null is an error of command `cmd`. pub(crate) fn edits_stroke(s: &Session, p: &Value, item: ItemTarget, default: bool, cmd: &str) -> Result { check_switches(p, &["stroke"], cmd)?; if let Some(b) = p.get("stroke").and_then(Value::as_bool) { @@ -674,8 +707,14 @@ pub(crate) fn edits_stroke(s: &Session, p: &Value, item: ItemTarget, default: bo } let ItemTarget::Item { index, .. } = item else { return Ok(default) }; let d = &s.doc()?.doc; - let ids = drop_design_art(d, appearance_targets(s, p)?); - Ok(ids.get(item.anchor(s, &ids)).and_then(|id| d.node(*id)?.appearance.items.get(index)).map_or(default, |it| !it.is_fill())) + let holder = match reference_of(s, p, item, cmd)? { + Some(of) => Some(of), + None => { + let ids = drop_design_art(d, appearance_targets(s, p)?); + ids.get(item.anchor(s, &ids)).copied() + } + }; + Ok(holder.and_then(|id| d.node(id)?.appearance.items.get(index)).map_or(default, |it| !it.is_fill())) } /// The whole number of 0 or more that `key` holds, read with [`u64_req`] and [`index`]. diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 7619444c74..798b635e65 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -14,7 +14,8 @@ use vectorcraft_geom::{FillRule, PathData}; use vectorcraft_render::effects; use super::appearance::{ - ItemTarget, aligned, appearance_targets, design_reference, edit_targets, index_param, item_indices, item_target, item_target_at, with_reference, + ItemTarget, aligned, appearance_targets, design_reference, edit_targets, index_param, item_indices, item_target, item_target_at, reference_of, + with_reference, }; use super::expand::has_seeded; use super::rasterfx; @@ -336,15 +337,6 @@ pub(crate) fn effect_targets(s: &Session, p: &Value, item: ItemTarget, index: us /// have. const APPENDED: usize = usize::MAX; -/// The [`design_reference`] of an effect edit aimed at `item`: none for the Appearance panel's active row, which is a -/// row of the first selected object's stack. -fn reference_of(s: &Session, p: &Value, item: ItemTarget, cmd: &str) -> Result> { - if matches!(item, ItemTarget::Item { explicit: false, .. }) { - return Ok(None); - } - design_reference(s, p, cmd) -} - #[allow(clippy::too_many_arguments)] fn edit_effects( s: &mut Session, diff --git a/crates/engine/src/cmd/freeform.rs b/crates/engine/src/cmd/freeform.rs index a37cce38d0..c0cb1b5292 100644 --- a/crates/engine/src/cmd/freeform.rs +++ b/crates/engine/src/cmd/freeform.rs @@ -201,7 +201,9 @@ fn edit(s: &mut Session, p: &Value, cmd: &str, mut f: impl FnMut(&mut Freefor first.get_or_insert(r); Ok(()) }; - edit_items(s, &ids, item, cmd, "Gradient", !stroke, |n, index| { + let of = super::appearance::reference_of(s, p, item, cmd)?; + let shown_at = super::appearance::first_edited(s, item, of, &ids); + edit_items(s, &ids, item, of, cmd, "Gradient", !stroke, |n, index| { if index.is_none() && let NodeKind::Text(t) = &mut n.kind { @@ -222,7 +224,9 @@ fn edit(s: &mut Session, p: &Value, cmd: &str, mut f: impl FnMut(&mut Freefor })?; let r = first.ok_or_else(|| bad(cmd, NOT_FREEFORM))?; // The edited gradient is the last one used (`paint.lastGradient`). - let shown = s.doc()?.doc.node(ids[0]).map(|n| super::paint::proxy_paint(n, stroke, item.resolve(&n.appearance, !stroke, cmd).ok().flatten())); + let shown = shown_at.and_then(|(id, item)| { + s.doc().ok()?.doc.node(id).map(|n| super::paint::proxy_paint(n, stroke, item.resolve(&n.appearance, !stroke, cmd).ok().flatten())) + }); if let Some(p) = shown { s.remember_paint(&p); } diff --git a/crates/engine/src/cmd/gradient.rs b/crates/engine/src/cmd/gradient.rs index 7c7724f0f9..a3ebacf42f 100644 --- a/crates/engine/src/cmd/gradient.rs +++ b/crates/engine/src/cmd/gradient.rs @@ -580,7 +580,10 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result { let new_default = apply_gradient_edit_in_model(&default_paint, &with_stored_stops(&for_default, &default_paint, &stored), None, &|_| true, model) .map_err(|e| bad(C, e))?; let freeform = str_param(p, "kind").and_then(GradientKind::parse) == Some(GradientKind::Freeform); - edit_items(s, &ids, item, C, "Gradient", !stroke, |n, index| { + let of = super::appearance::reference_of(s, p, item, C)?; + let shown_at = super::appearance::first_edited(s, item, of, &ids); + // The objects returned: with a design's art, those with a row equal to the art's. + let edited = edit_items(s, &ids, item, of, C, "Gradient", !stroke, |n, index| { if index.is_none() && let NodeKind::Text(t) = &mut n.kind { @@ -616,9 +619,12 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result { st.gradient_mode = m; } // The last gradient is the one the first object now shows (the defaults' without objects). - let shown = match ids.first() { - Some(id) => s.doc()?.doc.node(*id).map(|n| super::paint::proxy_paint(n, stroke, item.resolve(&n.appearance, !stroke, C).ok().flatten())), - None => Some(new_default.clone()), + let shown = match shown_at { + Some((id, item)) => { + s.doc()?.doc.node(id).map(|n| super::paint::proxy_paint(n, stroke, item.resolve(&n.appearance, !stroke, C).ok().flatten())) + } + None if ids.is_empty() => Some(new_default.clone()), + None => None, }; if let Some(shown) = shown { s.remember_paint(&shown); @@ -628,7 +634,7 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result { } else { s.paint.fill = new_default; } - Ok(json!({"ids": ids.iter().map(|i| i.0).collect::>()})) + Ok(json!({"ids": edited.iter().map(|i| i.0).collect::>()})) } /// A painted stroke on a type run without a weight gets 1 pt. @@ -766,7 +772,9 @@ fn set_gradient_geom(s: &mut Session, p: &Value) -> Result { } let targets = super::appearance::drop_design_art(&s.doc()?.doc, targets); let model = super::paint::new_color_model(s, &Value::Null); - edit_items(s, &targets, item, C, "Gradient", !stroke, |n, index| { + let of = super::appearance::reference_of(s, p, item, C)?; + // The objects returned: with a design's art, those with a row equal to the art's. + let targets = edit_items(s, &targets, item, of, C, "Gradient", !stroke, |n, index| { if index.is_none() && let NodeKind::Text(t) = &mut n.kind { diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index df002ae526..ba059aac4a 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -859,6 +859,173 @@ fn aligned_rows_of_a_graph_designs_art_and_a_different_stack_are_not_moved() { assert_eq!(paints, [art_was.items[1].paint().clone(), art_was.items[0].paint().clone(), art_was.items[1].paint().clone()]); } +/// An `item` given with `aligned: true` and the art of a graph design in a series first is a row of the art's stack in the +/// paint and stroke commands: each other object edits the row equal to it, and a row the art does not have as a fill or +/// stroke as edited, or that no other object has, is an error that leaves the document and its undo history as they are. +#[test] +fn aligned_paint_rows_of_a_graph_designs_art_edit_the_equal_rows_of_the_other_objects() { + let rows = |s: &Session, id: NodeId| (*s.doc().unwrap().doc.node(id).unwrap().appearance).clone(); + let green = Paint::solid(vectorcraft_color::Color::rgb(0.0, 1.0, 0.0)); + // The art's row 1 is the blue fill the mark has as row 0, and its row 2 the black stroke the mark has as row 1. + let (mut s, _, art, mark) = aligned_design_art(); + let black = rows(&s, mark).items[1].clone(); + s.edit("Stroke", |d, _| { + d.node_mut(art).unwrap().appearance.items.push(black.clone()); + Ok(()) + }) + .unwrap(); + let (art_was, mark_was) = (rows(&s, art), rows(&s, mark)); + let fill = |s: &mut Session, cmd: &str, p: serde_json::Value| { + s.execute(cmd, &p).unwrap(); + let now = rows(s, mark); + assert_eq!(rows(s, art), art_was, "{cmd}"); + assert_ne!(now.items[0], mark_was.items[0], "{cmd} edits the mark's blue fill"); + assert_eq!(now.items[1], mark_was.items[1], "{cmd} leaves the mark's stroke"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(rows(s, mark), mark_was, "{cmd} is one undo step"); + now + }; + let ids = [art.0, mark.0]; + let now = fill(&mut s, "paint.setFill", json!({"ids": ids, "item": 1, "aligned": true, "color": "#00ff00"})); + assert_eq!(now.items[0].paint(), &green); + let now = fill(&mut s, "paint.invert", json!({"ids": ids, "item": 1, "aligned": true})); + assert_eq!(Some(s.last_solid), now.items[0].paint().color(), "the inverted color is the last color, not the art's"); + let now = fill(&mut s, "paint.editGradient", json!({"ids": ids, "item": 1, "aligned": true, "kind": "linear"})); + let Paint::Gradient(g) = now.items[0].paint() else { panic!("{:?}", now.items[0].paint()) }; + assert_eq!(&s.last_gradient, &**g, "the mark's new gradient is the last gradient"); + + let stroke = |s: &mut Session, cmd: &str, p: serde_json::Value| { + s.execute(cmd, &p).unwrap(); + let now = rows(s, mark); + assert_eq!(rows(s, art), art_was, "{cmd}"); + assert_eq!(now.items[0], mark_was.items[0], "{cmd} leaves the mark's fill"); + assert_ne!(now.items[1], mark_was.items[1], "{cmd} edits the mark's stroke"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(rows(s, mark), mark_was, "{cmd} is one undo step"); + now + }; + let now = stroke(&mut s, "paint.setStroke", json!({"ids": ids, "item": 2, "aligned": true, "color": "#00ff00"})); + assert_eq!(now.items[1].paint(), &green); + let now = stroke(&mut s, "stroke.set", json!({"ids": ids, "item": 2, "aligned": true, "weight": 5})); + assert_eq!(now.stroke_at(Some(1)).unwrap().width, 5.0); + let now = stroke(&mut s, "stroke.setAdvanced", json!({"ids": ids, "item": 2, "aligned": true, "arrowScale": [200, 200]})); + assert_eq!(now.stroke_at(Some(1)).unwrap().arrow_scale, (200.0, 200.0)); + // Without `stroke`, the kind of the art's row is edited. + let now = stroke(&mut s, "paint.editGradient", json!({"ids": ids, "item": 2, "aligned": true, "kind": "linear"})); + assert!(matches!(now.items[1].paint(), Paint::Gradient(_))); + fill(&mut s, "paint.complement", json!({"ids": ids, "item": 1, "aligned": true, "stroke": false})); + + let steps = s.doc().unwrap().history.undo.len(); + for (cmd, p, said) in [ + ("stroke.set", json!({"ids": ids, "item": 1, "aligned": true, "weight": 5}), "appearance item 1 is not a stroke"), + ("paint.setFill", json!({"ids": ids, "item": 2, "aligned": true, "color": "#00ff00"}), "appearance item 2 is not a fill"), + ("paint.setFill", json!({"ids": ids, "item": 0, "aligned": true, "color": "#00ff00"}), "no appearance item 0"), + ("paint.setFill", json!({"ids": ids, "item": 9, "aligned": true, "color": "#00ff00"}), "no appearance item 9"), + // The art alone: no other object has the row. + ("paint.setFill", json!({"ids": [art.0], "item": 9, "aligned": true, "color": "#00ff00"}), "no appearance item 9"), + ("paint.setFill", json!({"ids": [art.0], "item": 1, "aligned": true, "color": "#00ff00"}), "no appearance item 1"), + ("stroke.set", json!({"ids": [art.0], "item": 1, "aligned": true, "weight": 5}), "appearance item 1 is not a stroke"), + ("paint.editGradient", json!({"ids": [art.0], "item": 1, "aligned": true, "kind": "linear"}), "no appearance item 1"), + ] { + let defaults = format!("{:?}", (&s.paint.fill, &s.paint.stroke, &s.last_gradient)); + let e = s.execute(cmd, &p).unwrap_err().to_string(); + assert!(e.contains(said), "{cmd}: {e}"); + assert_eq!((rows(&s, art), rows(&s, mark)), (art_was.clone(), mark_was.clone()), "{cmd}"); + assert_eq!(s.doc().unwrap().history.undo.len(), steps, "{cmd}"); + assert_eq!(format!("{:?}", (&s.paint.fill, &s.paint.stroke, &s.last_gradient)), defaults, "{cmd}"); + } + + // An object with no row equal to the art's is left out of what the commands report as edited. + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + let rect = *s.doc().unwrap().selection.subjects().first().unwrap(); + s.execute("paint.setFill", &json!({"ids": [rect.0], "color": "#ff00ff"})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [rect.0], "color": "#ff00ff"})).unwrap(); + let rect_was = rows(&s, rect); + let ids = [art.0, mark.0, rect.0]; + for (cmd, p, key, said) in [ + ("paint.setFill", json!({"ids": ids, "item": 1, "aligned": true, "color": "#00ff00"}), "objects", json!(1)), + ("stroke.set", json!({"ids": ids, "item": 2, "aligned": true, "weight": 5}), "objects", json!(1)), + ("paint.editGradient", json!({"ids": ids, "item": 1, "aligned": true, "kind": "linear"}), "ids", json!([mark.0])), + ("paint.setGradientGeom", json!({"ids": ids, "item": 1, "aligned": true, "start": [0, 0], "end": [10, 40]}), "ids", json!([mark.0])), + ] { + if cmd == "paint.setGradientGeom" { + s.execute("paint.editGradient", &json!({"ids": [mark.0], "item": 0, "kind": "linear"})).unwrap(); + let gradient = rows(&s, mark).items[0].clone(); + s.edit("Gradient", |d, _| { + d.node_mut(art).unwrap().appearance.items[1] = gradient.clone(); + Ok(()) + }) + .unwrap(); + } + let r = s.execute(cmd, &p).unwrap(); + assert_eq!(r[key], said, "{cmd}: {r}"); + assert_eq!(rows(&s, rect), rect_was, "{cmd}"); + s.execute("edit.undo", &json!({})).unwrap(); + } + + // A gradient spanning the selection spans only the objects that take it: a distant object without an equal row does + // not move it. + s.execute("shape.rectangle", &json!({"x": 5000, "y": 5000, "width": 10, "height": 10})).unwrap(); + let far = *s.doc().unwrap().selection.subjects().first().unwrap(); + s.execute("paint.setFill", &json!({"ids": [far.0], "color": "#ff00ff"})).unwrap(); + let far_was = rows(&s, far); + let gradient = json!({"stops": [{"offset": 0, "color": "#000000"}, {"offset": 1, "color": "#ffffff"}]}); + let mut placed = Vec::new(); + for ids in [vec![art.0, mark.0], vec![art.0, mark.0, far.0]] { + s.execute("paint.setFill", &json!({"ids": ids, "item": 1, "aligned": true, "gradient": gradient, "span": "selection"})).unwrap(); + placed.push(rows(&s, mark).items[0].clone()); + assert_eq!(rows(&s, far), far_was); + s.execute("edit.undo", &json!({})).unwrap(); + } + assert!(matches!(placed[0].paint(), Paint::Gradient(_))); + assert_eq!(placed[1], placed[0]); +} + +/// The gradient and freeform gradient commands read an `item` given with `aligned: true` and the art of a graph design in +/// a series first as a row of the art's stack, as the paint commands do. +#[test] +fn aligned_gradient_rows_of_a_graph_designs_art_edit_the_equal_rows_of_the_other_objects() { + let rows = |s: &Session, id: NodeId| (*s.doc().unwrap().doc.node(id).unwrap().appearance).clone(); + for kind in ["linear", "freeform"] { + let (mut s, _, art, mark) = aligned_design_art(); + // The mark's fill and the art's equal row become the same gradient. + s.execute("paint.editGradient", &json!({"ids": [mark.0], "item": 0, "kind": kind})).unwrap(); + let gradient = rows(&s, mark).items[0].clone(); + // A yellow fill above the mark's gradient is the mark's row 1, as the gradient is the art's row 1. + let mut yellow = gradient.clone(); + *yellow.paint_mut() = Paint::solid(vectorcraft_color::Color::rgb(1.0, 1.0, 0.0)); + s.edit("Gradient", |d, _| { + d.node_mut(art).unwrap().appearance.items[1] = gradient.clone(); + d.node_mut(mark).unwrap().appearance.items.insert(1, yellow.clone()); + Ok(()) + }) + .unwrap(); + let ids = [art.0, mark.0]; + let edits = if kind == "linear" { + vec![ + ("paint.setGradientGeom", json!({"ids": ids, "item": 1, "aligned": true, "start": [0, 0], "end": [10, 40]})), + ("paint.editGradient", json!({"ids": ids, "item": 1, "aligned": true, "kind": "radial"})), + ] + } else { + vec![("paint.freeform.addPoint", json!({"ids": ids, "item": 1, "aligned": true, "at": [5, 5]}))] + }; + for (cmd, p) in edits { + let (art_was, mark_was) = (rows(&s, art), rows(&s, mark)); + s.execute(cmd, &p).unwrap(); + let now = rows(&s, mark); + assert_eq!(rows(&s, art), art_was, "{cmd}"); + assert_ne!(now.items[0], mark_was.items[0], "{cmd} edits the mark's gradient fill"); + assert_eq!(now.items[1..], mark_was.items[1..], "{cmd}"); + s.execute("edit.undo", &json!({})).unwrap(); + // The edited gradient is the last gradient (paint.setGradientGeom does not set it). + if cmd != "paint.setGradientGeom" { + let Paint::Gradient(g) = now.items[0].paint() else { panic!("{cmd}") }; + assert_eq!(&s.last_gradient, &**g, "{cmd}: the mark's edited gradient is the last gradient"); + } + } + } +} + /// The paint commands leave the art of a graph design in a series out, a copy of the design given by id with an item /// included. #[test] diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index 02875517f7..b4090cad29 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -538,7 +538,14 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) let item = item_target(s, p, cmd)?.of_kind(s, fill); let selected = item.targets(s, p)?; let ids = super::graph::paint_targets(&s.doc()?.doc, &selected); - let paint = if span { placed_across(&s.doc()?.doc, &ids, paint, p, fill) } else { paint }; + let of = super::appearance::reference_of(s, p, item, cmd)?; + let paint = if span { + // With a design's art, the gradient spans only the objects with a row equal to the art's, which take it. + let spanned: Vec = ids.iter().zip(item.per_target_of(s, of, &ids)).filter_map(|(id, it)| it.map(|_| *id)).collect(); + placed_across(&s.doc()?.doc, &spanned, paint, p, fill) + } else { + paint + }; // The gradient mesh points picked with the Mesh tool or Direct Selection: a solid fill // recolours just those points of their meshes. let mesh_points = if fill && matches!(paint, Paint::Solid { .. }) { picked_mesh_points(s, &ids) } else { vec![] }; @@ -555,10 +562,12 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) // The type objects whose characters took the paint (no row targeted): only those change what a graph's labels keep // for their characters; a row edit is kept with the label's rows. let characters = std::cell::RefCell::new(Vec::new()); - edit_items_then( + // The objects counted: with a design's art, those with a row equal to the art's. + let ids = edit_items_then( s, &ids, item, + of, cmd, label, fill, @@ -1004,26 +1013,34 @@ fn proxy_recolor(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn( return Ok(json!({ "changed": changed as usize })); } let item = item_target(s, p, cmd)?.of_kind(s, !stroke); - let (r, shown_item) = match item { + let (r, shown_at) = match item { ItemTarget::Item { index, .. } => { - let ids = super::appearance::drop_design_art(&s.doc()?.doc, item.targets(s, p)?); + let targets = super::appearance::drop_design_art(&s.doc()?.doc, item.targets(s, p)?); + let of = super::appearance::reference_of(s, p, item, cmd)?; + // With design art first, the row shown is the one edited in the first object that has it. + let shown_at = match of { + Some(_) => super::appearance::first_edited(s, item, of, &targets).and_then(|(id, it)| match it { + ItemTarget::Item { index, .. } => Some((id, Some(index))), + ItemTarget::Top => None, + }), + None => ids.first().map(|id| (*id, Some(index))), + }; let mut changed = 0; - edit_items(s, &ids, item, cmd, label, !stroke, |n, i| { + edit_items(s, &targets, item, of, cmd, label, !stroke, |n, i| { let paint = if stroke { n.appearance.stroke_at_mut(i).map(|l| &mut l.paint) } else { n.appearance.fill_at_mut(i).map(|l| &mut l.paint) }; changed += paint.is_some_and(|paint| super::colorcmds::map_paint(paint, f)) as usize; Ok(()) })?; - (json!({ "changed": changed }), Some(index)) + (json!({ "changed": changed }), shown_at) } // The proxy recolors its paints, including a colorized image's fill, without changing image pixels or pattern tiles. ItemTarget::Top => { let q = json!({"fill": !stroke, "stroke": stroke, "includeImages": false, "includePatterns": false, "ids": ids.iter().map(|i| i.0).collect::>()}); - (super::colorcmds::recolor(s, &q, cmd, label, f)?, None) + (super::colorcmds::recolor(s, &q, cmd, label, f)?, leaf_targets(s, &ids)?.first().map(|id| (*id, None))) } }; - let shown_ids = if shown_item.is_some() { ids } else { leaf_targets(s, &ids)? }; - if let Some(shown) = shown_ids.first().and_then(|id| s.doc().ok()?.doc.node(*id).map(|n| proxy_paint(n, stroke, shown_item))) { + if let Some(shown) = shown_at.and_then(|(id, at)| s.doc().ok()?.doc.node(id).map(|n| proxy_paint(n, stroke, at))) { s.remember_paint(&shown); } Ok(r) diff --git a/crates/engine/src/cmd/stroke.rs b/crates/engine/src/cmd/stroke.rs index 2a0159930d..5ee137b399 100644 --- a/crates/engine/src/cmd/stroke.rs +++ b/crates/engine/src/cmd/stroke.rs @@ -22,7 +22,7 @@ pub fn specs() -> Vec { "Stroke Options", ["Window", "Stroke"], None, - "{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|LineArrow|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null|{custom: name} (a custom arrowhead of the arrowheads file, stroke.arrowheads.list)|{Custom: {name, path}} (a custom head with its art, as document.inspect reports one; refused when the path fills no area, parts thinner than a millionth of a head unit counting as none, or has more than 4096 anchors, more than 4096 subpaths or a point more than 64 head units from the tip) (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does). On a path directly in a graph series, the graph also stores a weight it changes on the topmost visible stroke (docs/mcp.md); on a graph label's stroke row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind → {objects: how many objects took the stroke options}", + "{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|LineArrow|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null|{custom: name} (a custom arrowhead of the arrowheads file, stroke.arrowheads.list)|{Custom: {name, path}} (a custom head with its art, as document.inspect reports one; refused when the path fills no area, parts thinner than a millionth of a head unit counting as none, or has more than 4096 anchors, more than 4096 subpaths or a point more than 64 head units from the tip) (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection; with the art of a graph design in a series given first, rows of the art's stack: docs/mcp.md)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does). On a path directly in a graph series, the graph also stores a weight it changes on the topmost visible stroke (docs/mcp.md); on a graph label's stroke row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind → {objects: how many objects took the stroke options}", has_doc, stroke_set ), @@ -303,7 +303,9 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result { st.profile = pr.clone(); } }; - edit_items(s, &ids, item, C, "Stroke", false, |n, index| { + let of = super::appearance::reference_of(s, p, item, C)?; + // The objects counted: with a design's art, those with a row equal to the art's. + let ids = edit_items(s, &ids, item, of, C, "Stroke", false, |n, index| { if index.is_none() && let NodeKind::Text(t) = &mut n.kind { @@ -372,7 +374,7 @@ fn stroke_step(s: &mut Session, p: &Value, up: bool) -> Result { } let ids = super::appearance::drop_design_art(&s.doc()?.doc, ids); let label = if up { "Stroke Weight Up" } else { "Stroke Weight Down" }; - edit_items(s, &ids, item, c, label, false, |n, index| { + edit_items(s, &ids, item, None, c, label, false, |n, index| { if index.is_none() && let NodeKind::Text(t) = &mut n.kind { @@ -535,7 +537,8 @@ fn stroke_advanced(s: &mut Session, p: &Value) -> Result { { arrows_and_profile(st); } - edit_items(s, &ids, item, C, "Stroke", false, |n, index| { + let of = super::appearance::reference_of(s, p, item, C)?; + edit_items(s, &ids, item, of, C, "Stroke", false, |n, index| { let Some(st) = n.appearance.stroke_at_mut(index) else { return Ok(()) }; arrows_and_profile(st); if let Some(b) = &brush { diff --git a/docs/mcp.md b/docs/mcp.md index 7b13e9eb68..435e1d9629 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -829,9 +829,13 @@ given), `threeD.mapGraphic`, do not use the Appearance panel's active row. When the first object given is design art, rows and effects given with `aligned: true` are rows of its stack in `appearance.setItem`, `appearance.removeItem`, `appearance.duplicateItem`, `transparency.set` with an `item`, `effect.apply`, `appearance.addEffect` and the other `effect.*` commands that edit, -move or remove an effect: each other object edits the row or effect equal to it, and an object without one is left as -it is. When no object has one, `appearance.setItem` and `appearance.removeItem` leave the document as it is, as they do -for a row no object has, and the other commands are an error. `appearance.moveItem`, `effect.move` and +move or remove an effect, `paint.setFill`, `paint.setStroke`, `paint.invert` and `paint.complement` with an `item`, +`stroke.set`, `stroke.setAdvanced`, `paint.editGradient`, `paint.setGradientGeom` and the `paint.freeform.*` commands +that edit points and lines: each other object edits the row or effect equal to it, and an object without one is left +as it is. When no object has one, `appearance.setItem` and `appearance.removeItem` leave the document as it is, as they +do for a row no object has, and the other commands are an error. The paint, stroke and gradient commands are also an +error when the art's row is not the kind of row they edit, and `paint.editGradient`, `paint.setGradientGeom` and the +`paint.freeform.*` commands without `stroke` edit the kind of the art's row. `appearance.moveItem`, `effect.move` and `appearance.duplicateItem` with `to` are an error when the art's stack differs from the other objects', and `appearance.duplicateItem` with `to` is also an error when two or more other objects are given, as it is without the art. To change the art, edit the design with `graph.design`. From 9578b61d286e34ce7c8cb73da5dfaba9aa69c970 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 09:27:38 -0800 Subject: [PATCH 13/52] Graphs: invert and complement remember the color of the first object they recolor With a graph design's art given first and an item without aligned, the art is left out, but paint.invert and paint.complement remembered the art's unchanged color as the last color. They now read the first object they recolor. --- crates/engine/src/cmd/graph/tests.rs | 6 ++++++ crates/engine/src/cmd/paint.rs | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index cefadcda76..a48f012e40 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -890,6 +890,9 @@ fn aligned_paint_rows_of_a_graph_designs_art_edit_the_equal_rows_of_the_other_ob assert_eq!(now.items[0].paint(), &green); let now = fill(&mut s, "paint.invert", json!({"ids": ids, "item": 1, "aligned": true})); assert_eq!(Some(s.last_solid), now.items[0].paint().color(), "the inverted color is the last color, not the art's"); + // Without `aligned`, the art is left out and the item is a row of the mark. + let now = fill(&mut s, "paint.invert", json!({"ids": ids, "item": 0})); + assert_eq!(Some(s.last_solid), now.items[0].paint().color(), "the mark's inverted color is the last color, not the art's"); let now = fill(&mut s, "paint.editGradient", json!({"ids": ids, "item": 1, "aligned": true, "kind": "linear"})); let Paint::Gradient(g) = now.items[0].paint() else { panic!("{:?}", now.items[0].paint()) }; assert_eq!(&s.last_gradient, &**g, "the mark's new gradient is the last gradient"); @@ -926,6 +929,9 @@ fn aligned_paint_rows_of_a_graph_designs_art_edit_the_equal_rows_of_the_other_ob ("paint.setFill", json!({"ids": [art.0], "item": 1, "aligned": true, "color": "#00ff00"}), "no appearance item 1"), ("stroke.set", json!({"ids": [art.0], "item": 1, "aligned": true, "weight": 5}), "appearance item 1 is not a stroke"), ("paint.editGradient", json!({"ids": [art.0], "item": 1, "aligned": true, "kind": "linear"}), "no appearance item 1"), + // The weight steps take no row: they step each object's active or top stroke. + ("stroke.weightUp", json!({"ids": ids, "item": 2}), "unknown parameter `item`"), + ("stroke.weightDown", json!({"ids": ids, "aligned": true}), "unknown parameter `aligned`"), ] { let defaults = format!("{:?}", (&s.paint.fill, &s.paint.stroke, &s.last_gradient)); let e = s.execute(cmd, &p).unwrap_err().to_string(); diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index 91e1efdbe3..6e2071d7f8 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -1025,7 +1025,7 @@ fn proxy_recolor(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn( ItemTarget::Item { index, .. } => Some((id, Some(index))), ItemTarget::Top => None, }), - None => ids.first().map(|id| (*id, Some(index))), + None => targets.first().map(|id| (*id, Some(index))), }; let mut changed = 0; edit_items(s, &targets, item, of, cmd, label, !stroke, |n, i| { From 56cbdbb09c8372d08bd33b978100d97b64304006 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 11:11:20 -0800 Subject: [PATCH 14/52] MCP: SVG output under read roots refuses to name a linked image file outside them With automation read roots in force, an SVG or SVGZ that would name a linked image's file outside them is refused before any file, folder or reply is written, with "a linked image file is outside the read roots", which names no file or folder. An names the file with images: link, and with embed when the document holds no pixels for the image. With preserveEditing, the native document the SVG carries names every linked image and placed file, the relative paths kept of them and each data set's image, so those are checked too, and a stored brush entry that does not read as a brush is refused because its links cannot be checked. - The check runs where the linked JavaScript check runs: document.serialize, document.export, document.exportSelection, document.exportForScreens and assets.export, every SVG and SVGZ save, file.exportAs and the SVG Options dialog's Show Code. It also covers clipboard.exportSvg, and a Copy that an automation request asks for puts no SVG on the system clipboard when it would name such a file. - Only the art each file writes is checked: the selected objects with selectedOnly and Export Selection, the art over each chosen artboard, hidden layers and objects only when hiddenLayers is on (a save keeps them by default), never template layers or the symbol or opacity-mask editing layer, and the symbols, patterns and brushes that art uses. An image colorized with a solid fill whose pixels are gray or missing names no file, so it is not checked outside Preserve Editing. - Embedded pixels, a file inside the roots, a relative path that leads inside them and a URL are written as before, and the person at the keyboard exports every link as stored. Docs: docs/mcp.md, docs/target-app-parity.md and the params text; de and nl rows for the new message, next to the JavaScript message's rows. --- crates/engine/src/cmd/clipboard.rs | 29 +- crates/engine/src/cmd/fileio.rs | 3 +- crates/engine/src/cmd/fileio/access.rs | 43 +++ crates/engine/src/cmd/fileio/commands.rs | 8 +- crates/engine/src/cmd/fileio/export.rs | 8 +- crates/engine/src/cmd/fileio/save.rs | 16 +- crates/engine/src/cmd/fileio/screens.rs | 7 + crates/engine/src/cmd/fileio/svg.rs | 14 + crates/engine/src/cmd/links.rs | 107 ++++++- crates/engine/src/tests/links.rs | 347 ++++++++++++++++++++++ crates/mcp/src/ui_catalog.rs | 2 +- crates/ui-egui/src/dialogs/svg_options.rs | 50 +++- crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/io/export.rs | 1 + crates/ui-egui/src/menus/ui_commands.rs | 2 +- crates/ui-egui/src/sysclip.rs | 5 +- crates/ui-egui/src/tests/automation.rs | 40 +++ docs/mcp.md | 15 + docs/target-app-parity.md | 1 + 20 files changed, 678 insertions(+), 22 deletions(-) diff --git a/crates/engine/src/cmd/clipboard.rs b/crates/engine/src/cmd/clipboard.rs index 446634d21c..4e086569ae 100644 --- a/crates/engine/src/cmd/clipboard.rs +++ b/crates/engine/src/cmd/clipboard.rs @@ -32,7 +32,7 @@ use crate::DocState; pub fn specs() -> Vec { let mut v = vec![ - cmd!(query "clipboard.exportSvg", "Clipboard as SVG", [], None, "{} → {svg} the copied objects as standalone SVG (null when the clipboard is empty)", always, export_svg), + cmd!(query "clipboard.exportSvg", "Clipboard as SVG", [], None, "{} → {svg} the copied objects as standalone SVG (null when the clipboard is empty). Under automation read roots it is refused while the SVG would name a linked image's file outside them (an image whose pixels the clipboard doesn't hold; a relative path is followed from the document the objects came from while it is open)", always, export_svg), cmd!( query "clipboard.importSvg", "Load SVG into Clipboard", @@ -435,14 +435,37 @@ impl Clipboard { } impl Session { - /// The internal clipboard as standalone SVG (`None` when it is empty). + /// The internal clipboard as standalone SVG (`None` when it is empty, and when the SVG would name + /// a linked image's file the caller asking now may not be shown: [`Self::clipboard_svg_refused`]). pub fn clipboard_svg(&self) -> Option { + if self.clipboard_svg_refused() { + return None; + } self.clipboard.svg() } + + /// Would the internal clipboard's SVG name a linked image's file outside the read roots in + /// force? It names the file of an image whose pixels the clipboard doesn't hold. A relative + /// path is followed from the document the objects came from while it is open; otherwise it + /// counts as outside. + pub fn clipboard_svg_refused(&self) -> bool { + self.check_clipboard_svg("clipboard.exportSvg").is_err() + } + + /// [`Self::clipboard_svg_refused`] as `cmd`'s error, which names no file or folder. + fn check_clipboard_svg(&self, cmd: &str) -> Result<()> { + if crate::file_access::current().is_none() || !self.clipboard.has_art() { + return Ok(()); + } + let from = self.docs.iter().find(|d| Some(d.uid) == self.clipboard.source_doc); + let base = from.map_or_else(super::links::LinkBase::default, super::links::LinkBase::of); + super::fileio::check_images_in(cmd, "svg", &self.clipboard.to_document(), base, &json!({})) + } } fn export_svg(s: &mut Session, _: &Value) -> Result { - Ok(json!({ "svg": s.clipboard_svg() })) + s.check_clipboard_svg("clipboard.exportSvg")?; + Ok(json!({ "svg": s.clipboard.svg() })) } fn import_svg(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index 84fb121d0c..aa811b06b0 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -121,9 +121,10 @@ mod commands; mod export_names; mod formats; +pub(crate) use access::check_images_in; #[cfg(not(target_arch = "wasm32"))] pub(crate) use access::list_dir; -pub use access::{check_linked_scripts, write_file_with, write_save_file, write_save_file_with}; +pub use access::{check_linked_images, check_linked_scripts, write_file_with, write_save_file, write_save_file_with}; #[cfg(all(test, not(target_arch = "wasm32")))] mod tests_readonly; diff --git a/crates/engine/src/cmd/fileio/access.rs b/crates/engine/src/cmd/fileio/access.rs index 203a2262b0..cb5b4e85cc 100644 --- a/crates/engine/src/cmd/fileio/access.rs +++ b/crates/engine/src/cmd/fileio/access.rs @@ -139,6 +139,49 @@ pub fn check_linked_scripts(cmd: &str, format: &str, st: &crate::DocState) -> Re Ok(()) } +/// The error for an SVG that would name a linked image's file outside the read roots. +pub(crate) const IMAGE_OUTSIDE: &str = "a linked image file is outside the read roots"; + +/// `cmd` may write `d`, the document an output of `st` writes (all of it, or the objects it +/// exports), in format `format` (a format id) with options `p`: any format but SVG and SVGZ, and +/// those too unless read roots are in force and the file would name a linked image's file outside +/// them (a relative path taken from `st`'s folder; a URL is not checked). An `` names the +/// file with SVG Options › Images set to Link, and with Embed when the document holds no pixels for +/// the image, for the images of the art each file draws (with chosen artboards, the art over each +/// one; hidden layers and objects only with `hiddenLayers`, template layers and the opacity-mask +/// and symbol editing layers never) and of the symbols, patterns and brushes that art uses; with +/// Preserve Editing Capabilities, the native document the file carries names every linked file, the relative paths kept of them +/// and each data set's image. The error names no file or folder. +pub fn check_linked_images(cmd: &str, format: &str, st: &crate::DocState, d: &vectorcraft_doc::Document, p: &Value) -> Result<()> { + check_images_in(cmd, format, d, crate::cmd::links::LinkBase::of(st), p) +} + +/// [`check_linked_images`] for `d`, a document whose links are looked for from `base` (the +/// objects on the clipboard, looked for from the document they came from). +pub(crate) fn check_images_in(cmd: &str, format: &str, d: &vectorcraft_doc::Document, base: crate::cmd::links::LinkBase, p: &Value) -> Result<()> { + if !matches!(format, "svg" | "svgz") || crate::file_access::current().is_none() { + return Ok(()); + } + // Options that don't read as the encoder's choices count as Link, Preserve Editing and hidden + // layers kept: the encoder refuses them anyway. + let (link, preserve, hidden) = match super::svg::options_map(p) { + Ok(m) => { + let on = |k: &str| m.get(k).is_some_and(|v| v.as_bool() != Some(false)); + (m.get("images").is_some_and(|v| v.as_str() != Some("embed")), on("preserveEditing"), on("hiddenLayers")) + } + Err(_) => (true, true, true), + }; + // The opacity-mask and symbol editing layers are never written. + let d = &*d.without_edit_modes(); + let names = |d: &vectorcraft_doc::Document| crate::cmd::links::svg_names_hidden_image(d, base, link, hidden); + if preserve && crate::cmd::links::names_hidden_file(d, base) + || names(d) && super::svg::drawn(d, p).is_none_or(|files| files.iter().any(|f| names(f))) + { + return Err(bad(cmd, IMAGE_OUTSIDE)); + } + Ok(()) +} + /// `path` checked for writing against the automation roots in force. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn may_write(path: &str) -> Result<()> { diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index ca74243699..b1c9cf5e2d 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -27,7 +27,7 @@ pub fn specs() -> Vec { "Serialize Document", [], None, - "{format?: vectorcraft (default)|template|svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|hpgl|tiff|bmp|tga|psd (ait, a .ait template, is written by file.saveAsTemplate), …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own)} → {text, warnings} for svg (plus dataBase64, the file, when its encoding isn't UTF-8), else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is not used, and warnings name it. Under automation read roots, svg and svgz are refused while the document links a JavaScript file outside them (svgInteractivity; a URL is not checked)", + "{format?: vectorcraft (default)|template|svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|hpgl|tiff|bmp|tga|psd (ait, a .ait template, is written by file.saveAsTemplate), …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own)} → {text, warnings} for svg (plus dataBase64, the file, when its encoding isn't UTF-8), else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is not used, and warnings name it. Under automation read roots, svg and svgz are refused while the document links a JavaScript file outside them (svgInteractivity; a URL is not checked), or while the file would name a linked image's file outside them (images: link, preserveEditing, or an image whose pixels the document doesn't hold; a URL is not checked)", has_doc, export::serialize ), @@ -36,7 +36,7 @@ pub fn specs() -> Vec { "Export Document", [], None, - "{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|hpgl|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin., which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own), includeArtboardFill?: true (raster, PDF, SVG and EPS draw each exported artboard's background color behind its art, a transparent background too; false leaves it out), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; pdf/ai: without editing data, with a warning, and ai refuses pdfCompatible: false; SVG has it as an SVG option), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent; #rrggbbaa and #rgba are refused here and in matte), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts, variableData} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), …hpgl writes a plot of the artboard as pen moves for plotters and cutters (each visible path once along its line in plotter units; useArtboards: true one file per chosen artboard, false the bounds of the visible art), hpglVersion?: hpgl (default: IN, SP, PU, PD) | hpgl2 (also NP and PC pen colors, each pen's paths as one encoded polyline PE), curveTolerance?: 0.1 (points, 0.01 to 10), pens?: [\"#rrggbb\"…] (pen 1 first, up to 255; each path takes the nearest; default a pen per color in the order the art first draws in it, numbered alike in every file), origin?: bottomLeft (default) | center, sortPaths?: false (each pen's next path the nearest), insideFirst?: false (a path after the pen's paths that lie inside it), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is not used, and warnings name it. Under automation read roots, svg and svgz are refused while the document links a JavaScript file outside them (svgInteractivity; a URL is not checked). Never changes the document's path", + "{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|hpgl|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin., which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own), includeArtboardFill?: true (raster, PDF, SVG and EPS draw each exported artboard's background color behind its art, a transparent background too; false leaves it out), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; pdf/ai: without editing data, with a warning, and ai refuses pdfCompatible: false; SVG has it as an SVG option), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent; #rrggbbaa and #rgba are refused here and in matte), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts, variableData} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), …hpgl writes a plot of the artboard as pen moves for plotters and cutters (each visible path once along its line in plotter units; useArtboards: true one file per chosen artboard, false the bounds of the visible art), hpglVersion?: hpgl (default: IN, SP, PU, PD) | hpgl2 (also NP and PC pen colors, each pen's paths as one encoded polyline PE), curveTolerance?: 0.1 (points, 0.01 to 10), pens?: [\"#rrggbb\"…] (pen 1 first, up to 255; each path takes the nearest; default a pen per color in the order the art first draws in it, numbered alike in every file), origin?: bottomLeft (default) | center, sortPaths?: false (each pen's next path the nearest), insideFirst?: false (a path after the pen's paths that lie inside it), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is not used, and warnings name it. Under automation read roots, svg and svgz are refused while the document links a JavaScript file outside them (svgInteractivity; a URL is not checked), or while the file would name a linked image's file outside them (images: link, preserveEditing, or an image whose pixels the document doesn't hold; a URL is not checked). Never changes the document's path", has_doc, export::export ), @@ -45,7 +45,7 @@ pub fn specs() -> Vec { "Export Selection…", ["File"], None, - "{path?, format?: png|jpg|webp|svg|svgz|pdf|ai|gif|png8|tiff|bmp|tga|psd|txt|dxf|eps|emf|wmf|hpgl|vectorcraft|template (default: from the extension, else png), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), …the format's options} the selected objects (template layers left out), each in the layers and groups that hold it, whose clipping paths, opacity masks, effects, opacity and blend modes apply as on the canvas (an object of a blend, envelope, repeat or intertwine on its own; txt: the selected type alone), cropped to the bounds the renderer measures for that art (a clip group by its clip) → {path, format, bytes, warnings, bounds} (no path → {dataBase64, format, bytes, warnings, bounds}); warnings say what the format approximated or left out, as for document.export. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is not used, and warnings name it. Under automation read roots, svg and svgz are refused while the document links a JavaScript file outside them, as document.export refuses them. In the app, with no params or {}, the save panel picks the file and its format's options dialog follows (Save PDF, SVG Options, the raster options, EPS Options, DXF Options, Text Export Options or an exporter plug-in's parameters; other formats are written at once), whose OK writes the selection: the call returns null while the dialog is open, and OK returns what file.exportAs returns, without format or bounds (Text Export Options: {path})", + "{path?, format?: png|jpg|webp|svg|svgz|pdf|ai|gif|png8|tiff|bmp|tga|psd|txt|dxf|eps|emf|wmf|hpgl|vectorcraft|template (default: from the extension, else png), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), …the format's options} the selected objects (template layers left out), each in the layers and groups that hold it, whose clipping paths, opacity masks, effects, opacity and blend modes apply as on the canvas (an object of a blend, envelope, repeat or intertwine on its own; txt: the selected type alone), cropped to the bounds the renderer measures for that art (a clip group by its clip) → {path, format, bytes, warnings, bounds} (no path → {dataBase64, format, bytes, warnings, bounds}); warnings say what the format approximated or left out, as for document.export. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is not used, and warnings name it. Under automation read roots, svg and svgz are refused while the document links a JavaScript file outside them, or while the file would name a linked image's file outside them (an image among the selected objects), as document.export refuses them. In the app, with no params or {}, the save panel picks the file and its format's options dialog follows (Save PDF, SVG Options, the raster options, EPS Options, DXF Options, Text Export Options or an exporter plug-in's parameters; other formats are written at once), whose OK writes the selection: the call returns null while the dialog is open, and OK returns what file.exportAs returns, without format or bounds (Text Export Options: {path})", has_selection, export::export_selection ), @@ -54,7 +54,7 @@ pub fn specs() -> Vec { "Export for Screens", ["File", "Export"], None, - "{folder?, zip?: false (one .zip of every file, each deflated or stored, whichever is smaller: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboard?: index | artboards?: [index…] | range?: \"1-3\" (default all), assets?: [asset id…] (instead of artboards: each asset's art alone, cropped to its bounds and named after the asset; see assets.list), layers?: [layer id…] (instead of artboards: each layer's art alone, sublayers included, named after the layer), selection?: one|each (instead of artboards: the selected art as one file, or a file per selected object, named after it), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), includeArtboardFill?: true (Include Artboard Fill: each artboard's background color behind its art; false leaves it out), pdfs?: multiple|single (Export PDFs As: with several artboards, single writes them as the pages of one PDF per PDF row, named after the document), exportPreset?: (a preset of document.exportPresets.list: its settings, under these params), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), folders?: none|rows (as subfolders)|format (a folder per extension: png, svg, pdf…)|item (a folder per artboard, asset, layer or object)|itemFormat (item/extension) (default: rows with subfolders or a preset, else none), name?: (a file name pattern instead of the item's name, with for the document, for the artboard, asset, layer or object, from 1, #### for the counter zero-padded to the run of #, , , (else the row's suffix ends the name), YYYYMMDD, YYYY-MM-DD, YYYY, ,