From 4591c85845cc2169cefa64440dd4b953652bd9b7 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 02:45:32 +0700 Subject: [PATCH 1/2] M3.76: Attributes panel with overprint, centre point, path direction, fill rule, image map, URL and note Window > Attributes (Cmd+F11) shows Overprint Fill/Stroke, Show/Don't Show Center, Reverse Path Direction Off/On, the non-zero / even-odd fill rule, Image Map, a URL field with recent URLs and a Browser button, and a note. New commands attributes.set and path.setFillRule; path.reverse takes ids and reversed; attributes.info reports every value. Objects keep the attributes in Node.attrs. SVG export wraps linked objects in , and import reads links back. The canvas draws the centre point of selected objects that show it. Co-Authored-By: Claude Opus 5.5 --- ASSETS.md | 6 + assets/icons/dc-center-hide.svg | 1 + assets/icons/dc-center-show.svg | 1 + assets/icons/dc-dir-off.svg | 1 + assets/icons/dc-dir-on.svg | 1 + assets/icons/dc-rule-evenodd.svg | 1 + assets/icons/dc-rule-nonzero.svg | 1 + crates/doc/src/lib.rs | 1 + crates/doc/src/node.rs | 75 ++++++ crates/effects/src/bake.rs | 2 + crates/engine/src/cmd/attributes.rs | 185 +++++++++++++++ crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/overprint.rs | 85 +++++-- crates/engine/src/cmd/path.rs | 53 ++++- crates/engine/src/lib.rs | 5 + crates/engine/src/tests_attributes.rs | 192 ++++++++++++++++ crates/geom/src/path.rs | 10 + crates/svg/src/export.rs | 20 +- crates/svg/src/import.rs | 87 ++++++- crates/ui-egui/src/canvas.rs | 6 + crates/ui-egui/src/icon_data.rs | 6 + crates/ui-egui/src/menus.rs | 9 +- crates/ui-egui/src/panels/attributes.rs | 226 +++++++++++++++++++ crates/ui-egui/src/panels/mod.rs | 3 + crates/ui-egui/src/panels/pattern_options.rs | 7 +- crates/ui-egui/src/shortcut_editor.rs | 1 + crates/ui-egui/src/state.rs | 1 + crates/ui-egui/src/widgets.rs | 80 ++++--- docs/control-protocol.md | 4 + docs/mcp.md | 14 ++ 30 files changed, 1011 insertions(+), 75 deletions(-) create mode 100644 assets/icons/dc-center-hide.svg create mode 100644 assets/icons/dc-center-show.svg create mode 100644 assets/icons/dc-dir-off.svg create mode 100644 assets/icons/dc-dir-on.svg create mode 100644 assets/icons/dc-rule-evenodd.svg create mode 100644 assets/icons/dc-rule-nonzero.svg create mode 100644 crates/engine/src/cmd/attributes.rs create mode 100644 crates/engine/src/tests_attributes.rs create mode 100644 crates/ui-egui/src/panels/attributes.rs 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"}}} +``` From c1df6f06219623fe2480cbb52179bf2b1d1064ba Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 03:19:06 +0700 Subject: [PATCH 2/2] M3.77: [Registration] swatch on every plate, trim marks on every artboard, Japanese marks and live Crop Marks [Registration] is a built-in swatch that prints 100% on every plate: Separations Preview shows it on each process and spot plate and PDF export writes it as /Separation /All. It can't be edited, moved, duplicated, merged or deleted; swatch.list shows it after None and the Swatches panel applies it as a swatch. Object > Create Trim Marks strokes the marks in Registration and marks every artboard when nothing is selected ({style?, allArtboards?}). The preference japaneseCropMarks (General) switches to Japanese marks: double lines at the trim and bleed edges plus centre marks. Effect > Crop Marks (cropMarks) is a live effect whose marks follow the object; rendering, SVG/PDF export and Expand Appearance draw them. The mark geometry lives in doc::marks. Co-Authored-By: Claude Opus 5.5 --- crates/color/src/lib.rs | 8 ++ crates/color/src/swatch.rs | 14 ++ crates/doc/src/lib.rs | 1 + crates/doc/src/marks.rs | 150 +++++++++++++++++++++ crates/doc/src/swatches.rs | 5 +- crates/effects/src/bake.rs | 48 ++++--- crates/effects/src/lib.rs | 13 +- crates/effects/src/marks.rs | 70 ++++++++++ crates/engine/src/cmd/effectcmd.rs | 24 +++- crates/engine/src/cmd/menucmds.rs | 80 +++++------ crates/engine/src/cmd/paint.rs | 2 +- crates/engine/src/cmd/prefscmds.rs | 2 + crates/engine/src/cmd/swatch.rs | 23 +++- crates/engine/src/lib.rs | 5 + crates/engine/src/tests_registration.rs | 170 ++++++++++++++++++++++++ crates/engine/src/tests_swatches.rs | 2 +- crates/pdf/src/export.rs | 7 +- crates/render/src/fx.rs | 13 +- crates/render/src/proof.rs | 44 ++++-- crates/ui-egui/src/menus.rs | 12 +- crates/ui-egui/src/panels/swatches.rs | 15 ++- docs/mcp.md | 15 +++ 22 files changed, 627 insertions(+), 96 deletions(-) create mode 100644 crates/doc/src/marks.rs create mode 100644 crates/effects/src/marks.rs create mode 100644 crates/engine/src/tests_registration.rs diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index f4b5090f..b118521e 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -328,6 +328,14 @@ impl Paint { _ => None, } } + /// The Registration colour: 100% of every ink, linked to [`swatch::REGISTRATION`]. + pub fn registration() -> Self { + Paint::Solid { color: Color::cmyk(1.0, 1.0, 1.0, 1.0), swatch: Some(swatch::REGISTRATION.into()), tint: 1.0 } + } + /// Is this a colour linked to the Registration swatch (it prints on every plate)? + pub fn is_registration(&self) -> bool { + matches!(self, Paint::Solid { swatch: Some(s), .. } if s == swatch::REGISTRATION) + } pub fn label(&self) -> String { match self { Paint::None => "None".into(), diff --git a/crates/color/src/swatch.rs b/crates/color/src/swatch.rs index ad78b5e2..3cc6b463 100644 --- a/crates/color/src/swatch.rs +++ b/crates/color/src/swatch.rs @@ -17,6 +17,16 @@ pub struct Swatch { pub spot: bool, } +/// The name of the built-in Registration swatch ([`registration`]). +pub const REGISTRATION: &str = "[Registration]"; + +/// The built-in Registration swatch: a global colour of 100% of every ink that prints on every +/// plate, process and spot (printer's marks). Every document has it; it can't be edited or deleted. +pub fn registration() -> &'static Swatch { + static S: std::sync::OnceLock = std::sync::OnceLock::new(); + S.get_or_init(|| Swatch { name: REGISTRATION.into(), paint: Paint::solid(Color::cmyk(1.0, 1.0, 1.0, 1.0)), global: true, spot: false }) +} + impl Swatch { /// A tint swatch's base swatch and tint: its colour links to another (global) swatch. pub fn tint_of(&self) -> Option<(&str, f32)> { @@ -25,6 +35,10 @@ impl Swatch { _ => None, } } + /// None and Registration: built in, they can't be edited, moved or deleted. + pub fn is_reserved(&self) -> bool { + self.paint.is_none() || self.name == REGISTRATION + } } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index cbb76c5e..82fcacdf 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -9,6 +9,7 @@ pub mod appearance; pub mod graph; pub mod hit; pub mod live; +pub mod marks; pub mod node; pub mod overprint; pub mod pattern; diff --git a/crates/doc/src/marks.rs b/crates/doc/src/marks.rs new file mode 100644 index 00000000..7a7a2104 --- /dev/null +++ b/crates/doc/src/marks.rs @@ -0,0 +1,150 @@ +//! Printer's marks drawn as art: the trim marks of Object → Create Trim Marks and Effect → Crop +//! Marks, stroked in the Registration colour so that they print on every plate. + +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; +use vectorcraft_color::Paint; +use vectorcraft_geom::{Point, Rect, shapes}; + +use crate::{Appearance, Node, NodeId}; + +/// Trim mark style (the preference `japaneseCropMarks` picks Japanese). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum MarkStyle { + /// One line per edge at each corner, set off from the trim box. + #[default] + Roman, + /// Double lines at each corner (the trim and the bleed edges) and centre marks on each side. + Japanese, +} + +impl MarkStyle { + pub fn id(self) -> &'static str { + match self { + MarkStyle::Roman => "roman", + MarkStyle::Japanese => "japanese", + } + } + + /// Parse an id, ignoring case. + pub fn parse(s: &str) -> Option { + [MarkStyle::Roman, MarkStyle::Japanese].into_iter().find(|m| m.id().eq_ignore_ascii_case(s)) + } +} + +/// Trim marks around a rectangle (the trim box). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct TrimMarks { + pub style: MarkStyle, + /// Roman: the gap between the trim box and the marks. + pub offset: f64, + /// How long each mark is. + pub length: f64, + /// Japanese: the bleed, between the trim lines and the outer lines (3 mm). + pub bleed: f64, + /// Stroke weight. + pub weight: f64, +} + +impl Default for TrimMarks { + fn default() -> Self { + Self { style: MarkStyle::Roman, offset: 9.0, length: 18.0, bleed: 3.0 * 72.0 / 25.4, weight: 0.3 } + } +} + +impl TrimMarks { + /// The default marks of `style`. + pub fn of(style: MarkStyle) -> Self { + Self { style, ..Default::default() } + } + + /// How far the marks reach outside the trim box. + pub fn reach(&self) -> f64 { + let gap = match self.style { + MarkStyle::Roman => self.offset, + MarkStyle::Japanese => self.bleed, + }; + gap + self.length + self.weight + } + + /// The marks' lines around `r`. + pub fn lines(&self, r: Rect) -> Vec<(Point, Point)> { + let (l, mut out) = (self.length, vec![]); + // Each corner with its outward directions. + let corners = [(r.x0, r.y0, -1.0, -1.0), (r.x1, r.y0, 1.0, -1.0), (r.x1, r.y1, 1.0, 1.0), (r.x0, r.y1, -1.0, 1.0)]; + match self.style { + MarkStyle::Roman => { + let o = self.offset; + for (x, y, sx, sy) in corners { + out.push((Point::new(x + sx * o, y), Point::new(x + sx * (o + l), y))); + out.push((Point::new(x, y + sy * o), Point::new(x, y + sy * (o + l)))); + } + } + MarkStyle::Japanese => { + let b = self.bleed; + for (x, y, sx, sy) in corners { + // The trim lines, outside the bleed, and the bleed lines meeting at its corner. + out.push((Point::new(x + sx * b, y), Point::new(x + sx * (b + l), y))); + out.push((Point::new(x, y + sy * b), Point::new(x, y + sy * (b + l)))); + out.push((Point::new(x, y + sy * b), Point::new(x + sx * (b + l), y + sy * b))); + out.push((Point::new(x + sx * b, y), Point::new(x + sx * b, y + sy * (b + l)))); + } + // A cross outside the bleed at the middle of each side. + let c = r.center(); + for (p, dx, dy) in [ + (Point::new(c.x, r.y0), 0.0, -1.0), + (Point::new(r.x1, c.y), 1.0, 0.0), + (Point::new(c.x, r.y1), 0.0, 1.0), + (Point::new(r.x0, c.y), -1.0, 0.0), + ] { + let at = |d: f64| Point::new(p.x + dx * d, p.y + dy * d); + out.push((at(b), at(b + l))); + let m = at(b + l / 2.0); + out.push((Point::new(m.x - dy * l / 2.0, m.y - dx * l / 2.0), Point::new(m.x + dy * l / 2.0, m.y + dx * l / 2.0))); + } + } + } + out + } + + /// The marks around `r` as a group named "Trim Marks" of lines stroked in Registration, with + /// ids from `alloc` (the group's last). + pub fn group(&self, r: Rect, alloc: &mut dyn FnMut() -> NodeId) -> Node { + let ap = Appearance::basic(Paint::None, Paint::registration(), self.weight); + let children = self.lines(r).into_iter().map(|(a, b)| Arc::new(Node::path(alloc(), shapes::line(a, b), ap.clone()))).collect(); + let mut g = Node::group(alloc(), children); + g.name = Some("Trim Marks".into()); + g + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn roman_marks_sit_off_the_corners_and_japanese_ones_are_doubled() { + let r = Rect::new(0.0, 0.0, 100.0, 50.0); + let roman = TrimMarks::default().lines(r); + assert_eq!(roman.len(), 8); + assert!(roman.iter().all(|(a, b)| !r.contains(*a) && !r.contains(*b))); + assert_eq!(roman[0], (Point::new(-9.0, 0.0), Point::new(-27.0, 0.0))); + let jp = TrimMarks::of(MarkStyle::Japanese); + let lines = jp.lines(r); + assert_eq!(lines.len(), 24, "four lines per corner and a cross per side"); + // Two horizontal lines at the top-left corner: the trim line and the bleed line. + let tl: Vec = lines[..4].iter().filter(|(a, b)| a.y == b.y).map(|(a, _)| a.y).collect(); + assert_eq!(tl.len(), 2); + assert!((tl[0] - 0.0).abs() < 1e-9 && (tl[1] + jp.bleed).abs() < 1e-9, "{tl:?}"); + let mut n = 0; + let g = jp.group(r, &mut || { + n += 1; + NodeId(n) + }); + assert_eq!((g.children().unwrap().len(), g.id), (24, NodeId(25))); + assert!(g.children().unwrap()[0].appearance.stroke().unwrap().paint.is_registration()); + assert_eq!(MarkStyle::parse("Japanese"), Some(MarkStyle::Japanese)); + } +} diff --git a/crates/doc/src/swatches.rs b/crates/doc/src/swatches.rs index 17626f59..4fce4569 100644 --- a/crates/doc/src/swatches.rs +++ b/crates/doc/src/swatches.rs @@ -9,6 +9,7 @@ use std::sync::Arc; +use vectorcraft_color::swatch::REGISTRATION; use vectorcraft_color::{Color, Paint, Swatch, tint_percent}; use crate::{AppearanceItem, Document, Node, NodeId, NodeKind}; @@ -21,8 +22,10 @@ impl Document { pub fn swatches_iter_mut(&mut self) -> impl Iterator { self.swatches.iter_mut().chain(self.swatch_groups.iter_mut().flat_map(|g| g.swatches.iter_mut())) } + /// Swatch `name`, the built-in Registration swatch ([`vectorcraft_color::swatch::registration`]) + /// included. pub fn swatch(&self, name: &str) -> Option<&Swatch> { - self.swatches_iter().find(|s| s.name == name) + self.swatches_iter().find(|s| s.name == name).or_else(|| (name == REGISTRATION).then(vectorcraft_color::swatch::registration)) } pub fn swatch_mut(&mut self, name: &str) -> Option<&mut Swatch> { self.swatches_iter_mut().find(|s| s.name == name) diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index d38094bd..afd2edb8 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -16,7 +16,10 @@ use vectorcraft_doc::{Appearance, AppearanceItem, Document, Node, NodeKind, Stro use vectorcraft_geom::{FillRule, PathData}; use crate::group::{has_own_paint, paints}; -use crate::{GeomContext, apply_geometry_with, evaluate_container, has_geometry, has_pathfinder, is_geometry, needs_outline, reshape}; +use crate::{ + GeomContext, apply_geometry_with, crop_marks_art, evaluate_container, has_crop_marks, has_geometry, has_pathfinder, is_geometry, needs_outline, + reshape, +}; fn item_effects(item: &AppearanceItem) -> &[vectorcraft_doc::Effect] { match item { @@ -34,7 +37,8 @@ fn clear_item_effects(item: &mut AppearanceItem) { /// Does anything in `n`'s subtree need baking? pub fn needs_bake(n: &Node) -> bool { - has_pathfinder(n) + has_crop_marks(n) + || has_pathfinder(n) || has_own_paint(n) || has_geometry(&n.appearance.effects) || n.appearance.items.iter().any(|i| has_geometry(item_effects(i))) @@ -74,6 +78,10 @@ fn bake_node(d: &mut Document, n: &Node) -> Option { if !needs_bake(n) { return None; } + // Crop marks: the object and its marks. + if let Some(m) = crop_marks_art(n) { + return Some(bake_pieces(d, m)); + } // Type, images, symbol instances and live objects: reshaped through their outlines. if needs_outline(n) && has_geometry(&n.appearance.effects) { let symbol = match &n.kind { @@ -85,21 +93,8 @@ fn bake_node(d: &mut Document, n: &Node) -> Option { } // Groups and layers: Pathfinder, geometry effects and their own fills and strokes. The new // pieces (results, outlines, the fills' and strokes' art) get ids of their own. - if let Some(mut m) = evaluate_container(n) { - let mut seen = std::collections::HashSet::from([m.id]); - let children = m.children().cloned().unwrap_or_default(); - let children = children - .into_iter() - .map(|c| { - let mut c = Arc::unwrap_or_clone(c); - fresh_ids(d, &mut c, &mut seen); - Arc::new(bake_node(d, &c).unwrap_or(c)) - }) - .collect(); - if let Some(ch) = m.children_mut() { - *ch = children; - } - return Some(m); + if let Some(m) = evaluate_container(n) { + return Some(bake_pieces(d, m)); } if let Some(ch) = n.children() { if matches!(n.kind, NodeKind::Compound { .. }) @@ -117,6 +112,25 @@ fn bake_node(d: &mut Document, n: &Node) -> Option { bake_leaf(d, n) } +/// `m`, art evaluated from an object, with its children baked and given ids of their own (the +/// pieces copied from the source keep the source's ids until then). +fn bake_pieces(d: &mut Document, mut m: Node) -> Node { + let mut seen = std::collections::HashSet::from([m.id]); + let children = m.children().cloned().unwrap_or_default(); + let children = children + .into_iter() + .map(|c| { + let mut c = Arc::unwrap_or_clone(c); + fresh_ids(d, &mut c, &mut seen); + Arc::new(bake_node(d, &c).unwrap_or(c)) + }) + .collect(); + if let Some(ch) = m.children_mut() { + *ch = children; + } + m +} + /// Give `n` and its descendants a new id wherever one was already `seen` (the pieces evaluated art /// copies from its source keep ids of their own). pub fn fresh_ids(d: &mut Document, n: &mut Node, seen: &mut std::collections::HashSet) { diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 472141a5..eb06ce18 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -27,6 +27,7 @@ mod bake; mod clip; mod distort; mod group; +mod marks; mod raster; mod reshape; pub mod stroke; @@ -46,6 +47,7 @@ pub use clip::clip_outline; pub use group::{ OutlineHook, PATHFINDER_EFFECTS, evaluate_container, has_container_appearance, has_pathfinder, is_pathfinder, member_shapes, pathfinder_children, }; +pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks}; pub use raster::{RasterFx, outset, raster_effects}; pub use reshape::{expand_outlined, needs_outline, outline_art, outline_text, reshape}; pub use warp::{WarpStyle, warp_point}; @@ -228,6 +230,13 @@ pub fn effect_catalog() -> Vec { for (id, label, _) in PATHFINDER_EFFECTS { v.push(g(id, label, PATHFINDER, "{} (groups and layers: live Pathfinder over the members)", json!({}))); } + v.push(g( + CROP_MARKS, + "Crop Marks", + &["Effect"], + "{style?: \"roman\"|\"japanese\" (default: the japaneseCropMarks preference when applied)} trim marks in [Registration] around the object's bounds, following it", + json!({}), + )); v } @@ -272,9 +281,9 @@ pub fn is_raster(id: &str) -> bool { matches!(id, "stylize.dropShadow" | "stylize.innerGlow" | "stylize.outerGlow" | "stylize.feather" | "blur.gaussian") } -/// Does `id` change geometry? +/// Does `id` change geometry? (Crop Marks adds art of its own instead, [`crop_marks_art`].) pub fn is_geometry(id: &str) -> bool { - !is_raster(id) && !is_pathfinder(id) && catalog_index().contains_key(id) + !is_raster(id) && !is_pathfinder(id) && id != CROP_MARKS && catalog_index().contains_key(id) } /// Any visible geometry effect in the list? diff --git a/crates/effects/src/marks.rs b/crates/effects/src/marks.rs new file mode 100644 index 00000000..4009cad3 --- /dev/null +++ b/crates/effects/src/marks.rs @@ -0,0 +1,70 @@ +//! Effect → Crop Marks: live trim marks in the Registration colour around the object, which follow +//! it as it moves or changes size. The renderer, the exporters ([`crate::bake_document`]) and +//! Expand Appearance draw the object as [`crop_marks_art`]. + +use std::sync::Arc; + +use vectorcraft_doc::Node; +use vectorcraft_doc::marks::{MarkStyle, TrimMarks}; + +/// The effect id. +pub const CROP_MARKS: &str = "cropMarks"; + +/// Does `n` carry a visible Crop Marks effect? +pub fn has_crop_marks(n: &Node) -> bool { + n.appearance.effects.iter().any(|e| e.visible && e.id == CROP_MARKS) +} + +/// The art of an object with Crop Marks: a group (with the object's id, name and transparency) of +/// the object itself, without the effect, and the marks around its visual bounds (the last Crop +/// Marks effect's style). `None` without a visible Crop Marks effect. +pub fn crop_marks_art(n: &Node) -> Option { + let fx = n.appearance.effects.iter().rev().find(|e| e.visible && e.id == CROP_MARKS)?; + let style = fx.params.get("style").and_then(|v| v.as_str()).and_then(MarkStyle::parse).unwrap_or_default(); + let bounds = n.visual_bounds()?; + let marks = TrimMarks::of(style).group(bounds, &mut || n.id); + let mut object = n.clone(); + object.appearance.effects.retain(|e| e.id != CROP_MARKS); + // The group takes over the object's identity, transparency, mask and link. + let mut g = Node::group(n.id, vec![]); + (g.visible, g.locked) = (n.visible, n.locked); + g.name = object.name.take(); + g.opacity = std::mem::replace(&mut object.opacity, 1.0); + g.blend = std::mem::take(&mut object.blend); + g.isolate = std::mem::take(&mut object.isolate); + g.mask = object.mask.take(); + g.attrs = object.attrs.take(); + if let Some(ch) = g.children_mut() { + *ch = vec![Arc::new(object), Arc::new(marks)]; + } + Some(g) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use vectorcraft_doc::{Appearance, Effect, NodeId}; + use vectorcraft_geom::{Rect, shapes}; + + #[test] + fn the_marks_surround_the_object_and_follow_it() { + let mut n = Node::path(NodeId(7), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)), Appearance::default_art()); + assert!(crop_marks_art(&n).is_none()); + n.opacity = 0.5; + n.appearance.effects.push(Effect { id: CROP_MARKS.into(), params: json!({"style": "japanese"}), visible: true }); + let art = crop_marks_art(&n).unwrap(); + assert_eq!((art.id, art.opacity), (NodeId(7), 0.5)); + let [object, marks] = art.children().unwrap().as_slice() else { panic!() }; + assert!(object.appearance.effects.is_empty() && object.opacity == 1.0); + assert_eq!(marks.children().unwrap().len(), 24, "Japanese marks"); + // Around the stroked bounds (0.5 outside the path), by the marks' reach. + let reach = TrimMarks::of(MarkStyle::Japanese).reach() + 0.5; + let b = art.visual_bounds().unwrap(); + assert!((b.x0 + reach).abs() < 2.0 && (b.x1 - 100.0 - reach).abs() < 2.0, "{b:?}"); + // Moved, the marks move along. + n.transform(vectorcraft_geom::Affine::translate((40.0, 0.0)), false); + let b2 = crop_marks_art(&n).unwrap().visual_bounds().unwrap(); + assert!((b2.x0 - b.x0 - 40.0).abs() < 1e-6); + } +} diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index d595cc44..8a13018d 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -58,7 +58,7 @@ pub fn specs() -> Vec { "Expand Appearance", ["Object"], None, - "{ids?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (strokes outlined, brushed strokes their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it), type with effects or fills of its own is outlined; a group's or layer's own fills and strokes become objects among its members, which are expanded too. Hidden fills, strokes and effects are dropped. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids}", + "{ids?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (strokes outlined, brushed strokes their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it), type with effects or fills of its own is outlined; Crop Marks become a group of the object and its marks; a group's or layer's own fills and strokes become objects among its members, which are expanded too. Hidden fills, strokes and effects are dropped. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids}", can_expand, expand_appearance ), @@ -119,10 +119,14 @@ fn edit_effects( pub(crate) fn apply(s: &mut Session, p: &Value) -> Result { const C: &str = "effect.apply"; let id = str_param(p, "effect").or_else(|| str_param(p, "id")).ok_or_else(|| bad(C, "missing `effect` id"))?; - let params = p.get("params").cloned().unwrap_or(Value::Null); + let mut params = p.get("params").cloned().unwrap_or(Value::Null); if !params.is_null() && !params.is_object() { return Err(bad(C, "`params` must be an object")); } + // Crop Marks take their style from the preferences when applied. + if id == effects::CROP_MARKS && params.get("style").is_none() { + params["style"] = json!(s.crop_mark_style().id()); + } let effect = effects::new_effect(id, ¶ms).ok_or_else(|| bad(C, format!("unknown effect `{id}`")))?; let label = effects::effect_info(id).map(|e| e.label.trim_end_matches('…').to_string()).unwrap_or_default(); let item = item_target(s, p, C)?; @@ -370,6 +374,22 @@ fn expand_node(d: &mut Document, id: NodeId, brushes: &[vectorcraft_brush::Brush if !expandable(&n) { return; } + // Crop Marks: a group of the object and its marks, then the object expands on its own. + if let Some(mut m) = effects::crop_marks_art(&n) { + let mut seen = std::collections::HashSet::from([id]); + for c in m.children_mut().into_iter().flatten() { + effects::fresh_ids(d, Arc::make_mut(c), &mut seen); + } + let object = m.children().and_then(|c| c.first()).map(|c| c.id); + if let Some(slot) = d.node_mut(id) { + *slot = m; + out.push(id); + } + if let Some(object) = object { + expand_node(d, object, brushes, out); + } + return; + } let image = raster_image(d, &n); let mut v = n.clone(); strip_raster(&mut v, false); diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index 09e1e0eb..365c6238 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -6,8 +6,9 @@ use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::marks::{MarkStyle, TrimMarks}; use vectorcraft_doc::{Appearance, ImageObject, LiveShape, Node, NodeId, NodeKind}; -use vectorcraft_geom::{Affine, Anchor, PathData, Point, Rect, Vec2, shapes}; +use vectorcraft_geom::{Affine, Anchor, PathData, Point, Rect, Vec2}; use super::edit::{duplicate_in, selected_roots}; use super::*; @@ -115,7 +116,7 @@ pub fn specs() -> Vec { "Create Trim Marks", ["Object"], None, - "{offset?: pt (9), length?: pt (18), weight?: pt (0.3)} trim marks around the selection (or the first artboard) as a group → {id}", + "{style?: \"roman\"|\"japanese\" (default: the japaneseCropMarks preference; japanese: double lines at the trim and bleed edges plus centre marks), allArtboards?: bool (default: true when nothing is selected), offset?: pt (9, roman), length?: pt (18), bleed?: pt (8.5, japanese), weight?: pt (0.3)} trim marks stroked in [Registration] (printing on every plate) around the selection, or around every artboard, one group each, as one undo step → {id: the first group, ids}", has_doc, trim_marks ), @@ -518,47 +519,48 @@ fn crop_image(s: &mut Session, p: &Value) -> Result { // ---------- Trim marks ---------- -fn trim_marks(s: &mut Session, p: &Value) -> Result { - let st = s.doc()?; - let off = f64_or(p, "offset", 9.0).max(0.0); - let len = f64_or(p, "length", 18.0).max(0.1); - let weight = f64_or(p, "weight", 0.3).max(0.01); - let r = if st.selection.is_empty() { - st.doc.artboards.first().map(|a| a.rect) - } else { - let roots = selected_roots(s)?; - s.doc()?.doc.bounds_of(&roots, true) +impl Session { + /// The trim mark style the preferences ask for (General → Use Japanese Crop Marks). + pub fn crop_mark_style(&self) -> MarkStyle { + if self.prefs.japanese_crop_marks { MarkStyle::Japanese } else { MarkStyle::Roman } + } +} + +fn trim_marks(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.createTrimMarks"; + let style = match str_param(p, "style") { + Some(v) => MarkStyle::parse(v).ok_or_else(|| bad(C, "`style` must be roman or japanese"))?, + None => s.crop_mark_style(), + }; + let d = TrimMarks::default(); + let marks = TrimMarks { + style, + offset: f64_or(p, "offset", d.offset).max(0.0), + length: f64_or(p, "length", d.length).max(0.1), + bleed: f64_or(p, "bleed", d.bleed).max(0.0), + weight: f64_or(p, "weight", d.weight).max(0.01), + }; + let st = s.doc()?; + let rects: Vec = if bool_or(p, "allArtboards", st.selection.is_empty()) { + st.doc.artboards.iter().map(|a| a.rect).collect() + } else { + st.doc.bounds_of(&selected_roots(s)?, true).into_iter().collect() + }; + if rects.is_empty() { + return Err(bad(C, "nothing to mark")); } - .ok_or_else(|| EngineError::Other("nothing to mark".into()))?; let parent = s.doc()?.insertion_parent(); - let lines = [ - // Top-left. - (Point::new(r.x0 - off - len, r.y0), Point::new(r.x0 - off, r.y0)), - (Point::new(r.x0, r.y0 - off - len), Point::new(r.x0, r.y0 - off)), - // Top-right. - (Point::new(r.x1 + off, r.y0), Point::new(r.x1 + off + len, r.y0)), - (Point::new(r.x1, r.y0 - off - len), Point::new(r.x1, r.y0 - off)), - // Bottom-right. - (Point::new(r.x1 + off, r.y1), Point::new(r.x1 + off + len, r.y1)), - (Point::new(r.x1, r.y1 + off), Point::new(r.x1, r.y1 + off + len)), - // Bottom-left. - (Point::new(r.x0 - off - len, r.y1), Point::new(r.x0 - off, r.y1)), - (Point::new(r.x0, r.y1 + off), Point::new(r.x0, r.y1 + off + len)), - ]; - let id = s.edit("Create Trim Marks", |d, sel| { - let mut children = vec![]; - for (a, b) in lines { - let id = d.alloc_id(); - children.push(Arc::new(Node::path(id, shapes::line(a, b), Appearance::basic(Paint::None, Paint::solid(Color::BLACK), weight)))); + let ids = s.edit("Create Trim Marks", |d, sel| { + let mut ids = vec![]; + for r in &rects { + let g = marks.group(*r, &mut || d.alloc_id()); + ids.push(g.id); + d.insert(parent, usize::MAX, g)?; } - let gid = d.alloc_id(); - let mut g = Node::group(gid, children); - g.name = Some("Trim Marks".into()); - d.insert(parent, usize::MAX, g)?; - sel.set([gid]); - Ok(gid) + sel.set(ids.iter().copied()); + Ok(ids) })?; - Ok(json!({ "id": id.0 })) + Ok(json!({ "id": ids[0].0, "ids": ids.iter().map(|i| i.0).collect::>() })) } // ---------- Convert to Shape ---------- diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index edda0328..2fb85b1e 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -16,7 +16,7 @@ pub fn specs() -> Vec { "Fill", [], None, - "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}, none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray)} sets the selection's fill and the default (new art fits a gradient to itself)", + "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}, none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray)} sets the selection's fill and the default (new art fits a gradient to itself)", has_doc, |s, p| set_paint(s, p, true) ), diff --git a/crates/engine/src/cmd/prefscmds.rs b/crates/engine/src/cmd/prefscmds.rs index 910e01ac..5bc776d6 100644 --- a/crates/engine/src/cmd/prefscmds.rs +++ b/crates/engine/src/cmd/prefscmds.rs @@ -294,6 +294,8 @@ pub const PREF_SPECS: &[PrefSpec] = &[ p!("overrideCharColor", "Type", "Graphic Styles", "Override Character Color", bool), // Object → Pattern → Tile Edge Color p!("patternTileEdgeColor", "Guides & Grid", "Pattern Editing", "Tile Edge Color", color), + // Object → Create Trim Marks, Effect → Crop Marks + p!("japaneseCropMarks", "General", "Options", "Use Japanese Crop Marks", bool), ]; pub fn spec(key: &str) -> Option<&'static PrefSpec> { diff --git a/crates/engine/src/cmd/swatch.rs b/crates/engine/src/cmd/swatch.rs index effd970e..21b9bd3a 100644 --- a/crates/engine/src/cmd/swatch.rs +++ b/crates/engine/src/cmd/swatch.rs @@ -55,7 +55,7 @@ pub fn specs() -> Vec { "Swatches", [], None, - "{group?: name (only that colour group's swatches)} → {swatches: [{name, kind: \"none\"|\"color\"|\"gradient\"|\"pattern\", group, global, spot, color?, hex?, tintOf?: base swatch of a tint swatch, tint?: its %, gradient?, pattern?}], groups: [{name, swatches: [names]}]}", + "{group?: name (only that colour group's swatches)} → {swatches: [{name, kind: \"none\"|\"color\"|\"gradient\"|\"pattern\", group, global, spot, color?, hex?, tintOf?: base swatch of a tint swatch, tint?: its %, gradient?, pattern?}] (the built-in [Registration] after None: it prints on every plate and can't be edited, moved or deleted), groups: [{name, swatches: [names]}]}", has_doc, swatch_list ), @@ -210,7 +210,7 @@ fn swatch_delete(s: &mut Session, p: &Value) -> Result { Some(g) => g.swatches.iter().collect(), None => vec![d.swatch(n).ok_or_else(|| bad(C, format!("no swatch or colour group `{n}`")))?], }; - if swatches.iter().any(|w| w.paint.is_none()) { + if swatches.iter().any(|w| w.is_reserved()) { return Err(bad(C, format!("`{n}` can't be deleted"))); } global.extend(swatches.iter().filter(|w| w.global).map(|w| w.name.clone())); @@ -355,7 +355,7 @@ fn swatch_move(s: &mut Session, p: &Value) -> Result { } for n in &names { let sw = d.swatch(n).ok_or_else(|| bad(C, format!("no swatch `{n}` (swatches and colour groups move separately)")))?; - if sw.paint.is_none() { + if sw.is_reserved() { return Err(bad(C, format!("`{n}` can't be moved"))); } if group.is_some() && sw.paint.color().is_none() { @@ -384,6 +384,9 @@ fn swatch_duplicate(s: &mut Session, p: &Value) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("swatch.duplicate", "missing name"))?.to_string(); let new = s.edit("Duplicate Swatch", |d, _| { let src = d.swatch(&name).cloned().ok_or_else(|| EngineError::Other(format!("no swatch `{name}`")))?; + if src.is_reserved() { + return Err(bad("swatch.duplicate", format!("`{name}` can't be duplicated"))); + } let nm = d.free_swatch_name(&format!("{name} copy")); let copy = Swatch { name: nm.clone(), ..src }; if let Some(pos) = d.swatches.iter().position(|sw| sw.name == name) { @@ -480,7 +483,7 @@ fn swatch_edit(s: &mut Session, p: &Value) -> Result { let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?.to_string(); let d = &s.doc()?.doc; let sw = d.swatch(&name).cloned().ok_or_else(|| bad(C, format!("no swatch `{name}`")))?; - if sw.paint.is_none() { + if sw.is_reserved() { return Err(bad(C, format!("`{name}` can't be edited"))); } let given = |k: &str| p.get(k).is_some_and(|v| !v.is_null()); @@ -567,7 +570,11 @@ fn swatch_list(s: &mut Session, p: &Value) -> Result { Some(g) => vec![d.swatch_groups.iter().find(|x| x.name == g).ok_or_else(|| bad("swatch.list", format!("no colour group `{g}`")))?], None => d.swatch_groups.iter().collect(), }; - let ungrouped = d.swatches.iter().filter(|_| only.is_none()).map(|w| swatch_json(w, None)); + // The built-in Registration swatch lists after None, as the panel shows it. + let none = d.swatches.iter().take_while(|w| w.paint.is_none()).count(); + let (specials, rest) = d.swatches.split_at(none); + let all = specials.iter().chain([vectorcraft_color::swatch::registration()]).chain(rest); + let ungrouped = all.filter(|_| only.is_none()).map(|w| swatch_json(w, None)); let grouped = groups.iter().flat_map(|g| g.swatches.iter().map(|w| swatch_json(w, Some(&g.name)))); let swatches: Vec = ungrouped.chain(grouped).collect(); let groups: Vec = @@ -653,7 +660,11 @@ fn swatch_merge(s: &mut Session, p: &Value) -> Result { let names = names_param(p, C)?; let d = &s.doc()?.doc; for n in &names { - if d.swatch(n).ok_or_else(|| bad(C, format!("no swatch `{n}`")))?.paint.color().is_none() { + let sw = d.swatch(n).ok_or_else(|| bad(C, format!("no swatch `{n}`")))?; + if sw.is_reserved() { + return Err(bad(C, format!("`{n}` can't be merged"))); + } + if sw.paint.color().is_none() { return Err(bad(C, format!("`{n}` isn't a solid colour"))); } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 6a29b526..3fcf69f8 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -338,6 +338,8 @@ pub struct Prefs { /// Preferences dialog field, so it has no [`cmd::prefscmds::PREF_SPECS`] row. #[serde(skip_serializing_if = "Vec::is_empty")] pub width_profiles: Vec, + /// General → Use Japanese Crop Marks: the style of Create Trim Marks and Effect → Crop Marks. + pub japanese_crop_marks: bool, } impl Default for Prefs { @@ -458,6 +460,7 @@ impl Default for Prefs { eyedropper: Default::default(), flattener_presets: vec![], width_profiles: vec![], + japanese_crop_marks: false, } } } @@ -870,6 +873,8 @@ mod tests_prefs; #[cfg(test)] mod tests_proxyitems; #[cfg(test)] +mod tests_registration; +#[cfg(test)] mod tests_strokegeom; #[cfg(test)] mod tests_strokereach; diff --git a/crates/engine/src/tests_registration.rs b/crates/engine/src/tests_registration.rs new file mode 100644 index 00000000..af232531 --- /dev/null +++ b/crates/engine/src/tests_registration.rs @@ -0,0 +1,170 @@ +//! The built-in [Registration] swatch (every plate, PDF `/Separation /All`), Object → Create Trim +//! Marks (Registration, every artboard, Japanese marks) and Effect → Crop Marks. + +use serde_json::{Value, json}; +use vectorcraft_color::Paint; +use vectorcraft_color::swatch::REGISTRATION; +use vectorcraft_doc::{Document, Node}; +use vectorcraft_geom::Affine; +use vectorcraft_render::proof::ProofSetup; +use vectorcraft_render::{RenderOptions, Renderer}; + +use super::*; +use crate::tests_colormgmt::GLOBAL; + +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 id_of(v: &Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +fn node(s: &Session, id: NodeId) -> Node { + s.doc().unwrap().doc.node(id).unwrap().clone() +} + +/// The 4× render of `doc` (400×400 px), on white, showing `plates` only when given. +fn render(doc: &Document, plates: Option<&[&str]>) -> vectorcraft_render::Rendered { + let proof = plates.map(|p| ProofSetup { separations: Some(p.iter().map(|n| n.to_string()).collect()), ..Default::default() }); + let mut r = Renderer::new(); + r.threads = 0; + r.render(doc, 400, 400, Affine::scale(4.0), &RenderOptions { background: Some([255, 255, 255, 255]), proof, ..Default::default() }) +} + +fn dark(p: [u8; 4]) -> bool { + p[0] < 90 && p[1] < 90 && p[2] < 90 +} + +#[test] +fn registration_is_a_reserved_swatch() { + let mut s = session(); + let a = id_of(&run(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20}))); + run(&mut s, "paint.setStroke", json!({"swatch": REGISTRATION})); + let st = node(&s, a).appearance.stroke().unwrap().paint.clone(); + assert!(st.is_registration() && st == Paint::registration(), "{st:?}"); + // Listed after None, global; it can't be deleted, edited, moved, duplicated or merged. + let list = run(&mut s, "swatch.list", json!({})); + assert_eq!(list["swatches"][0]["kind"], "none"); + assert_eq!((list["swatches"][1]["name"].clone(), list["swatches"][1]["global"].clone()), (json!(REGISTRATION), json!(true))); + for (cmd, p) in [ + ("swatch.delete", json!({"name": REGISTRATION})), + ("swatch.edit", json!({"name": REGISTRATION, "color": "#ff0000"})), + ("swatch.move", json!({"name": REGISTRATION, "to": 5})), + ("swatch.duplicate", json!({"name": REGISTRATION})), + ("swatch.merge", json!({"names": ["White", REGISTRATION]})), + ("swatch.new", json!({"name": REGISTRATION, "color": "#ff0000"})), + ] { + let r = s.execute(cmd, &p); + assert!(r.is_err() || cmd == "swatch.new", "{cmd}"); + if cmd == "swatch.new" { + assert_ne!(r.unwrap()["name"], REGISTRATION, "the name is taken"); + } + } + assert!(s.doc().unwrap().doc.swatch(REGISTRATION).is_some()); + // Unused swatches never list it. + assert!(!run(&mut s, "swatch.unused", json!({}))["names"].as_array().unwrap().iter().any(|n| n == REGISTRATION)); +} + +#[test] +fn trim_marks_print_on_every_plate() { + let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + let mut s = session(); + run(&mut s, "swatch.new", json!({"name": "Spot Ink", "color": {"c": 0, "m": 60, "y": 90, "k": 0}, "global": true})); + run(&mut s, "swatch.setSpot", json!({"name": "Spot Ink"})); + run(&mut s, "shape.rectangle", json!({"x": 30, "y": 30.125, "width": 40, "height": 40})); + run(&mut s, "paint.setStroke", json!({"none": true})); + let g = id_of(&run(&mut s, "object.createTrimMarks", json!({"weight": 1}))); + let n = node(&s, g); + assert_eq!(n.children().unwrap().len(), 8); + assert!(n.children().unwrap().iter().all(|c| c.appearance.stroke().unwrap().paint.is_registration())); + let doc = s.doc().unwrap().doc.clone(); + // The top-left mark runs from x = 21 to 3 at y = 30.125: 100% ink on each plate. + for plate in ["Cyan", "Magenta", "Yellow", "Black", "Spot Ink"] { + let p = render(&doc, Some(&[plate])).pixel(48, 120); + assert!(dark(p), "{plate}: {p:?}"); + } + // A plain black mark would print on the black plate only. + run(&mut s, "paint.setStroke", json!({"ids": [n.children().unwrap()[0].id.0], "color": {"c": 0, "m": 0, "y": 0, "k": 100}})); + let doc = s.doc().unwrap().doc.clone(); + assert!(!dark(render(&doc, Some(&["Cyan"])).pixel(48, 120))); + assert!(!dark(render(&doc, Some(&["Spot Ink"])).pixel(48, 120))); +} + +#[test] +fn pdf_writes_registration_as_separation_all() { + let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + let mut s = session(); + run(&mut s, "shape.rectangle", json!({"x": 30, "y": 30, "width": 40, "height": 40})); + run(&mut s, "object.createTrimMarks", json!({})); + let bytes = + vectorcraft_pdf::export(&s.doc().unwrap().doc, &vectorcraft_pdf::PdfOptions { compress: false, created: Some(0), ..Default::default() }) + .unwrap(); + let pdf = String::from_utf8_lossy(&bytes); + assert!(pdf.contains("/Separation/All/DeviceCMYK"), "Registration prints on every plate"); +} + +#[test] +fn trim_marks_cover_every_artboard_and_take_the_japanese_style() { + let mut s = session(); + run(&mut s, "artboard.new", json!({"x": 200, "y": 0, "width": 100, "height": 100})); + let r = run(&mut s, "object.createTrimMarks", json!({})); + let ids = r["ids"].as_array().unwrap().clone(); + assert_eq!(ids.len(), 2, "one group per artboard when nothing is selected"); + let b = s.doc().unwrap().doc.bounds_of(&[NodeId(ids[1].as_u64().unwrap())], false).unwrap(); + assert!(b.x0 < 200.0 && b.x0 > 150.0 && b.x1 > 300.0, "{b:?}"); + assert_eq!(s.doc().unwrap().selection.objects.len(), 2); + run(&mut s, "edit.undo", json!({})); + assert!(s.doc().unwrap().doc.layers[0].children().unwrap().is_empty(), "one undo step"); + // Japanese marks: double lines at the trim and bleed edges, and centre marks. + run(&mut s, "prefs.set", json!({"key": "japaneseCropMarks", "value": true})); + assert_eq!(s.crop_mark_style(), vectorcraft_doc::marks::MarkStyle::Japanese); + assert!(s.execute("object.createTrimMarks", &json!({"allArtboards": false})).is_err(), "nothing selected to mark"); + let r = run(&mut s, "object.createTrimMarks", json!({})); + let first = node(&s, NodeId(r["ids"][0].as_u64().unwrap())); + let lines = first.children().unwrap(); + assert_eq!(lines.len(), 24); + // At the top-left corner of the first artboard: horizontal lines at y = 0 and y = -bleed. + let ys: Vec = lines[..4].iter().filter_map(|l| l.path_data().and_then(|p| p.bounds())).filter(|b| b.height() < 1e-9).map(|b| b.y0).collect(); + assert_eq!(ys.len(), 2); + assert!(ys.iter().any(|y| y.abs() < 1e-9) && ys.iter().any(|y| (y + 3.0 * 72.0 / 25.4).abs() < 1e-6), "{ys:?}"); + // An explicit style wins over the preference. + let r = run(&mut s, "object.createTrimMarks", json!({"style": "roman"})); + assert_eq!(node(&s, NodeId(r["ids"][0].as_u64().unwrap())).children().unwrap().len(), 8); + assert!(s.execute("object.createTrimMarks", &json!({"style": "western"})).is_err()); +} + +#[test] +fn crop_marks_effect_follows_the_object() { + let mut s = session(); + let a = id_of(&run(&mut s, "shape.rectangle", json!({"x": 30, "y": 30.125, "width": 40, "height": 40}))); + run(&mut s, "paint.setStroke", json!({"none": true})); + run(&mut s, "effect.apply", json!({"effect": "cropMarks"})); + let fx = node(&s, a).appearance.effects[0].clone(); + assert_eq!((fx.id.as_str(), fx.params["style"].as_str()), ("cropMarks", Some("roman")), "the style comes from the preferences"); + // The marks render outside the object (0.3 pt at 4×: about a pixel). + let mark = |s: &Session, x: u32| render(&s.doc().unwrap().doc, None).pixel(x, 120); + assert!(mark(&s, 48)[0] < 200, "{:?}", mark(&s, 48)); + // Moved, the marks move with it. + run(&mut s, "object.move", json!({"dx": 10, "dy": 0})); + assert_eq!(mark(&s, 48)[0], 255, "nothing left behind"); + assert!(mark(&s, 88)[0] < 200); + // SVG export and Expand Appearance draw the marks as paths. + let svg = vectorcraft_svg::export(&s.doc().unwrap().doc, &Default::default()); + assert_eq!(svg.matches(" { Some(SeparationSpace::new(SeparationColorant::Custom(sw.name.clone()), alt)) } - /// A solid paint, in the Separation colour space at its tint when it's linked to a spot swatch. + /// A solid paint, in the Separation colour space at its tint when it's linked to a spot swatch + /// or to Registration (`/All`: every plate). fn solid(&mut self, c: &Color, link: Option<&str>, tint: f32) -> krilla::color::Color { + if link == Some(vectorcraft_color::swatch::REGISTRATION) { + let alt = krilla::color::RegularColor::Cmyk(cmyk::Color::new(255, 255, 255, 255)); + return SepColor::new(q(tint), SeparationSpace::new(SeparationColorant::AllColorants, alt)).into(); + } match link.and_then(|n| self.separation(n)) { Some(space) => SepColor::new(q(tint), space).into(), None => self.col(c), diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index d8a57237..0b5a2e6e 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -48,10 +48,10 @@ pub(crate) fn has_fx(n: &Node) -> bool { } /// Does `n` carry visible object-level effects that only apply through its art (type, images, -/// symbol instances, live objects), or is it a group or layer with an appearance of its own (see -/// [`Renderer::draw_object_fx`])? +/// symbol instances, live objects; Crop Marks on anything), or is it a group or layer with an +/// appearance of its own (see [`Renderer::draw_object_fx`])? pub(crate) fn has_object_fx(n: &Node) -> bool { - (effects::needs_outline(n) && visible(&n.appearance.effects)) || effects::has_container_appearance(n) + (effects::needs_outline(n) && visible(&n.appearance.effects)) || effects::has_container_appearance(n) || effects::has_crop_marks(n) } /// Is `n` a group or layer (whose evaluated art keeps its knockout setting)? @@ -136,6 +136,8 @@ pub(crate) fn visual_bounds(n: &Node) -> Option { /// the subtree (a shadow can reach the view while its object is outside it). pub(crate) fn cull_bounds(n: &Node) -> Option { match &n.kind { + // The object and its crop marks. + _ if effects::has_crop_marks(n) => effects::crop_marks_art(n).and_then(|art| cull_bounds(&art)), // The evaluated art (its fills, strokes and geometry effects) and the raster effects' reach. _ if effects::has_container_appearance(n) => { let art = effects::evaluate_container(n); @@ -378,7 +380,8 @@ impl Renderer { /// What [`Self::draw_object_fx`] draws inside the object's transparency group. fn draw_object_fx_art(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc, cache: bool) { let art = self.fx_art(f.doc, a, cache); - let rfx = effects::raster_effects(&a.appearance.effects); + // With Crop Marks, the object inside the art paints its own raster effects. + let rfx = if effects::has_crop_marks(a) { vec![] } else { effects::raster_effects(&a.appearance.effects) }; match cull_bounds(&art) { Some(reach) if !f.opts.outline && !rfx.is_empty() => { // Feather and Inner Glow clip to the art's outline. @@ -413,7 +416,7 @@ impl Renderer { _ => None, }; let container = is_container(a); - let art = match effects::reshape(a, symbol.as_ref()) { + let art = match effects::crop_marks_art(a).or_else(|| effects::reshape(a, symbol.as_ref())) { Some(r) => r, None if container => effects::evaluate_container(a).unwrap_or_else(|| (**a).clone()), None if matches!(a.kind, NodeKind::Text(_) | NodeKind::Image(_)) => (**a).clone(), diff --git a/crates/render/src/proof.rs b/crates/render/src/proof.rs index 654c91a0..ddfbfbf6 100644 --- a/crates/render/src/proof.rs +++ b/crates/render/src/proof.rs @@ -6,7 +6,8 @@ //! into process inks (colour-managed), or a spot ink at the colour's tint when it's linked to a //! spot swatch (solid colours and gradient stops alike). With one plate visible the plate //! renders as greyscale ink coverage (black = 100%); with several, the visible inks are -//! composited through the proof profile, spots multiplied on top. +//! composited through the proof profile, spots multiplied on top. Colours linked to the +//! Registration swatch print their tint on every plate, spot plates included. //! Placed raster images are not separated (limitation). //! * **Overprint Preview** (and Separations Preview, which implies it): fills and strokes that //! overprint ([`vectorcraft_doc::FillLayer::overprint`], characters' too) are drawn with @@ -21,6 +22,7 @@ use std::sync::{Arc, RwLock}; use vectorcraft_color::cms::{self, Cms, PROCESS_PLATES}; pub use vectorcraft_color::cms::{Intent, ProofSetup, ProofTarget}; +use vectorcraft_color::swatch::REGISTRATION; use vectorcraft_color::{BlendMode, Color, Paint}; use vectorcraft_doc::{AppearanceItem, Document, Node, NodeKind}; @@ -33,6 +35,19 @@ pub struct Inks { pub cmyk: [f32; 4], /// Spot ink (swatch name, tint 0..1). pub spot: Option<(String, f32)>, + /// Registration: the tint (every process ink's) prints on every spot plate too. + pub registration: bool, +} + +impl Inks { + /// The tint this colour prints on spot plate `name`. + pub fn spot_tint(&self, name: &str) -> f32 { + match &self.spot { + _ if self.registration => self.cmyk[0], + Some((n, t)) if n == name => *t, + _ => 0.0, + } + } } /// A printing plate. @@ -77,14 +92,17 @@ fn spot_swatch<'a>(doc: &'a Document, name: &str) -> Option<&'a Color> { pub type Link<'a> = Option<(&'a str, f32)>; /// Separate one colour into inks. A colour linked to a spot swatch prints on that plate only, at -/// its tint. +/// its tint; one linked to the Registration swatch prints its tint on every plate. pub fn inks(doc: &Document, c: &Cms, color: &Color, link: Link, intent: Intent) -> Inks { + if let Some((REGISTRATION, tint)) = link { + return Inks { cmyk: [tint.clamp(0.0, 1.0); 4], spot: None, registration: true }; + } if let Some((name, tint)) = link && spot_swatch(doc, name).is_some() { - return Inks { cmyk: [0.0; 4], spot: Some((name.to_string(), tint.clamp(0.0, 1.0))) }; + return Inks { cmyk: [0.0; 4], spot: Some((name.to_string(), tint.clamp(0.0, 1.0))), registration: false }; } - Inks { cmyk: c.to_cmyk(color, intent), spot: None } + Inks { cmyk: c.to_cmyk(color, intent), spot: None, registration: false } } fn map_paint(p: &mut Paint, f: &mut dyn FnMut(&Color, Link) -> Color) { @@ -169,7 +187,7 @@ fn plate_color(doc: &Document, c: &Cms, proof: &ProofSetup, visible: &[String], let name = &visible[0]; let v = match PROCESS_PLATES.iter().position(|p| p == name) { Some(i) => ink.cmyk[i], - None => ink.spot.as_ref().filter(|(n, _)| n == name).map_or(0.0, |s| s.1), + None => ink.spot_tint(name), }; [1.0 - v; 3] } else { @@ -180,13 +198,15 @@ fn plate_color(doc: &Document, c: &Cms, proof: &ProofSetup, visible: &[String], } } let mut rgb = c.proof_cmyk_to_srgb(cmyk, proof); - if let Some((name, t)) = &ink.spot - && visible.contains(name) - && let Some(sc) = spot_swatch(doc, name) - { - let s = c.display_rgb(sc); - for i in 0..3 { - rgb[i] *= 1.0 - t * (1.0 - s[i]); + for name in visible.iter().filter(|v| !PROCESS_PLATES.contains(&v.as_str())) { + let t = ink.spot_tint(name); + if t > 0.0 + && let Some(sc) = spot_swatch(doc, name) + { + let s = c.display_rgb(sc); + for i in 0..3 { + rgb[i] *= 1.0 - t * (1.0 - s[i]); + } } } rgb diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 0be9c05f..0ab0c7c4 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1848,9 +1848,15 @@ fn effect_menu() -> Vec { Sep, Item::Header("Vector Effects"), ]; - // Submenus in Illustrator's order. - let order = ["3D and Materials", "Convert to Shape", "Distort & Transform", "Path", "Pathfinder", "Stylize", "SVG Filters", "Warp", "Blur"]; + // Submenus (and Crop Marks, an item of its own) in the reference app's order. + let order = + ["3D and Materials", "Convert to Shape", "Crop Marks", "Distort & Transform", "Path", "Pathfinder", "Stylize", "SVG Filters", "Warp", "Blur"]; + let top_level = |e: &vectorcraft_effects::EffectInfo| e.menu == ["Effect"] && order.contains(&e.label); for sub_name in order { + if let Some(e) = cat.iter().find(|e| top_level(e) && e.label == sub_name) { + out.push(Item::Cmd(e.label, "effect.apply", json!({ "effect": e.id }))); + continue; + } let items: Vec = cat .iter() .filter(|e| e.menu.last().copied() == Some(sub_name)) @@ -1880,7 +1886,7 @@ fn effect_menu() -> Vec { } } // Anything not placed above (future effects) still shows up. - for e in cat.iter().filter(|e| !e.menu.last().is_some_and(|m| order.contains(m))) { + for e in cat.iter().filter(|e| !top_level(e) && !e.menu.last().is_some_and(|m| order.contains(m))) { out.push(Item::Cmd(e.label, "effect.dialog", json!({ "effect": e.id }))); } out diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 92eacec5..be372fd5 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -10,6 +10,7 @@ use std::sync::Arc; use egui::{Color32, Rect, Response, Sense, Shape, Stroke, StrokeKind, Ui, pos2, vec2}; use serde_json::{Value, json}; use vectorcraft_color::libraries::{BuiltinLibrary, GRADIENT_LIBRARIES, SWATCH_LIBRARIES}; +use vectorcraft_color::swatch::REGISTRATION; use vectorcraft_color::{Color, GradientKind, Paint, Swatch, SwatchLibrary}; use vectorcraft_doc::Document; use vectorcraft_engine::cmd::swatchlib; @@ -106,8 +107,6 @@ impl Entry { } } -const REGISTRATION: &str = "[Registration]"; - /// The panel's rows for the kind filter and the find field's `query` (a case-insensitive name /// match; a colour group whose name matches shows all its swatches). fn entries(app: &VectorcraftApp, kind: Kind, query: &str) -> Vec { @@ -148,7 +147,7 @@ fn entries(app: &VectorcraftApp, kind: Kind, query: &str) -> Vec { /// The paint command params that apply entry `e` (`paint.setFill`); none for a colour group. fn swatch_params(e: &Entry) -> Option { Some(match e { - Entry::Registration => json!({"color": {"c": 1.0, "m": 1.0, "y": 1.0, "k": 1.0}}), + Entry::Registration => json!({"swatch": REGISTRATION}), Entry::Swatch { paint, .. } if paint.is_none() => json!({"none": true}), Entry::Swatch { name, .. } => json!({"swatch": name}), Entry::Folder(_) => return None, @@ -369,10 +368,14 @@ fn held(resp: &Response) -> Option> { resp.dnd_hover_payload::().filter(|d| matches!(**d, PanelDrag::Paint { .. })) } -/// The drag a tile or row of `e` starts, moving rows `names`. Registration and colour groups have -/// no paint of their own (the chip under the pointer draws Registration's mark). +/// The drag a tile or row of `e` starts, moving rows `names`. Colour groups have no paint of their +/// own (the chip under the pointer draws Registration's mark for Registration). fn drag_of(e: &Entry, names: Vec) -> PanelDrag { - let paint = if let Entry::Swatch { paint, .. } = e { paint.clone() } else { Paint::None }; + let paint = match e { + Entry::Swatch { paint, .. } => paint.clone(), + Entry::Registration => Paint::registration(), + Entry::Folder(_) => Paint::None, + }; let rows = SwatchRows { grabbed: e.name().to_string(), names, groups: e.is_folder() }; PanelDrag::Paint { paint, params: swatch_params(e).unwrap_or_default(), rows: Some(rows) } } diff --git a/docs/mcp.md b/docs/mcp.md index 0619ceb0..ac3ded6c 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -581,3 +581,18 @@ paths and compound paths, and `path.reverse {reversed?}` makes subpaths run coun {"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"}}} ``` + +## Registration and trim marks + +Every document has the built-in `[Registration]` swatch (listed after None by `swatch.list`): a colour that prints on +every plate, process and spot. `paint.setStroke {swatch: "[Registration]"}` applies it; it can't be edited, moved, +duplicated, merged or deleted. Separations Preview shows it on each plate and PDF export writes it as +`/Separation /All`. `object.createTrimMarks {style?, allArtboards?}` draws trim marks in Registration around the +selection, or around every artboard when nothing is selected; `effect.apply {effect: "cropMarks"}` adds live crop +marks that follow the object. Both use Japanese marks (double lines at the trim and bleed edges, centre marks) when +the preference `japaneseCropMarks` is on or `style: "japanese"` is given. + +```json +{"name":"run_command","arguments":{"command":"prefs.set","params":{"key":"japaneseCropMarks","value":true}}} +{"name":"run_command","arguments":{"command":"object.createTrimMarks","params":{}}} +```