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":{}}} +```