diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 9d4b9cc0..4378dd75 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -12,6 +12,7 @@ pub mod live; pub mod node; pub mod pattern; pub mod selection; +pub mod swatches; pub mod text; use std::collections::BTreeMap; @@ -594,9 +595,6 @@ impl Document { pub fn pattern_mut(&mut self, name: &str) -> Option<&mut PatternDef> { self.patterns.iter_mut().find(|p| p.name == name) } - pub fn swatch(&self, name: &str) -> Option<&Swatch> { - self.swatches.iter().chain(self.swatch_groups.iter().flat_map(|g| g.swatches.iter())).find(|s| s.name == name) - } /// Deep-clone `node` with fresh ids for it and all descendants. pub fn reid(&mut self, node: &Node) -> Node { let mut n = node.clone(); diff --git a/crates/doc/src/swatches.rs b/crates/doc/src/swatches.rs new file mode 100644 index 00000000..dc4c52c0 --- /dev/null +++ b/crates/doc/src/swatches.rs @@ -0,0 +1,279 @@ +//! Swatch lookups across colour groups and the walks over the paints that link to swatches. +//! +//! A swatch lives either in `Document::swatches` or in one of `Document::swatch_groups`; every +//! lookup here covers both. Solid paints link to a global swatch by name (`Paint::Solid.swatch`); +//! [`Document::map_solid_paints`] rewrites those links (and colours) wherever art can hold them, +//! copying only the nodes that change. + +use std::sync::Arc; + +use vectorcraft_color::{Color, Paint, Swatch}; + +use crate::{AppearanceItem, Document, Node, NodeId, NodeKind}; + +impl Document { + /// Every swatch: the ungrouped ones first, then each colour group's in order. + pub fn swatches_iter(&self) -> impl Iterator { + self.swatches.iter().chain(self.swatch_groups.iter().flat_map(|g| g.swatches.iter())) + } + 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())) + } + pub fn swatch(&self, name: &str) -> Option<&Swatch> { + self.swatches_iter().find(|s| s.name == name) + } + pub fn swatch_mut(&mut self, name: &str) -> Option<&mut Swatch> { + self.swatches_iter_mut().find(|s| s.name == name) + } + /// Index of the colour group holding swatch `name` (`None` when ungrouped or missing). + pub fn swatch_group_of(&self, name: &str) -> Option { + self.swatch_groups.iter().position(|g| g.swatches.iter().any(|s| s.name == name)) + } + /// Is `name` used by a swatch or a colour group (they share one namespace)? + pub fn swatch_name_taken(&self, name: &str) -> bool { + self.swatch(name).is_some() || self.swatch_groups.iter().any(|g| g.name == name) + } + /// Remove swatch `name` from wherever it lives. + pub fn remove_swatch(&mut self, name: &str) -> Option { + if let Some(i) = self.swatches.iter().position(|s| s.name == name) { + return Some(self.swatches.remove(i)); + } + let g = self.swatch_group_of(name)?; + let list = &mut self.swatch_groups[g].swatches; + let i = list.iter().position(|s| s.name == name)?; + Some(list.remove(i)) + } + + /// Visit every solid paint (fills, strokes and text runs) in the art, symbol definitions, + /// pattern tiles and graphic styles. `f(colour, link)` may change both and returns true when it + /// did. Only nodes holding a changed paint (and the paths to them) are copied. Returns the + /// number of changed paints. + pub fn map_solid_paints(&mut self, f: &mut dyn FnMut(&mut Color, &mut Option) -> bool) -> usize { + let mut n = map_trees(&mut self.layers, f); + for s in &mut self.symbols { + n += map_tree(&mut s.art, f); + } + for p in &mut self.patterns { + n += map_trees(&mut p.art, f); + } + for gs in &mut self.graphic_styles { + for it in &mut gs.appearance.items { + n += usize::from(map_solid(item_paint_mut(it), f)); + } + } + n + } + + /// [`Document::map_solid_paints`] limited to the subtrees of `ids` (each paint visited once even + /// when an id is inside another). + pub fn map_solid_paints_in(&mut self, ids: &[NodeId], f: &mut dyn FnMut(&mut Color, &mut Option) -> bool) -> usize { + let roots: Vec = + ids.iter().copied().filter(|id| !self.ancestry(*id).is_some_and(|a| a[..a.len() - 1].iter().any(|p| ids.contains(p)))).collect(); + let mut n = 0; + for id in roots { + let Some(node) = self.node(id) else { continue }; + if let Some((new, count)) = map_node(node, f) + && let Some(slot) = self.node_mut(id) + { + *slot = new; + n += count; + } + } + n + } +} + +/// Every colour in the subtree of `n` with the swatch it links to: solid fills, strokes and text +/// runs (with their link) and gradient stops (unlinked), in paint order. +pub fn node_colors(n: &Node, f: &mut dyn FnMut(&Color, Option<&str>)) { + n.walk(&mut |m| { + for p in node_paints(m) { + match p { + Paint::Solid { color, swatch } => f(color, swatch.as_deref()), + Paint::Gradient(g) => g.gradient.stops.iter().for_each(|s| f(&s.color, None)), + _ => {} + } + } + }); +} + +fn item_paint(it: &AppearanceItem) -> &Paint { + match it { + AppearanceItem::Fill(l) => &l.paint, + AppearanceItem::Stroke(l) => &l.paint, + } +} + +fn item_paint_mut(it: &mut AppearanceItem) -> &mut Paint { + match it { + AppearanceItem::Fill(l) => &mut l.paint, + AppearanceItem::Stroke(l) => &mut l.paint, + } +} + +/// The paints a node holds itself (not its children): appearance items, then text runs. +fn node_paints(n: &Node) -> impl Iterator { + let runs = match &n.kind { + NodeKind::Text(t) => t.runs.as_slice(), + _ => &[], + }; + n.appearance.items.iter().map(item_paint).chain(runs.iter().flat_map(|r| [&r.style.fill, &r.style.stroke])) +} + +/// Mutable [`node_paints`], in the same order. +fn node_paints_mut(n: &mut Node) -> impl Iterator { + let runs = match &mut n.kind { + NodeKind::Text(t) => t.runs.as_mut_slice(), + _ => &mut [], + }; + n.appearance.items.iter_mut().map(item_paint_mut).chain(runs.iter_mut().flat_map(|r| [&mut r.style.fill, &mut r.style.stroke])) +} + +/// Apply `f` to a solid paint; true when it changed. +fn map_solid(p: &mut Paint, f: &mut dyn FnMut(&mut Color, &mut Option) -> bool) -> bool { + match p { + Paint::Solid { color, swatch } => f(color, swatch), + _ => false, + } +} + +/// `n` with `f` applied to its subtree's solid paints, or `None` when nothing changed (then nothing +/// was copied). Also returns the number of changed paints. +fn map_node(n: &Node, f: &mut dyn FnMut(&mut Color, &mut Option) -> bool) -> Option<(Node, usize)> { + // Try each own paint on a scratch copy first, so unchanged nodes are never cloned. + let mut changes: Vec<(usize, Color, Option)> = vec![]; + for (i, p) in node_paints(n).enumerate() { + if let Paint::Solid { color, swatch } = p { + let (mut c, mut s) = (*color, swatch.clone()); + if f(&mut c, &mut s) { + changes.push((i, c, s)); + } + } + } + let mut count = changes.len(); + let mut out: Option = None; + if !changes.is_empty() { + let mut m = n.clone(); + let mut changes = changes.into_iter().peekable(); + for (i, p) in node_paints_mut(&mut m).enumerate() { + if changes.peek().is_some_and(|c| c.0 == i) + && let (Some((_, c, s)), Paint::Solid { color, swatch }) = (changes.next(), p) + { + *color = c; + *swatch = s; + } + } + out = Some(m); + } + if let Some(ch) = n.children() { + for (i, c) in ch.iter().enumerate() { + if let Some((new, k)) = map_node(c, f) { + let m = out.get_or_insert_with(|| n.clone()); + if let Some(slot) = m.children_mut().and_then(|v| v.get_mut(i)) { + *slot = Arc::new(new); + } + count += k; + } + } + } + out.map(|m| (m, count)) +} + +fn map_tree(n: &mut Arc, f: &mut dyn FnMut(&mut Color, &mut Option) -> bool) -> usize { + match map_node(n, f) { + Some((new, count)) => { + *n = Arc::new(new); + count + } + None => 0, + } +} + +fn map_trees(v: &mut [Arc], f: &mut dyn FnMut(&mut Color, &mut Option) -> bool) -> usize { + v.iter_mut().map(|n| map_tree(n, f)).sum() +} + +#[cfg(test)] +mod tests { + use vectorcraft_color::SwatchGroup; + use vectorcraft_geom::{Rect, shapes}; + + use super::*; + use crate::Appearance; + + fn linked(color: Color, name: &str) -> Paint { + Paint::Solid { color, swatch: Some(name.into()) } + } + + /// A document with a grouped global swatch "Brand", a rectangle filled with it and an unlinked one. + fn doc() -> (Document, NodeId, NodeId) { + let mut d = Document::new(100.0, 100.0); + let red = Color::rgb(1.0, 0.0, 0.0); + d.swatch_groups.push(SwatchGroup { + name: "Mine".into(), + swatches: vec![Swatch { name: "Brand".into(), paint: Paint::solid(red), global: true, spot: false }], + }); + let layer = d.layers[0].id; + let mut ids = vec![]; + for paint in [linked(red, "Brand"), Paint::solid(red)] { + let id = d.alloc_id(); + let r = shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)); + d.insert(Some(layer), usize::MAX, Node::path(id, r, Appearance::basic(paint, Paint::None, 0.0))).unwrap(); + ids.push(id); + } + (d, ids[0], ids[1]) + } + + #[test] + fn lookups_cover_colour_groups() { + let (mut d, _, _) = doc(); + let total = d.swatches.len() + d.swatch_groups.iter().map(|g| g.swatches.len()).sum::(); + assert_eq!(d.swatches_iter().count(), total); + assert!(d.swatch("Brand").is_some_and(|s| s.global)); + assert_eq!(d.swatch_group_of("Brand").map(|g| d.swatch_groups[g].name.as_str()), Some("Mine")); + d.swatch_mut("Brand").unwrap().spot = true; + assert!(d.swatch("Brand").unwrap().spot); + assert!(d.swatch_name_taken("Mine") && d.swatch_name_taken("Brand") && !d.swatch_name_taken("Nope")); + assert_eq!(d.remove_swatch("Brand").map(|s| s.name), Some("Brand".into())); + assert!(d.swatch("Brand").is_none() && d.remove_swatch("Brand").is_none()); + } + + #[test] + fn mapping_copies_only_changed_nodes() { + let (mut d, a, b) = doc(); + let before = d.clone(); + let blue = Color::rgb(0.0, 0.0, 1.0); + let n = d.map_solid_paints(&mut |c, s| { + if s.as_deref() != Some("Brand") { + return false; + } + *c = blue; + true + }); + assert_eq!(n, 1); + assert_eq!(d.node(a).unwrap().appearance.fill_paint(), linked(blue, "Brand")); + assert_eq!(d.node(b).unwrap().appearance.fill_paint(), Paint::solid(Color::rgb(1.0, 0.0, 0.0))); + let unchanged = |d: &Document| d.layers[0].children().unwrap()[1].clone(); + assert!(Arc::ptr_eq(&unchanged(&d), &unchanged(&before)), "the unlinked rectangle is shared, not copied"); + // Nothing to change: the tree is untouched. + let again = d.clone(); + assert_eq!(d.map_solid_paints(&mut |_, _| false), 0); + assert!(Arc::ptr_eq(&d.layers[0], &again.layers[0])); + } + + #[test] + fn mapping_within_ids_visits_each_paint_once() { + let (mut d, a, b) = doc(); + let layer = d.layers[0].id; + let mut seen = 0; + let n = d.map_solid_paints_in(&[layer, a], &mut |_, s| { + seen += 1; + s.take().is_some() + }); + assert_eq!((n, seen), (1, 2), "the layer covers `a`: each fill is visited once"); + assert_eq!(d.node(a).unwrap().appearance.fill_paint(), Paint::solid(Color::rgb(1.0, 0.0, 0.0))); + let mut colors = vec![]; + node_colors(d.node(b).unwrap(), &mut |c, s| colors.push((*c, s.map(str::to_string)))); + assert_eq!(colors, [(Color::rgb(1.0, 0.0, 0.0), None)]); + } +} diff --git a/crates/engine/src/cmd/colormgmt.rs b/crates/engine/src/cmd/colormgmt.rs index 230ed000..e06b0225 100644 --- a/crates/engine/src/cmd/colormgmt.rs +++ b/crates/engine/src/cmd/colormgmt.rs @@ -257,7 +257,7 @@ fn convert_mode(s: &mut Session, p: &Value) -> Result { } n }); - for sw in &mut d.swatches { + for sw in d.swatches_iter_mut() { match &mut sw.paint { Paint::Solid { color, .. } => *color = conv(color), Paint::Gradient(g) => { @@ -486,14 +486,14 @@ fn set_spot(s: &mut Session, p: &Value) -> Result { const C: &str = "swatch.setSpot"; let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?.to_string(); let spot = bool_or(p, "spot", true); - let sw = s.doc()?.doc.swatches.iter().find(|w| w.name == name).ok_or_else(|| bad(C, format!("no swatch `{name}`")))?; + let sw = s.doc()?.doc.swatch(&name).ok_or_else(|| bad(C, format!("no swatch `{name}`")))?; if !matches!(sw.paint, Paint::Solid { .. }) { return Err(bad(C, "only solid-colour swatches can be spot colours")); } if sw.spot != spot { let n = name.clone(); s.edit("Swatch Options", |d, _| { - if let Some(w) = d.swatches.iter_mut().find(|w| w.name == n) { + if let Some(w) = d.swatch_mut(&n) { w.spot = spot; if spot { w.global = true; diff --git a/crates/engine/src/cmd/docinfo.rs b/crates/engine/src/cmd/docinfo.rs index 8d2cb49d..570dc855 100644 --- a/crates/engine/src/cmd/docinfo.rs +++ b/crates/engine/src/cmd/docinfo.rs @@ -113,7 +113,7 @@ fn info(s: &mut Session, p: &Value) -> Result { "images": images, "symbols": symbols, "spotColors": spot, - "swatches": d.swatches.len(), + "swatches": d.swatches_iter().count(), "graphicStyles": d.graphic_styles.len(), "characterStyles": d.char_styles.len(), "paragraphStyles": d.para_styles.len(), diff --git a/crates/engine/src/cmd/docmenu.rs b/crates/engine/src/cmd/docmenu.rs index fce823ea..b47a74fd 100644 --- a/crates/engine/src/cmd/docmenu.rs +++ b/crates/engine/src/cmd/docmenu.rs @@ -469,7 +469,7 @@ fn color_mode(s: &mut Session, p: &Value) -> Result { for l in &mut d.layers { map_node_colors(Arc::make_mut(l), &f, true, true); } - for sw in &mut d.swatches { + for sw in d.swatches_iter_mut() { if let vectorcraft_color::Paint::Solid { color, .. } = &mut sw.paint { *color = f(*color); } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 020464db..4f49f21e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -733,6 +733,8 @@ mod tests_pattern; #[cfg(test)] mod tests_prefs; #[cfg(test)] +mod tests_swatches; +#[cfg(test)] mod tests_textedit; #[cfg(test)] mod tests_xform; diff --git a/crates/engine/src/tests_swatches.rs b/crates/engine/src/tests_swatches.rs new file mode 100644 index 00000000..2d068906 --- /dev/null +++ b/crates/engine/src/tests_swatches.rs @@ -0,0 +1,76 @@ +//! Swatches: colour groups as first-class swatch homes (colour mode, spot plates, PDF, Document +//! Info), naming and kinds, Swatch Options edits reaching linked art, deleting and new groups. + +use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; + +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_or_else(|e| panic!("{id} {p}: {e}")) +} + +fn rect(s: &mut Session) -> NodeId { + NodeId(run(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}))["id"].as_u64().unwrap()) +} + +fn doc(s: &Session) -> &vectorcraft_doc::Document { + &s.doc().unwrap().doc +} + +/// Put a solid swatch into colour group `group` directly in the model (no command creates one in a +/// group before M3.13). +fn grouped_swatch(s: &mut Session, group: &str, name: &str, color: Color) { + let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let g = d.swatch_groups.iter_mut().find(|g| g.name == group).unwrap(); + g.swatches.push(vectorcraft_color::Swatch { name: name.into(), paint: Paint::solid(color), global: true, spot: false }); +} + +#[test] +fn document_color_mode_converts_grouped_swatches() { + let mut s = session(); + grouped_swatch(&mut s, "Grays", "Ink", Color::rgb(0.2, 0.4, 0.6)); + run(&mut s, "object.convertDocumentColorMode", json!({"mode": "cmyk"})); + for g in &doc(&s).swatch_groups { + for sw in &g.swatches { + assert!(!matches!(sw.paint.color(), Some(Color::Rgb { .. })), "{} in {} is still RGB", sw.name, g.name); + } + } + assert!(matches!(doc(&s).swatch("Ink").unwrap().paint.color(), Some(Color::Cmyk { .. }))); + // The File menu's mode switch converts them as well. + let mut s = session(); + run(&mut s, "file.documentColorMode", json!({"mode": "cmyk"})); + assert!(matches!(doc(&s).swatch("Bright Red").unwrap().paint.color(), Some(Color::Cmyk { .. }))); +} + +#[test] +fn grouped_spot_swatches_print_on_their_own_plate() { + let mut s = session(); + grouped_swatch(&mut s, "Brights", "Signal Orange", Color::cmyk(0.0, 0.6, 1.0, 0.0)); + assert_eq!(run(&mut s, "swatch.setSpot", json!({"name": "Signal Orange"})), json!({"name": "Signal Orange", "spot": true})); + assert!(doc(&s).swatch("Signal Orange").is_some_and(|w| w.spot && w.global)); + let plates = run(&mut s, "color.plates", json!({})); + assert!(plates["plates"].as_array().unwrap().iter().any(|p| p["name"] == "Signal Orange" && p["spot"] == true), "{plates}"); + let a = rect(&mut s); + run(&mut s, "paint.setFill", json!({"ids": [a.0], "swatch": "Signal Orange"})); + let bytes = vectorcraft_pdf::export(doc(&s), &vectorcraft_pdf::PdfOptions { compress: false, created: Some(0), ..Default::default() }).unwrap(); + let pdf = String::from_utf8_lossy(&bytes); + assert!(pdf.contains("/Separation") && pdf.contains("Signal#20Orange"), "a grouped spot swatch is a Separation"); + let info = run(&mut s, "document.info", json!({})); + assert_eq!(info["spotColors"], json!(["Signal Orange"])); +} + +#[test] +fn document_info_counts_grouped_swatches() { + let mut s = session(); + let d = doc(&s); + let all = d.swatches.len() + d.swatch_groups.iter().map(|g| g.swatches.len()).sum::(); + assert!(all > d.swatches.len()); + assert_eq!(run(&mut s, "document.info", json!({}))["swatches"], json!(all)); +} diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 20cea8e3..bcb5c847 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -215,9 +215,7 @@ impl Exporter<'_> { /// swatch's CMYK equivalent is the alternate space). fn solid(&mut self, c: &Color, swatch: Option<&str>) -> krilla::color::Color { use krilla::color::separation::{Color as SepColor, SeparationColorant, SeparationSpace}; - let spot = swatch - .and_then(|n| self.doc.swatches.iter().find(|s| s.spot && s.name == n)) - .and_then(|s| s.paint.color().map(|sc| (s.name.clone(), sc))); + let spot = swatch.and_then(|n| self.doc.swatch(n).filter(|s| s.spot)).and_then(|s| s.paint.color().map(|sc| (s.name.clone(), sc))); let Some((name, sc)) = spot else { return self.col(c) }; let cms = vectorcraft_color::cms::active(); let intent = cms.settings().intent; diff --git a/crates/render/src/proof.rs b/crates/render/src/proof.rs index 7d51b31c..22ac9629 100644 --- a/crates/render/src/proof.rs +++ b/crates/render/src/proof.rs @@ -58,7 +58,7 @@ pub fn plates(doc: &Document) -> Vec { Plate { name: (*n).into(), spot: false, rgb: c.cmyk_to_srgb(ink, false) } }) .collect(); - for sw in &doc.swatches { + for sw in doc.swatches_iter() { if let (true, Paint::Solid { color, .. }) = (sw.spot, &sw.paint) && !v.iter().any(|p| p.name == sw.name) { @@ -69,7 +69,7 @@ pub fn plates(doc: &Document) -> Vec { } fn spot_swatch<'a>(doc: &'a Document, name: &str) -> Option<&'a Color> { - doc.swatches.iter().find(|s| s.spot && s.name == name).and_then(|s| match &s.paint { + doc.swatch(name).filter(|s| s.spot).and_then(|s| match &s.paint { Paint::Solid { color, .. } => Some(color), _ => None, })