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/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 0c676e3b..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;
@@ -43,6 +44,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/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/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/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 7f2508cc..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) {
@@ -147,6 +161,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/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/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