diff --git a/ASSETS.md b/ASSETS.md index 69ffc8a0..350780d1 100644 --- a/ASSETS.md +++ b/ASSETS.md @@ -99,12 +99,16 @@ Generated-in-code art is original and has no file to list. This covers the defau | `assets/icons/dc-cap-butt.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-cap-round.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-cap-square.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | +| `assets/icons/dc-center-hide.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | +| `assets/icons/dc-center-show.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-clear.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-color-guide.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-crystallize.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-cube.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-dash-align.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-dash-exact.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | +| `assets/icons/dc-dir-off.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | +| `assets/icons/dc-dir-on.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-direct.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-dist-bottom.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-dist-hcenter.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | @@ -182,6 +186,8 @@ Generated-in-code art is original and has no file to list. This covers the defau | `assets/icons/dc-reverse.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-rotate-view.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-rounded-rect.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | +| `assets/icons/dc-rule-evenodd.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | +| `assets/icons/dc-rule-nonzero.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-scallop.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-screen-mode.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-selection.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | diff --git a/assets/icons/dc-center-hide.svg b/assets/icons/dc-center-hide.svg new file mode 100644 index 00000000..708d9dd2 --- /dev/null +++ b/assets/icons/dc-center-hide.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-center-show.svg b/assets/icons/dc-center-show.svg new file mode 100644 index 00000000..5a86f3f5 --- /dev/null +++ b/assets/icons/dc-center-show.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-dir-off.svg b/assets/icons/dc-dir-off.svg new file mode 100644 index 00000000..cb9e1ae0 --- /dev/null +++ b/assets/icons/dc-dir-off.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-dir-on.svg b/assets/icons/dc-dir-on.svg new file mode 100644 index 00000000..0767b496 --- /dev/null +++ b/assets/icons/dc-dir-on.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-rule-evenodd.svg b/assets/icons/dc-rule-evenodd.svg new file mode 100644 index 00000000..61cc703d --- /dev/null +++ b/assets/icons/dc-rule-evenodd.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/dc-rule-nonzero.svg b/assets/icons/dc-rule-nonzero.svg new file mode 100644 index 00000000..627b4ee0 --- /dev/null +++ b/assets/icons/dc-rule-nonzero.svg @@ -0,0 +1 @@ + diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 0c676e3b..cbb76c5e 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -43,6 +43,7 @@ pub use graph::{GraphKind, GraphSpec}; pub use hit::{Hit, HitKind}; pub use live::{BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, GradientMesh, MeshPoint}; pub use node::Knockout; +pub use node::{ImageMap, ObjectAttributes}; pub use node::{ImageObject, LAYER_COLORS, LayerColor, LiveShape, Node, NodeId, NodeKind, OpacityMask}; pub use pattern::{Overlap, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType}; pub use selection::{AnchorRef, Selection}; diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 38c0b05c..e536bbf3 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -282,6 +282,9 @@ pub struct Node { /// that style's look: editing its appearance or transparency breaks the link. #[serde(default, skip_serializing_if = "Option::is_none")] pub graphic_style: Option, + /// Attributes panel: centre point display, image map, URL and note (`None`: all defaults). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub attrs: Option>, } /// Opacity mask: the luminance of the mask art sets the object's opacity (white = opaque). @@ -328,6 +331,7 @@ impl Node { graph: None, kind, graphic_style: None, + attrs: None, } } pub fn path(id: NodeId, path: PathData, appearance: Appearance) -> Self { @@ -909,6 +913,77 @@ impl Node { } } +/// Window → Attributes: an object's settings that don't change how it prints. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ObjectAttributes { + /// Show Center / Don't Show Center (`None`: the object kind's default, [`Node::shows_center`]). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub show_center: Option, + /// Image Map: the clickable area the URL covers in web output. + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub image_map: ImageMap, + /// URL the object links to (SVG ``; empty: none). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub url: String, + /// The note shown in the Attributes panel. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub note: String, +} + +/// The Attributes panel's Image Map shapes. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ImageMap { + #[default] + None, + Rectangle, + Polygon, +} + +impl ImageMap { + pub const ALL: [ImageMap; 3] = [ImageMap::None, ImageMap::Rectangle, ImageMap::Polygon]; + + pub fn label(self) -> &'static str { + match self { + ImageMap::None => "None", + ImageMap::Rectangle => "Rectangle", + ImageMap::Polygon => "Polygon", + } + } + + /// Parse a label or serialized name, ignoring case. + pub fn parse(s: &str) -> Option { + Self::ALL.into_iter().find(|m| m.label().eq_ignore_ascii_case(s)) + } +} + +impl Node { + /// The URL this object links to, if any. + pub fn url(&self) -> Option<&str> { + self.attrs.as_deref().map(|a| a.url.as_str()).filter(|u| !u.is_empty()) + } + + /// Whether the canvas shows this object's centre point when it is selected: as set in the + /// Attributes panel, else [`Self::shows_center_by_default`]. + pub fn shows_center(&self) -> bool { + self.attrs.as_deref().and_then(|a| a.show_center).unwrap_or_else(|| self.shows_center_by_default()) + } + + /// Shapes drawn with the shape tools (live rectangles, ellipses and polygons) show their centre. + pub fn shows_center_by_default(&self) -> bool { + matches!(&self.kind, NodeKind::Path { live: Some(l), .. } if !matches!(l, LiveShape::Line { .. })) + } + + /// Change this object's attributes with `f`; all-default attributes are dropped. + pub fn edit_attrs(&mut self, f: impl FnOnce(&mut ObjectAttributes) -> R) -> R { + let mut a = self.attrs.take().unwrap_or_default(); + let r = f(&mut a); + self.attrs = (*a != ObjectAttributes::default()).then_some(a); + r + } +} + /// Is `a` a move plus a positive uniform scale (after which a refit gradient still matches)? fn keeps_gradient_fit(a: Affine) -> bool { let [m0, m1, m2, m3, _, _] = a.as_coeffs(); diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index 7f2508cc..d38094bd 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -147,6 +147,8 @@ fn piece(d: &mut Document, n: &Node, path: PathData, rule: FillRule, item: Appea trace: None, wrap: None, graph: None, + // The group keeps the URL (one link around the pieces). + attrs: None, kind, ..n.clone() } diff --git a/crates/engine/src/cmd/attributes.rs b/crates/engine/src/cmd/attributes.rs new file mode 100644 index 00000000..d466c305 --- /dev/null +++ b/crates/engine/src/cmd/attributes.rs @@ -0,0 +1,185 @@ +//! Window → Attributes: overprint, centre point display, image map, URL and note +//! (`attributes.set`), the fill rule (`path.setFillRule`) and, through `path.reverse`, the path +//! direction. The panel reads everything back with `attributes.info` ([`super::overprint`]). + +use std::collections::BTreeSet; + +use serde_json::{Value, json}; +use vectorcraft_doc::{Document, ImageMap, Node, NodeKind, ObjectAttributes}; +use vectorcraft_geom::FillRule; + +use super::overprint::{AttributesInfo, apply_overprint, edit_counted, overprint_jobs}; +use super::path::paths_in; +use super::*; + +/// How many URLs the Attributes panel's recent list keeps. +const RECENT_URLS: usize = 10; + +pub fn specs() -> Vec { + vec![ + cmd!( + "attributes.set", + "Attributes", + [], + None, + "{overprintFill?: bool, overprintStroke?: bool (aimed as object.setOverprint's fill and stroke, item? too), showCenter?: bool (the canvas shows the selected object's centre point), imageMap?: \"none\"|\"rectangle\"|\"polygon\", url?: string (\"\" removes it; SVG export links the object), note?: string, ids?} set the Attributes panel's values on ids or the selection, as one undo step; a URL given joins the recent URLs (attributes.info) → {changed: objects}", + has_doc, + set + ), + cmd!( + "path.setFillRule", + "Fill Rule", + [], + None, + "{rule: \"nonZero\"|\"evenOdd\", ids?} the fill rule of the paths and compound paths in ids or the selection (groups: those inside), as one undo step → {changed}", + has_doc, + set_fill_rule + ), + ] +} + +/// A fill rule as the commands name it. +pub(super) fn rule_name(r: FillRule) -> &'static str { + match r { + FillRule::NonZero => "nonZero", + FillRule::EvenOdd => "evenOdd", + } +} + +fn parse_rule(s: &str) -> Option { + match s.to_ascii_lowercase().replace(['-', ' ', '_'], "").as_str() { + "nonzero" | "nonzerowinding" => Some(FillRule::NonZero), + "evenodd" => Some(FillRule::EvenOdd), + _ => None, + } +} + +/// The one value every item of `it` shares, if any. +fn same(mut it: impl Iterator) -> Option { + let first = it.next()?; + it.all(|v| v == first).then_some(first) +} + +/// The paths and compound paths in `n`'s subtree that have a fill rule of their own with it (not a +/// compound path's members, which fill with its rule). +fn ruled<'a>(n: &'a Node, out: &mut Vec<(&'a Node, FillRule)>) { + match &n.kind { + NodeKind::Path { rule, guide: false, .. } | NodeKind::Compound { rule, .. } => out.push((n, *rule)), + _ => n.children().into_iter().flatten().for_each(|c| ruled(c, out)), + } +} + +/// The Attributes panel's values of `info.ids` beyond overprinting. +pub(super) fn fill_info(d: &Document, info: &mut AttributesInfo) { + let nodes: Vec<&Node> = info.ids.iter().filter_map(|id| d.node(*id)).collect(); + let attrs = || nodes.iter().map(|n| n.attrs.as_deref().cloned().unwrap_or_default()); + info.show_center = same(nodes.iter().map(|n| n.shows_center())); + info.image_map = same(attrs().map(|a| a.image_map)); + info.url = same(attrs().map(|a| a.url)); + info.note = same(attrs().map(|a| a.note)); + let mut all = vec![]; + nodes.iter().for_each(|n| ruled(n, &mut all)); + info.fill_rule = same(all.into_iter().map(|(_, r)| r)); + let paths = paths_in(d, &info.ids); + let subpaths = paths.iter().filter_map(|id| d.node(*id)?.path_data()).flat_map(|p| &p.subpaths); + info.reversed = same(subpaths.map(|sp| sp.signed_area() < 0.0)); +} + +/// The attribute edits `p` asks for, applied to `a`. +#[derive(Default)] +struct Edits { + show_center: Option, + image_map: Option, + url: Option, + note: Option, +} + +impl Edits { + fn parse(p: &Value) -> Result { + const C: &str = "attributes.set"; + let text = |k: &str| -> Result> { + p.get(k).map(|v| v.as_str().map(|s| s.trim().to_string()).ok_or_else(|| bad(C, format!("`{k}` must be a string")))).transpose() + }; + Ok(Self { + show_center: p.get("showCenter").map(|v| v.as_bool().ok_or_else(|| bad(C, "`showCenter` must be true or false"))).transpose()?, + image_map: text("imageMap")? + .map(|s| ImageMap::parse(&s).ok_or_else(|| bad(C, "`imageMap` must be none, rectangle or polygon"))) + .transpose()?, + url: text("url")?, + note: text("note")?, + }) + } + + fn is_empty(&self) -> bool { + self.show_center.is_none() && self.image_map.is_none() && self.url.is_none() && self.note.is_none() + } + + /// `n`'s attributes with the edits applied (Show Center kept only where it isn't the default). + fn apply(&self, n: &Node) -> ObjectAttributes { + let mut a = n.attrs.as_deref().cloned().unwrap_or_default(); + if let Some(v) = self.show_center { + a.show_center = (v != n.shows_center_by_default()).then_some(v); + } + if let Some(v) = self.image_map { + a.image_map = v; + } + if let Some(v) = &self.url { + a.url.clone_from(v); + } + if let Some(v) = &self.note { + a.note.clone_from(v); + } + a + } +} + +fn set(s: &mut Session, p: &Value) -> Result { + const C: &str = "attributes.set"; + let jobs = overprint_jobs(s, p, C, ["overprintFill", "overprintStroke"])?; + let edits = Edits::parse(p)?; + if jobs.is_empty() && edits.is_empty() { + return Err(bad(C, "give overprintFill, overprintStroke, showCenter, imageMap, url or note")); + } + let ids = targets(s, p)?; + let changed = edit_counted(s, "Attributes", |d| { + let mut changed = BTreeSet::new(); + apply_overprint(d, &jobs, C, &mut changed)?; + if !edits.is_empty() { + for id in &ids { + let Some(n) = d.node(*id) else { continue }; + let new = edits.apply(n); + if new != n.attrs.as_deref().cloned().unwrap_or_default() + && let Some(n) = d.node_mut(*id) + { + n.edit_attrs(|a| *a = new); + changed.insert(*id); + } + } + } + Ok(changed.len()) + })?; + if let Some(url) = edits.url.filter(|u| !u.is_empty() && !ids.is_empty()) { + s.recent_urls.retain(|u| *u != url); + s.recent_urls.insert(0, url); + s.recent_urls.truncate(RECENT_URLS); + } + Ok(json!({ "changed": changed })) +} + +fn set_fill_rule(s: &mut Session, p: &Value) -> Result { + const C: &str = "path.setFillRule"; + let rule = str_param(p, "rule").and_then(parse_rule).ok_or_else(|| bad(C, "`rule` must be nonZero or evenOdd"))?; + let ids = targets(s, p)?; + let changed = edit_counted(s, "Fill Rule", |d| { + let mut all = vec![]; + ids.iter().filter_map(|id| d.node(*id)).for_each(|n| ruled(n, &mut all)); + let todo: BTreeSet<_> = all.into_iter().filter(|(_, r)| *r != rule).map(|(n, _)| n.id).collect(); + for id in &todo { + if let Some(NodeKind::Path { rule: r, .. } | NodeKind::Compound { rule: r, .. }) = d.node_mut(*id).map(|n| &mut n.kind) { + *r = rule; + } + } + Ok(todo.len()) + })?; + Ok(json!({ "changed": changed })) +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 5878143e..c7ecf4c3 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -1,6 +1,7 @@ //! The command registry. Ids follow Illustrator's menu structure. pub(crate) mod appearance; +mod attributes; mod brushsym; mod buildcmds; pub mod clipboard; @@ -204,6 +205,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(flatten::specs()); v.extend(stylelib::specs()); v.extend(expand::specs()); + v.extend(attributes::specs()); v }) } diff --git a/crates/engine/src/cmd/overprint.rs b/crates/engine/src/cmd/overprint.rs index eae8acb3..8087ba28 100644 --- a/crates/engine/src/cmd/overprint.rs +++ b/crates/engine/src/cmd/overprint.rs @@ -7,7 +7,8 @@ use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_doc::overprint::OverprintBlack; -use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; +use vectorcraft_doc::{Document, ImageMap, Node, NodeId, NodeKind}; +use vectorcraft_geom::FillRule; use super::appearance::{ItemTarget, item_target}; use super::edit::selected_roots; @@ -29,7 +30,7 @@ pub fn specs() -> Vec { "Attributes", [], None, - "{ids?, item?} → {ids, overprintFill, overprintStroke} the Attributes panel's values for `ids` or the selection, aimed as object.setOverprint aims; a value is null where they differ (or nothing has a fill or stroke)", + "{ids?, item?} → {ids, overprintFill, overprintStroke, showCenter, imageMap: \"none\"|\"rectangle\"|\"polygon\", url, note, fillRule: \"nonZero\"|\"evenOdd\" (of the paths and compound paths in them), reversed (their subpaths run counter-clockwise on screen: Reverse Path Direction On), recentUrls: [newest first]} the Attributes panel's values for `ids` or the selection, overprint aimed as object.setOverprint aims; a value is null where they differ (or nothing has a fill, stroke or path)", has_doc, |s, p| Ok(attributes(s, p)?.to_json()) ), @@ -45,6 +46,17 @@ pub struct AttributesInfo { pub overprint_fill: Option, /// Overprint Stroke; `None` where the targets differ or have no stroke. pub overprint_stroke: Option, + /// The rest of the panel ([`super::attributes`]); `None` where the targets differ. + pub show_center: Option, + pub image_map: Option, + pub url: Option, + pub note: Option, + /// Fill rule of the paths and compound paths in the targets. + pub fill_rule: Option, + /// Whether their subpaths run counter-clockwise on screen (Reverse Path Direction On). + pub reversed: Option, + /// [`Session::recent_urls`]. + pub recent_urls: Vec, } impl AttributesInfo { @@ -53,6 +65,13 @@ impl AttributesInfo { "ids": self.ids.iter().map(|id| id.0).collect::>(), "overprintFill": self.overprint_fill, "overprintStroke": self.overprint_stroke, + "showCenter": self.show_center, + "imageMap": self.image_map, + "url": self.url, + "note": self.note, + "fillRule": self.fill_rule.map(super::attributes::rule_name), + "reversed": self.reversed, + "recentUrls": self.recent_urls, }) } } @@ -97,9 +116,9 @@ fn flags_mut(n: &mut Node, fill: bool, index: Option) -> Vec<&mut bool> { v } -/// The `fill` and `stroke` params given: (fill?, on). -fn kinds(p: &Value, cmd: &str) -> Result> { - [(true, "fill"), (false, "stroke")] +/// The fill and stroke overprint params given under `keys` (fill's, stroke's): (fill?, on). +fn kinds(p: &Value, cmd: &str, keys: [&str; 2]) -> Result> { + [(true, keys[0]), (false, keys[1])] .into_iter() .filter_map(|(fill, key)| { p.get(key).map(|v| v.as_bool().map(|on| (fill, on)).ok_or_else(|| bad(cmd, format!("`{key}` must be true or false")))) @@ -121,7 +140,7 @@ fn aims(s: &Session, p: &Value, cmd: &str, kinds: impl IntoIterator /// Run `f` on a copy of the document and commit it as one undo step (`label`) only when `f` /// reports changes; returns their count. -fn edit_counted(s: &mut Session, label: &str, f: impl FnOnce(&mut Document) -> Result) -> Result { +pub(super) fn edit_counted(s: &mut Session, label: &str, f: impl FnOnce(&mut Document) -> Result) -> Result { let mut d = (*s.doc()?.doc).clone(); let n = f(&mut d)?; if n > 0 { @@ -133,27 +152,42 @@ fn edit_counted(s: &mut Session, label: &str, f: impl FnOnce(&mut Document) -> R Ok(n) } -fn set_overprint(s: &mut Session, p: &Value) -> Result { - const C: &str = "object.setOverprint"; - let kinds = kinds(p, C)?; - if kinds.is_empty() { - return Err(bad(C, "give `fill` and/or `stroke`")); - } - let jobs: Vec<_> = aims(s, p, C, kinds.iter().map(|k| k.0))?.into_iter().zip(kinds.iter().map(|k| k.1)).collect(); - let changed = edit_counted(s, "Overprint", |d| { - let mut changed = BTreeSet::new(); - for ((fill, item, ids), on) in &jobs { - for id in ids { - let Some(n) = d.node_mut(*id) else { continue }; - let index = item.resolve(&n.appearance, *fill, C)?; - for f in flags_mut(n, *fill, index) { - if *f != *on { - *f = *on; - changed.insert(*id); - } +/// One overprint edit: (fill?, item, objects), on. +pub(super) type OverprintJob = ((bool, ItemTarget, Vec), bool); + +/// The overprint edits `p` asks for under `keys` (the fill's and the stroke's), aimed as +/// `object.setOverprint` aims. +pub(super) fn overprint_jobs(s: &Session, p: &Value, cmd: &str, keys: [&str; 2]) -> Result> { + let kinds = kinds(p, cmd, keys)?; + Ok(aims(s, p, cmd, kinds.iter().map(|k| k.0))?.into_iter().zip(kinds.iter().map(|k| k.1)).collect()) +} + +/// Apply `jobs` to `d`, adding the objects changed to `changed`. +pub(super) fn apply_overprint(d: &mut Document, jobs: &[OverprintJob], cmd: &str, changed: &mut BTreeSet) -> Result<()> { + for ((fill, item, ids), on) in jobs { + for id in ids { + let Some(n) = d.node_mut(*id) else { continue }; + let index = item.resolve(&n.appearance, *fill, cmd)?; + for f in flags_mut(n, *fill, index) { + if *f != *on { + *f = *on; + changed.insert(*id); } } } + } + Ok(()) +} + +fn set_overprint(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.setOverprint"; + let jobs = overprint_jobs(s, p, C, ["fill", "stroke"])?; + if jobs.is_empty() { + return Err(bad(C, "give `fill` and/or `stroke`")); + } + let changed = edit_counted(s, "Overprint", |d| { + let mut changed = BTreeSet::new(); + apply_overprint(d, &jobs, C, &mut changed)?; Ok(changed.len()) })?; Ok(json!({ "changed": changed })) @@ -162,7 +196,8 @@ fn set_overprint(s: &mut Session, p: &Value) -> Result { fn attributes(s: &Session, p: &Value) -> Result { const C: &str = "attributes.info"; let d = &s.doc()?.doc; - let mut info = AttributesInfo { ids: targets(s, p)?, ..Default::default() }; + let mut info = AttributesInfo { ids: targets(s, p)?, recent_urls: s.recent_urls.clone(), ..Default::default() }; + super::attributes::fill_info(d, &mut info); for (fill, item, ids) in aims(s, p, C, [true, false])? { let mut all = ids.iter().filter_map(|id| d.node(*id)).filter_map(|n| Some(flags(n, fill, item.resolve(&n.appearance, fill, C).ok()?))).flatten(); diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index 6bc09d35..c0b663ee 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -49,7 +49,15 @@ pub fn specs() -> Vec { has_doc, set_anchors ), - cmd!("path.reverse", "Reverse Path Direction", ["Object", "Path"], None, "{}", has_selection, reverse), + cmd!( + "path.reverse", + "Reverse Path Direction", + ["Object", "Path"], + None, + "{ids?, reversed?: bool (true: every subpath runs counter-clockwise on screen, false: clockwise, the Attributes panel's Reverse Path Direction On / Off; omitted: flip each path)} the paths in ids or the selection (groups and compound paths: the paths inside), as one undo step → {changed: paths}", + has_doc, + reverse + ), cmd!( "path.join", "Join", @@ -233,15 +241,46 @@ fn selected_paths(s: &Session) -> Result> { Ok(st.selection.objects.iter().copied().filter(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Path { .. }))).collect()) } -fn reverse(s: &mut Session, _: &Value) -> Result { - let ids = selected_paths(s)?; - s.edit("Reverse Path Direction", |d, _| { +/// The paths in the subtrees of `ids` (in groups and compound paths too), each once. +pub(super) fn paths_in(d: &vectorcraft_doc::Document, ids: &[NodeId]) -> Vec { + let mut out = BTreeSet::new(); + for n in ids.iter().filter_map(|id| d.node(*id)) { + n.walk(&mut |c| { + if matches!(c.kind, NodeKind::Path { guide: false, .. }) { + out.insert(c.id); + } + }); + } + out.into_iter().collect() +} + +fn reverse(s: &mut Session, p: &Value) -> Result { + const C: &str = "path.reverse"; + let want = p.get("reversed").map(|v| v.as_bool().ok_or_else(|| bad(C, "`reversed` must be true or false"))).transpose()?; + let ids = paths_in(&s.doc()?.doc, &targets(s, p)?); + if ids.is_empty() { + return Err(bad(C, "select paths")); + } + let changed = super::overprint::edit_counted(s, "Reverse Path Direction", |d| { + let mut changed = 0; for id in &ids { - path_mut(d, *id)?.reverse(); + // The subpaths to flip: all, or those not yet running the wanted way. + let flip: Vec = d + .node(*id) + .and_then(|n| n.path_data()) + .map_or(vec![], |pd| pd.subpaths.iter().map(|sp| want.is_none_or(|ccw| (sp.signed_area() < 0.0) != ccw)).collect()); + if flip.contains(&true) { + for (sp, f) in path_mut(d, *id)?.subpaths.iter_mut().zip(flip) { + if f { + sp.reverse(); + } + } + changed += 1; + } } - Ok(()) + Ok(changed) })?; - ok() + Ok(json!({ "changed": changed })) } fn join(s: &mut Session, _: &Value) -> Result { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index cc4c7fca..6a29b526 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -507,6 +507,8 @@ pub struct Session { pub(crate) freeform_point: Option<(usize, cmd::gradient::StopOwner)>, /// User Defined and loaded graphic style libraries (Window → Graphic Style Libraries); not saved. pub style_libraries: cmd::stylelib::Libraries, + /// URLs recently given in the Attributes panel (`attributes.set {url}`), newest first; not saved. + pub recent_urls: Vec, } impl Default for Session { @@ -541,6 +543,7 @@ impl Session { swatch_libraries: Default::default(), freeform_point: None, style_libraries: Default::default(), + recent_urls: vec![], } } @@ -787,6 +790,8 @@ mod tests; #[cfg(test)] mod tests_appearance; #[cfg(test)] +mod tests_attributes; +#[cfg(test)] mod tests_brushsym; #[cfg(test)] mod tests_build; diff --git a/crates/engine/src/tests_attributes.rs b/crates/engine/src/tests_attributes.rs new file mode 100644 index 00000000..3e7b0231 --- /dev/null +++ b/crates/engine/src/tests_attributes.rs @@ -0,0 +1,192 @@ +//! Window → Attributes (`attributes.set`, `attributes.info`, `path.setFillRule`, `path.reverse`) +//! and the links SVG export writes for objects with a URL. + +use serde_json::{Value, json}; +use vectorcraft_doc::{ImageMap, NodeKind}; +use vectorcraft_geom::{Affine, FillRule}; +use vectorcraft_render::{RenderOptions, Renderer}; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + s +} + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap() +} + +fn rect(s: &mut Session, x: f64) -> NodeId { + NodeId(run(s, "shape.rectangle", json!({"x": x, "y": 10, "width": 30, "height": 30}))["id"].as_u64().unwrap()) +} + +fn info(s: &mut Session, p: Value) -> Value { + run(s, "attributes.info", p) +} + +#[test] +fn each_attribute_sets_reads_back_and_undoes_in_one_step() { + let mut s = session(); + let a = rect(&mut s, 10.0); + let i = info(&mut s, json!({})); + assert_eq!((i["overprintFill"].clone(), i["showCenter"].clone(), i["imageMap"].clone()), (json!(false), json!(true), json!("none"))); + assert_eq!((i["url"].clone(), i["note"].clone(), i["fillRule"].clone()), (json!(""), json!(""), json!("nonZero"))); + let changed = run( + &mut s, + "attributes.set", + json!({"overprintFill": true, "overprintStroke": true, "showCenter": false, "imageMap": "Rectangle", "url": " https://example.com ", "note": "hello"}), + ); + assert_eq!(changed["changed"], 1); + let i = info(&mut s, json!({})); + for (k, v) in [ + ("overprintFill", json!(true)), + ("overprintStroke", json!(true)), + ("showCenter", json!(false)), + ("imageMap", json!("rectangle")), + ("url", json!("https://example.com")), + ("note", json!("hello")), + ("recentUrls", json!(["https://example.com"])), + ] { + assert_eq!(i[k], v, "{k}"); + } + let n = s.doc().unwrap().doc.node(a).unwrap().clone(); + let at = n.attrs.as_deref().unwrap(); + assert_eq!((at.show_center, at.image_map, n.url()), (Some(false), ImageMap::Rectangle, Some("https://example.com"))); + assert!(!n.shows_center()); + // One undo step takes everything back, and the attributes go away entirely. + run(&mut s, "edit.undo", json!({})); + let n = s.doc().unwrap().doc.node(a).unwrap().clone(); + assert!(n.attrs.is_none() && !n.has_overprint()); + // Nothing to change: no undo step; bad values are refused. + let before = s.doc().unwrap().revision; + assert_eq!(run(&mut s, "attributes.set", json!({"showCenter": true}))["changed"], 0); + assert_eq!(s.doc().unwrap().revision, before); + assert!(s.execute("attributes.set", &json!({"imageMap": "circle"})).is_err()); + assert!(s.execute("attributes.set", &json!({})).is_err()); + // Clearing the URL and note drops the attributes. + run(&mut s, "attributes.set", json!({"url": "u", "note": "n"})); + run(&mut s, "attributes.set", json!({"url": "", "note": ""})); + assert!(s.doc().unwrap().doc.node(a).unwrap().attrs.is_none()); +} + +#[test] +fn values_that_differ_read_null_and_recent_urls_keep_the_newest_first() { + let mut s = session(); + let a = rect(&mut s, 0.0); + let b = rect(&mut s, 50.0); + run(&mut s, "attributes.set", json!({"ids": [a.0], "url": "https://a.example", "note": "a"})); + run(&mut s, "attributes.set", json!({"ids": [b.0], "url": "https://b.example"})); + run(&mut s, "attributes.set", json!({"ids": [a.0], "url": "https://a.example"})); + let i = info(&mut s, json!({"ids": [a.0, b.0]})); + assert_eq!((i["url"].clone(), i["note"].clone(), i["imageMap"].clone()), (Value::Null, Value::Null, json!("none"))); + assert_eq!(i["recentUrls"], json!(["https://a.example", "https://b.example"])); + // A pen path doesn't show its centre by default; a rectangle does. + let p = NodeId(run(&mut s, "path.create", json!({"anchors": [{"x": 0, "y": 0}, {"x": 10, "y": 10}]}))["id"].as_u64().unwrap()); + assert_eq!(info(&mut s, json!({"ids": [p.0]}))["showCenter"], false); + assert_eq!(info(&mut s, json!({"ids": [p.0, a.0]}))["showCenter"], Value::Null); +} + +/// A five-pointed star drawn as one self-intersecting path (its centre is wound twice). +fn star(s: &mut Session) -> NodeId { + let (c, r) = (50.0f64, 45.0f64); + let anchors: Vec = (0..5) + .map(|i| { + let a = (-90.0 + 144.0 * i as f64).to_radians(); + json!({"x": c + r * a.cos(), "y": c + r * a.sin()}) + }) + .collect(); + let id = NodeId(run(s, "path.create", json!({"anchors": anchors, "closed": true}))["id"].as_u64().unwrap()); + run(s, "paint.setFill", json!({"ids": [id.0], "color": "#ff0000"})); + run(s, "paint.setStroke", json!({"ids": [id.0], "none": true})); + id +} + +fn red_at_centre(s: &Session) -> bool { + let doc = s.doc().unwrap().doc.clone(); + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let p = Renderer::new().render(&doc, 100, 100, Affine::IDENTITY, &opts).pixel(50, 52); + p[0] > 200 && p[1] < 80 +} + +#[test] +fn an_even_odd_star_has_a_hollow_centre() { + let mut s = session(); + let id = star(&mut s); + assert!(red_at_centre(&s), "non-zero fills the centre"); + assert_eq!(run(&mut s, "path.setFillRule", json!({"rule": "evenOdd"}))["changed"], 1); + assert!(matches!(s.doc().unwrap().doc.node(id).unwrap().kind, NodeKind::Path { rule: FillRule::EvenOdd, .. })); + assert_eq!(info(&mut s, json!({}))["fillRule"], "evenOdd"); + assert!(!red_at_centre(&s), "even-odd leaves it empty"); + assert_eq!(run(&mut s, "path.setFillRule", json!({"rule": "evenOdd"}))["changed"], 0); + run(&mut s, "edit.undo", json!({})); + assert!(red_at_centre(&s)); + assert!(s.execute("path.setFillRule", &json!({"rule": "odd"})).is_err()); +} + +#[test] +fn fill_rule_reaches_compound_paths_and_the_paths_in_groups() { + let mut s = session(); + let a = rect(&mut s, 0.0); + let b = rect(&mut s, 50.0); + run(&mut s, "select.set", json!({"ids": [a.0, b.0]})); + run(&mut s, "object.compoundPath.make", json!({})); + let compound = s.doc().unwrap().selection.objects[0]; + let d = rect(&mut s, 0.0); + run(&mut s, "select.set", json!({"ids": [compound.0, d.0]})); + run(&mut s, "object.group", json!({})); + assert_eq!(run(&mut s, "path.setFillRule", json!({"rule": "evenOdd"}))["changed"], 2, "the compound path and the rectangle"); + let doc = &s.doc().unwrap().doc; + assert!(matches!(doc.node(compound).unwrap().kind, NodeKind::Compound { rule: FillRule::EvenOdd, .. })); + assert!(matches!(doc.node(d).unwrap().kind, NodeKind::Path { rule: FillRule::EvenOdd, .. })); +} + +#[test] +fn reverse_path_direction_on_and_off_set_the_winding() { + let mut s = session(); + let a = rect(&mut s, 10.0); + let ccw = |s: &Session| s.doc().unwrap().doc.node(a).unwrap().path_data().unwrap().subpaths[0].signed_area() < 0.0; + let start = ccw(&s); + assert_eq!(info(&mut s, json!({}))["reversed"], start); + // Off / On set it; asking again changes nothing. + assert_eq!(run(&mut s, "path.reverse", json!({"reversed": !start}))["changed"], 1); + assert_eq!(ccw(&s), !start); + assert_eq!(info(&mut s, json!({}))["reversed"], !start); + assert_eq!(run(&mut s, "path.reverse", json!({"reversed": !start}))["changed"], 0); + // Without `reversed` it flips. + run(&mut s, "path.reverse", json!({})); + assert_eq!(ccw(&s), start); + run(&mut s, "select.none", json!({})); + assert!(s.execute("path.reverse", &json!({})).is_err(), "nothing to reverse"); +} + +#[test] +fn attributes_survive_a_native_round_trip() { + let mut s = session(); + let a = rect(&mut s, 10.0); + run(&mut s, "attributes.set", json!({"showCenter": false, "imageMap": "polygon", "url": "https://example.com/x", "note": "keep"})); + let doc = s.doc().unwrap().doc.clone(); + let back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap(); + assert_eq!(back.node(a).unwrap().attrs, doc.node(a).unwrap().attrs); + assert!(back.node(a).unwrap().attrs.is_some()); +} + +#[test] +fn svg_export_links_objects_and_import_reads_the_links_back() { + let mut s = session(); + let a = rect(&mut s, 10.0); + rect(&mut s, 50.0); + run(&mut s, "attributes.set", json!({"ids": [a.0], "url": "https://example.com/?a=1&b=2"})); + let svg = vectorcraft_svg::export(&s.doc().unwrap().doc, &Default::default()); + assert_eq!(svg.matches(" f64 { + use kurbo::ParamCurveArea; + let a: f64 = (0..self.segment_count()).map(|i| self.segment(i).signed_area()).sum(); + match (self.closed, self.anchors.first(), self.anchors.last()) { + (false, Some(f), Some(l)) => a + kurbo::Line::new(l.p, f.p).signed_area(), + _ => a, + } + } /// Split segment `seg` at parameter `t`, inserting a new anchor. Returns the new anchor index. pub fn insert_anchor(&mut self, seg: usize, t: f64) -> usize { let n = self.anchors.len(); diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index 38cf6320..c9ccabab 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -685,7 +685,7 @@ impl Writer<'_> { self.depth += 1; let mut bare = n.clone(); bare.mask = None; - self.node(&bare); + self.node_body(&bare); self.depth -= 1; self.line(""); } @@ -769,7 +769,21 @@ impl Writer<'_> { mid } + /// An object, inside `` when it links to a URL (Attributes panel). fn node(&mut self, n: &Node) { + match n.url().filter(|_| n.visible && !self.anonymous) { + Some(url) => { + self.line(&format!("", xml_escape(url))); + self.depth += 1; + self.node_body(n); + self.depth -= 1; + self.line(""); + } + None => self.node_body(n), + } + } + + fn node_body(&mut self, n: &Node) { if !n.visible { return; } @@ -887,7 +901,7 @@ impl Writer<'_> { // Live blends/envelopes/meshes export their evaluated (expanded) form. NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => { let g = vectorcraft_doc::live::expand_deep(n, None); - self.node(&g); + self.node_body(&g); } } } @@ -898,7 +912,7 @@ impl Writer<'_> { let mut inner = n.clone(); inner.appearance.effects.retain(|e| !vectorcraft_effects::is_raster(&e.id)); let opened = self.open_filters(n, &n.appearance.effects); - self.node(&inner); + self.node_body(&inner); self.close_filters(opened); } diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index 3b9ee12e..627701d8 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -15,6 +15,8 @@ use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Vec2}; use crate::SvgError; pub(crate) fn import(svg: &str) -> Result<(Document, Vec), SvgError> { + let (svg, links) = link_ids(svg); + let svg = svg.as_ref(); let opt = usvg::Options::default(); let tree = usvg::Tree::from_str(svg, &opt).map_err(|e| SvgError::Parse(e.to_string()))?; let size = tree.size(); @@ -22,7 +24,7 @@ pub(crate) fn import(svg: &str) -> Result<(Document, Vec), SvgError> { // absolute units is that physical size (usvg resolves it to 96 px per inch, i.e. 4/3 px per pt). let (kx, ky) = physical_scale(svg); let doc = Document::new(size.width() as f64 * kx, size.height() as f64 * ky); - let mut im = Importer { doc, warnings: Vec::new(), mask_flags: mask_flags(svg) }; + let mut im = Importer { doc, warnings: Vec::new(), mask_flags: mask_flags(svg), links }; // usvg wraps everything in an id-less group carrying the viewBox transform when needed. let mut top = tree.root(); @@ -37,8 +39,8 @@ pub(crate) fn import(svg: &str) -> Result<(Document, Vec), SvgError> { // Top-level `` elements become layers (Illustrator does the same); otherwise all art goes // into "Layer 1". - let layer_mode = - !top.children().is_empty() && top.children().iter().all(|c| matches!(c, usvg::Node::Group(g) if !g.id().is_empty() && is_plain(g))); + let layer_mode = !top.children().is_empty() + && top.children().iter().all(|c| matches!(c, usvg::Node::Group(g) if !g.id().is_empty() && !im.links.contains_key(g.id()) && is_plain(g))); if layer_mode { im.doc.layers.clear(); for (i, c) in top.children().iter().enumerate() { @@ -87,6 +89,8 @@ struct Importer { warnings: Vec, /// Opacity-mask options our export wrote, by mask id (see [`mask_flags`]). mask_flags: HashMap, + /// The URL of each ``'s group, by its id ([`link_ids`]). + links: HashMap, } /// The options [`crate::export::MASK_FLAGS`] records on exported `` elements, by mask id: @@ -106,6 +110,61 @@ fn mask_flags(svg: &str) -> HashMap { xml.descendants().filter(|n| n.tag_name().name() == "mask" && n.has_attribute(attr)).filter_map(flags).collect() } +/// Prefix of the ids [`link_ids`] gives the `` elements that have none. +const LINK_ID: &str = "vectorcraft-link-"; + +/// The URL an `` element links to. +fn href<'a>(a: XNode<'a, '_>) -> Option<&'a str> { + a.attribute("href").or_else(|| a.attribute(("http://www.w3.org/1999/xlink", "href"))).filter(|h| !h.is_empty()) +} + +/// usvg reads `` as a plain group and drops the URL. So that the group can be found, every +/// link without an id gets one ([`LINK_ID`]…); returns the SVG with those ids and the URLs by id. +fn link_ids(svg: &str) -> (std::borrow::Cow<'_, str>, HashMap) { + let mut links = HashMap::new(); + if !svg.contains(" id.to_string(), + None => { + let start = a.range().start; + let end = svg[start + 1..].find(|c: char| c.is_whitespace() || c == '/' || c == '>').map_or(start + 2, |i| start + 1 + i); + let id = format!("{LINK_ID}{}", inserts.len()); + inserts.push((end, id.clone())); + id + } + }; + links.insert(id, url.to_string()); + } + if inserts.is_empty() { + return (svg.into(), links); + } + let mut out = String::with_capacity(svg.len() + inserts.len() * 32); + let mut last = 0; + for (at, id) in inserts { + out.push_str(&svg[last..at]); + out.push_str(&format!(" id=\"{id}\"")); + last = at; + } + out.push_str(&svg[last..]); + (out.into(), links) +} + +/// Make `n` link to `url`, unless it links somewhere already (an inner link wins). +fn link(n: &mut Node, url: &str) { + if n.url().is_none() { + n.edit_attrs(|a| a.url = url.to_string()); + } +} + fn aff(t: usvg::Transform) -> Affine { Affine::new([t.sx as f64, t.ky as f64, t.kx as f64, t.sy as f64, t.tx as f64, t.ty as f64]) } @@ -199,8 +258,19 @@ impl Importer { } fn group(&mut self, g: &usvg::Group, acc: Affine) -> Option { + let url = self.links.get(g.id()).cloned(); + let mut n = self.group_node(g, acc)?; + if let Some(url) = url { + link(&mut n, &url); + } + Some(n) + } + + fn group_node(&mut self, g: &usvg::Group, acc: Affine) -> Option { let ts = acc * aff(g.transform()); - let label = if g.id().is_empty() { "a group".to_string() } else { format!("'{}'", g.id()) }; + // The made-up ids of links aren't names. + let id = if g.id().starts_with(LINK_ID) { "" } else { g.id() }; + let label = if id.is_empty() { "a group".to_string() } else { format!("'{id}'") }; let mask = g.mask().and_then(|m| self.opacity_mask(m, ts, &label)); if !g.filters().is_empty() { self.warn(format!("filter on {label} ignored")); @@ -213,14 +283,14 @@ impl Importer { let clip = self.clip_node(cp, ts)?; let mut ch = vec![Arc::new(clip)]; ch.extend(children); - self.named(g.id(), NodeKind::Group { children: ch, clip: true }) + self.named(id, NodeKind::Group { children: ch, clip: true }) } else { if children.is_empty() { return None; } // An id-less wrapper around a single object (usvg adds these for opacity/transform on // shapes): fold its opacity and blend into the object. - if g.id().is_empty() + if id.is_empty() && mask.is_none() && children.len() == 1 && (g.blend_mode() == usvg::BlendMode::Normal || children[0].blend == BlendMode::Normal) @@ -233,7 +303,7 @@ impl Importer { c.isolate |= g.isolate(); return Some(c); } - self.named(g.id(), NodeKind::Group { children, clip: false }) + self.named(id, NodeKind::Group { children, clip: false }) }; n.opacity = g.opacity().get(); n.blend = blend(g.blend_mode()); @@ -799,6 +869,9 @@ fn text_fallback(im: &mut Importer, svg: &str, w: f64, h: f64, (kx, ky): (f64, f }; obj.para.justify = justify; let mut node = im.named(t.attribute("id").unwrap_or(""), NodeKind::Text(Box::new(obj))); + if let Some(url) = chain.iter().filter(|a| a.tag_name().name() == "a").find_map(|a| href(*a)) { + link(&mut node, url); + } if let Some(o) = ctx.own(t, "opacity").and_then(|o| o.trim().parse::().ok()) { node.opacity = o.clamp(0.0, 1.0); } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 4c257f80..e36b1bd4 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -886,6 +886,12 @@ fn selection_overlay(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) { ); } }); + // Centre point (Attributes panel → Show Center). + if n.shows_center() + && let Some(b) = n.geometric_bounds() + { + anchor_square(p, xf.to_screen(b.center()), color, true, 4.0); + } // Text: baseline marker. if let NodeKind::Text(tx) = &n.kind { let o = xf.to_screen(tx.xf * Point::ZERO); diff --git a/crates/ui-egui/src/icon_data.rs b/crates/ui-egui/src/icon_data.rs index c3f309dc..f78ae698 100644 --- a/crates/ui-egui/src/icon_data.rs +++ b/crates/ui-egui/src/icon_data.rs @@ -54,12 +54,16 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("dc-cap-butt", include_bytes!("../../../assets/icons/dc-cap-butt.svg")), ("dc-cap-round", include_bytes!("../../../assets/icons/dc-cap-round.svg")), ("dc-cap-square", include_bytes!("../../../assets/icons/dc-cap-square.svg")), + ("dc-center-hide", include_bytes!("../../../assets/icons/dc-center-hide.svg")), + ("dc-center-show", include_bytes!("../../../assets/icons/dc-center-show.svg")), ("dc-clear", include_bytes!("../../../assets/icons/dc-clear.svg")), ("dc-color-guide", include_bytes!("../../../assets/icons/dc-color-guide.svg")), ("dc-crystallize", include_bytes!("../../../assets/icons/dc-crystallize.svg")), ("dc-cube", include_bytes!("../../../assets/icons/dc-cube.svg")), ("dc-dash-align", include_bytes!("../../../assets/icons/dc-dash-align.svg")), ("dc-dash-exact", include_bytes!("../../../assets/icons/dc-dash-exact.svg")), + ("dc-dir-off", include_bytes!("../../../assets/icons/dc-dir-off.svg")), + ("dc-dir-on", include_bytes!("../../../assets/icons/dc-dir-on.svg")), ("dc-direct", include_bytes!("../../../assets/icons/dc-direct.svg")), ("dc-dist-bottom", include_bytes!("../../../assets/icons/dc-dist-bottom.svg")), ("dc-dist-hcenter", include_bytes!("../../../assets/icons/dc-dist-hcenter.svg")), @@ -137,6 +141,8 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("dc-reverse", include_bytes!("../../../assets/icons/dc-reverse.svg")), ("dc-rotate-view", include_bytes!("../../../assets/icons/dc-rotate-view.svg")), ("dc-rounded-rect", include_bytes!("../../../assets/icons/dc-rounded-rect.svg")), + ("dc-rule-evenodd", include_bytes!("../../../assets/icons/dc-rule-evenodd.svg")), + ("dc-rule-nonzero", include_bytes!("../../../assets/icons/dc-rule-nonzero.svg")), ("dc-scallop", include_bytes!("../../../assets/icons/dc-scallop.svg")), ("dc-screen-mode", include_bytes!("../../../assets/icons/dc-screen-mode.svg")), ("dc-selection", include_bytes!("../../../assets/icons/dc-selection.svg")), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index c01f2ab0..0be9c05f 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -315,6 +315,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{} open Expand for the selection (dialog `expand`, fields object, fill, stroke (all on; one the selection has nothing for is disabled, see object.expand.info), gradient: objects|mesh, steps 1..1000 (255)): OK runs object.expand with them as one undo step", ), + ( + "attributes.openUrl", + "Browser", + "", + "{url?} open url, else the selection's URL (attributes.info), in the web browser (the Attributes panel's Browser button) → {url}", + ), ]; /// Handle a UI command. `None` = not a UI command (the engine handles it). @@ -682,6 +688,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option Err("no such user library".into()), }, "ui.expandDialog" => crate::dialogs::expand::open(app), + "attributes.openUrl" => crate::panels::attributes::open_url(app, p), _ => return None, }; Some(r) @@ -1417,7 +1424,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { panel("Appearance", "appearance"), panel("Artboards", "artboards"), todo("Asset Export"), - todo("Attributes"), + panel("Attributes", "attributes"), panel("Brushes", "brushes"), panel("Color", "color"), panel("Color Guide", "colorGuide"), diff --git a/crates/ui-egui/src/panels/attributes.rs b/crates/ui-egui/src/panels/attributes.rs new file mode 100644 index 00000000..2d93378b --- /dev/null +++ b/crates/ui-egui/src/panels/attributes.rs @@ -0,0 +1,226 @@ +//! Attributes panel: Overprint Fill / Overprint Stroke, Show / Don't Show Center, Reverse Path +//! Direction Off / On, the fill rule (non-zero winding or even-odd), Image Map, URL (with the +//! recent URLs and a Browser button) and the note. The menu hides the options and shows the note. +//! Everything reads `attributes.info` ([`vectorcraft_engine::Session::attributes_info`]) and acts +//! through `attributes.set`, `path.reverse` and `path.setFillRule`. + +use egui::Ui; +use serde_json::{Value, json}; +use vectorcraft_doc::{ImageMap, NodeId, NodeKind}; +use vectorcraft_engine::cmd::AttributesInfo; +use vectorcraft_geom::FillRule; + +use super::{pstate, selection_len, set_pstate}; +use crate::VectorcraftApp; +use crate::widgets::{self, menu_item}; + +/// Panel state: the options (centre, direction, fill rule, image map, URL) are hidden. +const HIDE_OPTIONS: &str = "attr-hide-options"; +/// Panel state: the note field is hidden. +const HIDE_NOTE: &str = "attr-hide-note"; +const LABEL_W: f32 = 70.0; + +/// The panel's values and whether the targets hold paths (direction and fill rule apply), +/// recomputed only when the document or the selection changes. +fn state(app: &VectorcraftApp, ctx: &egui::Context) -> (AttributesInfo, bool) { + type Key = (u64, u64, usize, Option, Option); + let Some(st) = app.session.active() else { return Default::default() }; + let sel = &st.selection.objects; + let key: Key = (st.uid, st.revision, sel.len(), sel.first().copied(), sel.last().copied()); + let id = egui::Id::new("attr-state"); + if let Some((k, v)) = ctx.data(|d| d.get_temp::<(Key, (AttributesInfo, bool))>(id)) + && k == key + { + return v; + } + let info = app.session.attributes_info(); + let mut paths = false; + for n in info.ids.iter().filter_map(|id| st.doc.node(*id)) { + n.walk(&mut |c| paths |= matches!(c.kind, NodeKind::Path { guide: false, .. })); + } + let v = (info, paths); + ctx.data_mut(|d| d.insert_temp(id, (key, v.clone()))); + v +} + +/// Run `attributes.set` with `p` on the selection (errors show in the status bar). +fn set(app: &mut VectorcraftApp, p: Value) { + app.run("attributes.set", p).ok(); +} + +/// A pair of icon buttons choosing one of two values (`current`: the targets' value, `None` where +/// they differ); returns the value clicked. +fn pair(ui: &mut Ui, enabled: bool, current: Option, items: [(&str, &str, T); 2]) -> Option { + let mut clicked = None; + for (icon, tip, v) in items { + if widgets::icon_button_enabled(ui, icon, tip, enabled && current == Some(v), enabled, 24.0).clicked() { + clicked = Some(v); + } + } + clicked +} + +pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { + let (info, paths) = state(app, ui.ctx()); + let has = !info.ids.is_empty(); + ui.horizontal(|ui| { + for (label, key, value) in + [("Overprint Fill", "overprintFill", info.overprint_fill), ("Overprint Stroke", "overprintStroke", info.overprint_stroke)] + { + if widgets::check3(ui, label, value, has) { + set(app, json!({ key: value != Some(true) })); + } + ui.add_space(8.0); + } + }); + if !pstate::(ui.ctx(), HIDE_OPTIONS) { + widgets::divider(ui); + ui.horizontal(|ui| { + let center = [("dc-center-hide", "Don't Show Center", false), ("dc-center-show", "Show Center", true)]; + if let Some(v) = pair(ui, has, info.show_center, center) { + set(app, json!({ "showCenter": v })); + } + ui.add_space(10.0); + let dir = [("dc-dir-off", "Reverse Path Direction Off", false), ("dc-dir-on", "Reverse Path Direction On", true)]; + if let Some(v) = pair(ui, paths, info.reversed, dir) { + app.run("path.reverse", json!({ "reversed": v })).ok(); + } + ui.add_space(10.0); + let rules = [ + ("dc-rule-nonzero", "Use Non-Zero Winding Fill Rule", FillRule::NonZero), + ("dc-rule-evenodd", "Use Even-Odd Fill Rule", FillRule::EvenOdd), + ]; + if let Some(r) = pair(ui, paths, info.fill_rule, rules) { + app.run("path.setFillRule", json!({ "rule": if r == FillRule::EvenOdd { "evenOdd" } else { "nonZero" } })).ok(); + } + }); + ui.add_space(4.0); + let url = info.url.clone().filter(|u| !u.is_empty()); + widgets::label_row(ui, "Image Map:", LABEL_W, |ui| { + ui.add_enabled_ui(has, |ui| { + let labels = ImageMap::ALL.map(ImageMap::label); + if let Some(i) = widgets::dropdown(ui, "attr-map", info.image_map.map_or("", ImageMap::label), &labels, 100.0) { + set(app, json!({ "imageMap": ImageMap::ALL[i] })); + } + }); + ui.add_space(6.0); + let r = ui.add_enabled_ui(url.is_some(), |ui| widgets::flat_button(ui, "Browser", 64.0)).inner; + if r.on_hover_text("Open the URL in the web browser").clicked() { + app.run("attributes.openUrl", json!({})).ok(); + } + }); + widgets::label_row(ui, "URL:", LABEL_W, |ui| { + ui.add_enabled_ui(has, |ui| { + let width = (ui.available_width() - 26.0).max(80.0); + if let Some(u) = widgets::text_field(ui, "attr-url", info.url.as_deref(), width, 1) { + set(app, json!({ "url": u })); + } + recent_urls(app, ui); + }); + }); + } + if !pstate::(ui.ctx(), HIDE_NOTE) { + widgets::divider(ui); + widgets::subheader(ui, "Note:"); + ui.add_enabled_ui(has, |ui| { + if let Some(n) = widgets::text_field(ui, "attr-note", info.note.as_deref(), ui.available_width(), 3) { + set(app, json!({ "note": n })); + } + }); + } + if !has { + widgets::dim_label(ui, if selection_len(app) == 0 { "No Selection" } else { "" }); + } +} + +/// The recent URLs button: a menu of the URLs given lately; picking one sets it. +fn recent_urls(app: &mut VectorcraftApp, ui: &mut Ui) { + let resp = widgets::icon_button_enabled(ui, "chevron-down", "Recent URLs", false, !app.session.recent_urls.is_empty(), 20.0); + let mut chosen = None; + egui::Popup::menu(&resp).show(|ui| { + ui.set_min_width(220.0); + for u in &app.session.recent_urls { + if menu_item(ui, u, true, false) { + chosen = Some(u.clone()); + } + } + }); + if let Some(u) = chosen { + set(app, json!({ "url": u })); + } +} + +pub fn menu(_app: &mut VectorcraftApp, ui: &mut Ui) { + for (key, hide, show) in [(HIDE_OPTIONS, "Hide Options", "Show Options"), (HIDE_NOTE, "Hide Note", "Show Note")] { + let hidden: bool = pstate(ui.ctx(), key); + if menu_item(ui, if hidden { show } else { hide }, true, false) { + set_pstate(ui.ctx(), key, !hidden); + } + } +} + +/// `attributes.openUrl {url?}`: open `url`, else the selection's URL, in the web browser. +pub(crate) fn open_url(app: &mut VectorcraftApp, p: &Value) -> Result { + let url = match p.get("url").and_then(Value::as_str) { + Some(u) => u.to_string(), + None => app.session.attributes_info().url.filter(|u| !u.is_empty()).ok_or("the selection has no URL (or they differ)")?, + }; + app.open_url(&url); + Ok(json!({ "url": url })) +} + +#[cfg(test)] +mod tests { + use super::*; + use vectorcraft_engine::Session; + + /// Run the panel and its menu for one headless frame: the texts drawn. + fn frame(app: &mut VectorcraftApp) -> Vec { + fn texts(s: &egui::Shape, out: &mut Vec) { + match s { + egui::Shape::Text(t) => out.push(t.galley.text().to_string()), + egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)), + _ => {} + } + } + let ctx = egui::Context::default(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + show(app, ui); + menu(app, ui); + }); + out.textures_delta.clear(); + let mut v = vec![]; + out.shapes.iter().for_each(|c| texts(&c.shape, &mut v)); + v + } + + fn run(app: &mut VectorcraftApp, id: &str, p: Value) -> Value { + app.session.execute(id, &p).unwrap() + } + + #[test] + fn panel_shows_the_selections_attributes() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + run(&mut app, "file.new", json!({"width": 100, "height": 100})); + let texts = frame(&mut app); + assert!(texts.iter().any(|t| t == "No Selection") && texts.iter().any(|t| t == "Overprint Fill"), "{texts:?}"); + run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})); + run(&mut app, "attributes.set", json!({"url": "https://example.com/a", "note": "first note", "imageMap": "polygon"})); + let texts = frame(&mut app); + for want in ["https://example.com/a", "first note", "Polygon", "Image Map:", "URL:", "Browser", " Hide Options", " Hide Note"] { + assert!(texts.iter().any(|t| t == want), "{want} in {texts:?}"); + } + assert!(!texts.iter().any(|t| t == "No Selection")); + // The URL button opens the selection's URL. + let (info, paths) = state(&app, &egui::Context::default()); + assert!(paths && info.url.as_deref() == Some("https://example.com/a")); + assert_eq!(open_url(&mut app, &json!({})).unwrap()["url"], "https://example.com/a"); + // Two objects whose values differ show blanks. + let b = run(&mut app, "shape.ellipse", json!({"x": 60, "y": 0, "width": 30, "height": 30}))["id"].clone(); + let a = app.session.active().unwrap().doc.layers[0].children().unwrap()[0].id.0; + run(&mut app, "select.set", json!({"ids": [a, b]})); + let texts = frame(&mut app); + assert!(!texts.iter().any(|t| t == "https://example.com/a" || t == "first note"), "{texts:?}"); + assert!(open_url(&mut app, &json!({})).is_err()); + } +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index cab19a31..acdea134 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -7,6 +7,7 @@ pub mod actions; pub mod align; pub mod appearance; pub mod artboards; +pub mod attributes; pub mod brushes; pub mod character; pub mod color; @@ -90,6 +91,7 @@ pub fn show_icon_panel(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) { "magicWand" => magic_wand::show(app, ui), "tabs" => tabs::show(app, ui), flattener_preview::ID => flattener_preview::show(app, ui), + "attributes" => attributes::show(app, ui), _ => { dim_label(ui, "This panel is on the roadmap (see the parity plan)."); } @@ -129,6 +131,7 @@ pub fn panel_menu_items(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) { "magicWand" => magic_wand::menu(app, ui), "tabs" => tabs::menu(app, ui), flattener_preview::ID => flattener_preview::menu(app, ui), + "attributes" => attributes::menu(app, ui), _ => { ui.add_enabled(false, egui::Button::new("No options").frame(false)); } diff --git a/crates/ui-egui/src/panels/pattern_options.rs b/crates/ui-egui/src/panels/pattern_options.rs index 5df83a37..63199485 100644 --- a/crates/ui-egui/src/panels/pattern_options.rs +++ b/crates/ui-egui/src/panels/pattern_options.rs @@ -23,12 +23,7 @@ fn editing(app: &VectorcraftApp) -> Option<(PatternDef, Unit)> { } fn row(ui: &mut Ui, label: &str, add: impl FnOnce(&mut Ui)) { - ui.horizontal(|ui| { - let (r, _) = ui.allocate_exact_size(egui::vec2(96.0, 24.0), egui::Sense::hover()); - let t = crate::theme::Tokens::get(ui.ctx()); - ui.painter().text(r.left_center(), egui::Align2::LEFT_CENTER, label, egui::FontId::proportional(12.5), t.text); - add(ui); - }); + widgets::label_row(ui, label, 96.0, add); } fn set(app: &mut VectorcraftApp, name: &str, mut p: Value) { diff --git a/crates/ui-egui/src/shortcut_editor.rs b/crates/ui-egui/src/shortcut_editor.rs index 919bef73..d885ff75 100644 --- a/crates/ui-egui/src/shortcut_editor.rs +++ b/crates/ui-egui/src/shortcut_editor.rs @@ -89,6 +89,7 @@ pub const PANEL_SHORTCUTS: &[(&str, &str)] = &[ ("stroke", "Cmd+F10"), ("gradient", "Cmd+F9"), ("transparency", "Cmd+Shift+F10"), + ("attributes", "Cmd+F11"), ]; /// Default shortcut of an entry key (`tool:`, `panel:` or a command id). diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index b76b9d52..3cd49d0e 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -89,6 +89,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[ ("imageTrace", "Image Trace", "image"), ("magicWand", "Magic Wand", "wand-sparkles"), (crate::panels::flattener_preview::ID, "Flattener Preview", "eye"), + ("attributes", "Attributes", "settings"), ]; /// Groups of icon panels separated by dividers in the collapsed column. diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index c50f25c7..21b367f5 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -130,6 +130,59 @@ pub fn num_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: if commit { unit.parse(&buf) } else { None } } +/// The recessed text box of the panel fields showing `shown`, `rows` lines tall (1: single line), +/// its text kept under `id` while it has focus. Returns the text (as edited) and the response. +fn recessed_text(ui: &mut Ui, id: egui::Id, shown: &str, width: f32, rows: usize) -> (String, Response) { + let t = Tokens::get(ui.ctx()); + let editing = ui.memory(|m| m.has_focus(id)); + let mut buf: String = if editing { ui.data_mut(|d| d.get_temp::(id)).unwrap_or_else(|| shown.to_string()) } else { shown.to_string() }; + let height = 26.0 + 16.0 * (rows.max(1) - 1) as f32; + let resp = ui + .allocate_ui_with_layout(vec2(width, height), egui::Layout::left_to_right(egui::Align::Center), |ui| { + ui.set_min_width(width); + egui::Frame::NONE + .fill(t.input) + .stroke(Stroke::new(1.0, if editing { t.accent } else { t.input_border })) + .corner_radius(CornerRadius::same(2)) + .inner_margin(egui::Margin::symmetric(6, 4)) + .show(ui, |ui| { + let edit = if rows > 1 { egui::TextEdit::multiline(&mut buf).desired_rows(rows) } else { egui::TextEdit::singleline(&mut buf) }; + ui.add( + edit.id(id) + .frame(egui::Frame::NONE) + .desired_width(width - 14.0) + .min_size(vec2(width - 14.0, 0.0)) + .font(egui::FontId::proportional(12.5)) + .text_color(t.text_strong), + ) + }) + .inner + }) + .inner; + ui.data_mut(|d| d.insert_temp(id, buf.clone())); + (buf, resp) +} + +/// A recessed text field showing `value` (blank when `None`: the selection's values differ), +/// `rows` lines tall (1: single line, which Enter commits). Returns the new text, trimmed, when a +/// change is committed (Enter or focus loss). +pub fn text_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: Option<&str>, width: f32, rows: usize) -> Option { + let id = ui.id().with(id); + let shown = value.unwrap_or_default(); + let (buf, resp) = recessed_text(ui, id, shown, width, rows); + (resp.lost_focus() && buf.trim() != shown).then(|| buf.trim().to_string()) +} + +/// A form row: `label` in a column `label_width` wide, then what `add` draws. +pub fn label_row(ui: &mut Ui, label: &str, label_width: f32, add: impl FnOnce(&mut Ui)) { + ui.horizontal(|ui| { + let (r, _) = ui.allocate_exact_size(vec2(label_width, 24.0), Sense::hover()); + let t = Tokens::get(ui.ctx()); + ui.painter().text(r.left_center(), egui::Align2::LEFT_CENTER, label, egui::FontId::proportional(12.5), t.text); + add(ui); + }); +} + /// A plain number field (percent, degrees, counts) with optional suffix. pub fn plain_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: f64, suffix: &str, decimals: usize, width: f32) -> Option { mixed_field(ui, id, Some(value), suffix, decimals, width) @@ -144,7 +197,6 @@ pub fn mixed_field( decimals: usize, width: f32, ) -> Option { - let t = Tokens::get(ui.ctx()); let id = ui.id().with(id); let shown = value .map(|value| { @@ -153,31 +205,7 @@ pub fn mixed_field( format!("{s}{suffix}") }) .unwrap_or_default(); - let editing = ui.memory(|m| m.has_focus(id)); - let mut buf: String = if editing { ui.data_mut(|d| d.get_temp::(id)).unwrap_or_else(|| shown.clone()) } else { shown.clone() }; - let resp = ui - .allocate_ui_with_layout(vec2(width, 26.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { - ui.set_min_width(width); - egui::Frame::NONE - .fill(t.input) - .stroke(Stroke::new(1.0, if editing { t.accent } else { t.input_border })) - .corner_radius(CornerRadius::same(2)) - .inner_margin(egui::Margin::symmetric(6, 4)) - .show(ui, |ui| { - ui.add( - egui::TextEdit::singleline(&mut buf) - .id(id) - .frame(egui::Frame::NONE) - .desired_width(width - 14.0) - .min_size(vec2(width - 14.0, 0.0)) - .font(egui::FontId::proportional(12.5)) - .text_color(t.text_strong), - ) - }) - .inner - }) - .inner; - ui.data_mut(|d| d.insert_temp(id, buf.clone())); + let (buf, resp) = recessed_text(ui, id, &shown, width, 1); if resp.lost_focus() && buf != shown { buf.trim().trim_end_matches(suffix.trim()).trim().trim_end_matches(['%', '°']).parse::().ok() } else { diff --git a/docs/control-protocol.md b/docs/control-protocol.md index c44806c4..12ce2d88 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -144,3 +144,7 @@ opens the `expand` dialog for the selection. Fields: `object`, `fill`, `stroke` `__stroke` say which the selection has something for, as `object.expand.info` does, and the others are disabled), `gradient` (`objects` or `mesh`) and `steps` (1–1000, default 255). `ui.dialog.confirm` runs `object.expand` with them as one undo step. + +Attributes panel: `window.panel {panel: "attributes"}` (Cmd+F11) shows it; its controls run `attributes.set`, +`path.reverse {reversed}` and `path.setFillRule {rule}`, and its Browser button runs the UI command +`attributes.openUrl {url?}`, which opens the URL (default: the selection's) in the web browser and answers `{url}`. diff --git a/docs/mcp.md b/docs/mcp.md index cf797a29..0619ceb0 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -567,3 +567,17 @@ stroke}`), and `object.mesh.create` on a gradient-filled object colours the mesh {"name":"run_command","arguments":{"command":"object.expand","params":{"stroke":false,"gradient":"mesh"}}} {"name":"run_command","arguments":{"command":"object.expand","params":{"steps":16}}} ``` + +## Attributes, URLs and image maps + +The Attributes panel (`window.panel {panel: "attributes"}`, Cmd+F11) reads `attributes.info {ids?}`: +`overprintFill`, `overprintStroke`, `showCenter`, `imageMap`, `url`, `note`, `fillRule` and `reversed` (null where the +objects differ) and `recentUrls`. `attributes.set {overprintFill?, overprintStroke?, showCenter?, imageMap?, url?, +note?, ids?}` sets them in one undo step; `path.setFillRule {rule: "nonZero"|"evenOdd"}` sets the fill rule of the +paths and compound paths, and `path.reverse {reversed?}` makes subpaths run counter-clockwise (true) or clockwise +(false). SVG export wraps an object with a URL in ``, and SVG import reads `` links back. + +```json +{"name":"run_command","arguments":{"command":"attributes.set","params":{"ids":[12],"url":"https://example.com","imageMap":"rectangle"}}} +{"name":"run_command","arguments":{"command":"path.setFillRule","params":{"rule":"evenOdd"}}} +```