diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index e8706b1c2..c85fe7e37 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -100,7 +100,31 @@ pub enum ItemKind { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct LibraryColor { pub name: String, + /// The colour; a spot colour's full-strength ink (its swatch's colour), without the tint. pub color: Color, + /// A spot colour's ink (its swatch's name): using it adds the document's spot swatch of that + /// name and links the paint to it (#5812). Files written before spot colours have none. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub spot: Option, + /// A spot colour's tint (0..1; 1 for a process colour). + #[serde(default = "full_tint", skip_serializing_if = "is_full_tint")] + pub tint: f32, +} + +fn full_tint() -> f32 { + 1.0 +} + +fn is_full_tint(t: &f32) -> bool { + *t >= 1.0 +} + +/// A library colour as `library.add` reads it from the selection or params. +struct NewColor { + name: String, + color: Color, + spot: Option, + tint: f32, } /// A graphic: the art as a native document (its own resources included) and its thumbnail. @@ -369,6 +393,13 @@ impl Library { fn sanitize(&mut self) { clip(&mut self.name); self.colors.truncate(MAX_ITEMS); + for c in &mut self.colors { + if let Some(ink) = &mut c.spot { + clip(ink); + } + c.spot = c.spot.take().filter(|ink| !ink.trim().is_empty()); + c.tint = if c.spot.is_some() && c.tint.is_finite() { c.tint.clamp(0.0, 1.0) } else { 1.0 }; + } self.gradients.retain_mut(|g| clean_gradient(&mut g.gradient)); self.gradients.truncate(MAX_ITEMS); self.patterns.truncate(MAX_ITEMS); @@ -598,7 +629,7 @@ pub fn specs() -> Vec { "Library", [], None, - "{library?: id or name (default: the current library)} → {id, name, graphics: [{id, name, width, height, thumbnail: PNG base64}], colors: [{name, hex, color}], gradients: [{name, gradient: {gradient: {kind, stops}, angle}}], patterns: [{name, pattern: its name in the item, thumbnail: PNG base64}], charStyles: [{name, attrs}], paraStyles: [{name, attrs}], groups: [{name, items: [{kind: color|gradient|pattern|charStyle|paraStyle|graphic, item: name (graphic: id)}]}] (an item is in one group at most; the others are ungrouped)}", + "{library?: id or name (default: the current library)} → {id, name, graphics: [{id, name, width, height, thumbnail: PNG base64}], colors: [{name, hex, color, spot: ink name or null, tint: 0..1}], gradients: [{name, gradient: {gradient: {kind, stops}, angle}}], patterns: [{name, pattern: its name in the item, thumbnail: PNG base64}], charStyles: [{name, attrs}], paraStyles: [{name, attrs}], groups: [{name, items: [{kind: color|gradient|pattern|charStyle|paraStyle|graphic, item: name (graphic: id)}]}] (an item is in one group at most; the others are ungrouped)}", always, get ), @@ -643,7 +674,7 @@ pub fn specs() -> Vec { "Add to Library", [], None, - "{library?: id or name (default: the current library, made when there is none), kind: graphic|fillColor|strokeColor|fillGradient|strokeGradient|fillPattern|strokePattern|charStyle|paraStyle, ids?: objects (default: the selection), name?, color?: colour (a colour to add, instead of the selection's; #rrggbbaa and #rgba are refused), swatch?: a gradient or pattern swatch of the document (fillGradient…strokePattern: added instead of the selection's paint), group?: name, before?: item | after?: item (an item of the same kind)} add an item from the selection: graphic: a copy of the objects with every resource they use; fillColor/strokeColor: the first selected object's solid fill/stroke colour (else the default fill/stroke); fillGradient/strokeGradient: the first selected object's fill/stroke gradient (else the default's), without its placement or swatch links, named after its swatch, else \"Gradient\"; fillPattern/strokePattern: the first selected object's fill/stroke pattern (else the default's) with the resources its art uses, named after it; charStyle/paraStyle: the selected text's character/paragraph attributes (the Type tool's range, else the first selected text object). A new item goes into `group` (default: none) and, with `before` or `after`, just before or after that item in the order its kind is shown in, as dropping it among the Libraries panel's items does; an item the library already has (same kind and contents) isn't added again, nor moved → {library, kind, name, id?: graphic id, existing: bool, group?: name or null (a new item's)}", + "{library?: id or name (default: the current library, made when there is none), kind: graphic|fillColor|strokeColor|fillGradient|strokeGradient|fillPattern|strokePattern|charStyle|paraStyle, ids?: objects (default: the selection), name?, color?: colour (a colour to add, instead of the selection's; #rrggbbaa and #rgba are refused), swatch?: a gradient or pattern swatch of the document (fillGradient…strokePattern: added instead of the selection's paint; with `color`, the spot swatch it is, kept as a spot colour), tint?: 0..100 (with `color` and a spot `swatch`: its %), group?: name, before?: item | after?: item (an item of the same kind)} add an item from the selection: graphic: a copy of the objects with every resource they use; fillColor/strokeColor: the first selected object's solid fill/stroke colour (else the default fill/stroke), a colour linked to a spot swatch kept as a spot colour (the swatch's name, its colour and the tint); fillGradient/strokeGradient: the first selected object's fill/stroke gradient (else the default's), without its placement or swatch links, named after its swatch, else \"Gradient\"; fillPattern/strokePattern: the first selected object's fill/stroke pattern (else the default's) with the resources its art uses, named after it; charStyle/paraStyle: the selected text's character/paragraph attributes (the Type tool's range, else the first selected text object). A new item goes into `group` (default: none) and, with `before` or `after`, just before or after that item in the order its kind is shown in, as dropping it among the Libraries panel's items does; an item the library already has (same kind and contents) isn't added again, nor moved → {library, kind, name, id?: graphic id, existing: bool, group?: name or null (a new item's)}", has_doc, add ), @@ -652,7 +683,7 @@ pub fn specs() -> Vec { "Use Library Item", [], None, - "{library?, kind, item: name (graphic: id or name), center?: [x, y] (graphic; default: the first artboard's centre), to?: fill|stroke (colors, gradients, patterns; default: the kind's), recorded?: the item as it was when used (recorded in the journal so a replay matches)} graphic: place a copy centred on `center`, its resources pasted along, selected, one undo step; colors and gradients: paint the selection (and the defaults) as paint.setFill/paint.setStroke, a gradient fitting each object; patterns: add the pattern and its swatch to the document (a name the document gives another pattern gets a number; the same pattern is reused) and paint the selection (and the defaults) with it, one undo step; charStyle/paraStyle: add the style to the document (a name taken by different attributes gets a number) and apply it to the selected text, one undo step → {kind, ids?, style?}", + "{library?, kind, item: name (graphic: id or name), center?: [x, y] (graphic; default: the first artboard's centre), to?: fill|stroke (colors, gradients, patterns; default: the kind's), recorded?: the item as it was when used (recorded in the journal so a replay matches)} graphic: place a copy centred on `center`, its resources pasted along, selected, one undo step; colors and gradients: paint the selection (and the defaults) as paint.setFill/paint.setStroke, a gradient fitting each object; a spot color links to the document's spot swatch of its ink's name, added when the document has none of that colour (a name taken by another colour gets a number), one undo step; patterns: add the pattern and its swatch to the document (a name the document gives another pattern gets a number; the same pattern is reused) and paint the selection (and the defaults) with it, one undo step; charStyle/paraStyle: add the style to the document (a name taken by different attributes gets a number) and apply it to the selected text, one undo step → {kind, ids?, style?}", has_doc, use_item ), @@ -782,7 +813,7 @@ fn get(s: &mut Session, p: &Value) -> Result { "id": id, "name": lib.name, "graphics": lib.graphics.iter().map(|g| json!({"id": g.id, "name": g.name, "width": g.width, "height": g.height, "thumbnail": g.thumbnail})).collect::>(), - "colors": lib.colors.iter().map(|c| json!({"name": c.name, "hex": c.color.to_hex(), "color": c.color})).collect::>(), + "colors": lib.colors.iter().map(|c| json!({"name": c.name, "hex": c.color.to_hex(), "color": c.color, "spot": c.spot, "tint": c.tint})).collect::>(), "gradients": lib.gradients.iter().map(|g| json!({"name": g.name, "gradient": g.gradient})).collect::>(), "patterns": lib.patterns.iter().map(|p| json!({"name": p.name, "pattern": p.pattern, "thumbnail": p.thumbnail})).collect::>(), "charStyles": lib.char_styles.iter().map(|t| json!({"name": t.name, "attrs": t.attrs})).collect::>(), @@ -856,13 +887,14 @@ fn add(s: &mut Session, p: &Value) -> Result { lib.graphics.push(g); placed(lib, &place, ItemRef { kind: item_kind, item: key }, v)? } - Item::Color((name, color)) => { + Item::Color(NewColor { name, color, spot, tint }) => { full(lib.colors.len())?; - if let Some(old) = lib.colors.iter().find(|o| o.color == color) { + // A spot colour is its ink: one of another ink (or a process colour) is another item. + if let Some(old) = lib.colors.iter().find(|o| o.color == color && o.spot == spot && o.tint == tint) { return Ok(json!({ "name": old.name, "existing": true })); } let name = unique(&given.clone().unwrap_or(name), |n| lib.colors.iter().any(|o| o.name == n)); - lib.colors.push(LibraryColor { name: name.clone(), color }); + lib.colors.push(LibraryColor { name: name.clone(), color, spot, tint }); placed(lib, &place, ItemRef { kind: item_kind, item: name.clone() }, json!({ "name": name, "existing": false }))? } Item::Gradient(mut g) => { @@ -925,7 +957,7 @@ fn placed(lib: &mut Library, place: &Placement, r: ItemRef, mut v: Value) -> Res enum Item { Graphic(LibraryGraphic), - Color((String, Color)), + Color(NewColor), Gradient(LibraryGradient), Pattern(LibraryPattern), Style(bool, Map), @@ -963,23 +995,38 @@ fn graphic_of(s: &Session, p: &Value, cmd: &str) -> Result { } /// The first selected object's solid fill (`stroke`: stroke) colour, else the default one, with a -/// name: its swatch's, else its hex. -fn color_of(s: &Session, p: &Value, stroke: bool, cmd: &str) -> Result<(String, Color)> { - // A colour given (a swatch dropped on the panel). +/// name: its swatch's, else its hex. A colour linked to a spot swatch keeps the ink (the swatch's +/// name and full-strength colour) and its tint. +fn color_of(s: &Session, p: &Value, stroke: bool, cmd: &str) -> Result { + // A colour given (a swatch dropped on the panel), with its spot swatch's name when it has one. if let Some(c) = p.get("color").filter(|v| !v.is_null()) { let color = color_value(c).ok_or_else(|| bad(cmd, "bad colour"))?; - return Ok((color.to_hex(), color)); + let swatch = str_param(p, "swatch").map(str::to_string); + let tint = super::paint::tint_param(p, cmd)?.unwrap_or(1.0); + let given = NewColor { name: color.to_hex(), color, spot: None, tint: 1.0 }; + return Ok(spot_color(s, given, swatch, tint)); } match selected_paint(s, p, stroke)? { - Paint::Solid { color, swatch, .. } => { - let name = swatch.filter(|n| !n.is_empty()).unwrap_or_else(|| color.to_hex()); - Ok((name, color)) + Paint::Solid { color, swatch, tint } => { + let swatch = swatch.filter(|n| !n.is_empty()); + let name = swatch.clone().unwrap_or_else(|| color.to_hex()); + Ok(spot_color(s, NewColor { name, color, spot: None, tint: 1.0 }, swatch, tint)) } Paint::None => Err(bad(cmd, if stroke { "the selection has no stroke colour" } else { "the selection has no fill colour" })), _ => Err(bad(cmd, "only solid colours can be added (not gradients or patterns)")), } } +/// `c` as the spot colour of the active document's spot swatch `swatch` at `tint` (0..1), when +/// there is one: named after it, with its full-strength colour; else `c` as it is. +fn spot_color(s: &Session, c: NewColor, swatch: Option, tint: f32) -> NewColor { + let Some(doc) = s.active().map(|st| &st.doc) else { return c }; + let Some(name) = swatch else { return c }; + let Some(ink) = doc.swatch(&name).filter(|w| w.spot).and_then(|w| w.paint.color()) else { return c }; + let tint = if tint.is_finite() { tint.clamp(0.0, 1.0) } else { 1.0 }; + NewColor { name: name.clone(), color: ink, spot: Some(name), tint } +} + /// The first selected object's (or `ids`') fill (`stroke`: stroke) paint (type's: its first /// character's, as the proxy shows it), else the default one. fn selected_paint(s: &Session, p: &Value, stroke: bool) -> Result { @@ -1102,7 +1149,10 @@ fn use_item(s: &mut Session, p: &Value) -> Result { } Kind::FillColor | Kind::StrokeColor => { let c = lib.colors.iter().find(|c| c.name == item).ok_or_else(missing)?; - json!({ "color": c.color }) + match &c.spot { + Some(ink) => json!({ "color": c.color, "spot": ink, "tint": c.tint }), + None => json!({ "color": c.color }), + } } Kind::FillGradient | Kind::StrokeGradient => { let g = lib.gradients.iter().find(|g| g.name == item).ok_or_else(missing)?; @@ -1145,7 +1195,13 @@ fn use_item(s: &mut Session, p: &Value) -> Result { Kind::FillColor | Kind::StrokeColor => { let stroke = to_stroke(p, kind)?; let color = data.get("color").cloned().ok_or_else(|| bad(C, "no colour"))?; - s.execute(if stroke { "paint.setStroke" } else { "paint.setFill" }, &json!({ "color": color }))?; + let set = if stroke { "paint.setStroke" } else { "paint.setFill" }; + match str_param(&data, "spot").map(str::trim).filter(|n| !n.is_empty()) { + Some(ink) => use_spot(s, set, ink, &color, &data)?, + None => { + s.execute(set, &json!({ "color": color }))?; + } + } Ok(json!({ "kind": if stroke { "strokeColor" } else { "fillColor" } })) } Kind::FillGradient | Kind::StrokeGradient => { @@ -1187,6 +1243,32 @@ fn use_item(s: &mut Session, p: &Value) -> Result { } } +/// Paint with library spot colour `ink` (`color` its full-strength colour, `data`'s `tint` its +/// tint) through `set` (paint.setFill or paint.setStroke), linked to the document's spot swatch of +/// that name: the one it has when it is the same colour, else a new one (a name taken by another +/// colour gets a number), one undo step (#5812). +fn use_spot(s: &mut Session, set: &str, ink: &str, color: &Value, data: &Value) -> Result<()> { + const C: &str = "library.use"; + let c = color_value(color).ok_or_else(|| bad(C, "the library item is damaged"))?; + let tint = data.get("tint").and_then(Value::as_f64).filter(|t| t.is_finite()).map_or(1.0, |t| t.clamp(0.0, 1.0)); + let doc = &s.doc()?.doc; + let have = doc.swatch(ink).is_some_and(|w| w.spot && w.paint.color() == Some(c)); + in_one_step(s, |s| { + let swatch = if have { + ink.to_string() + } else { + let r = s.execute("swatch.new", &json!({ "name": ink, "color": color, "spot": true, "keepModel": true }))?; + r.get("name").and_then(Value::as_str).ok_or_else(|| bad(C, "the spot colour couldn't be added"))?.to_string() + }; + let mut p = json!({ "swatch": swatch }); + if tint < 1.0 { + p["tint"] = json!(tint * 100.0); + } + s.execute(set, &p)?; + Ok(()) + }) +} + /// The `to` param of `library.use`: whether the item paints the stroke (default: the kind's). fn to_stroke(p: &Value, kind: Kind) -> Result { match str_param(p, "to") { diff --git a/crates/engine/src/tests/library.rs b/crates/engine/src/tests/library.rs index a9bafc540..d232b2676 100644 --- a/crates/engine/src/tests/library.rs +++ b/crates/engine/src/tests/library.rs @@ -111,6 +111,69 @@ fn colours_come_from_the_selection_and_paint_it() { assert!(s.execute("library.use", &json!({"library": lib, "kind": "fillColor", "item": "#nope"})).is_err()); } +/// The spot swatch of object `id`'s fill: its name, whether it is spot, the paint's tint. +fn spot_of(s: &Session, id: &Value) -> (String, bool, f32) { + match paint_of(s, id, false) { + vectorcraft_color::Paint::Solid { swatch: Some(name), tint, .. } => { + let spot = s.doc().unwrap().doc.swatch(&name).is_some_and(|w| w.spot); + (name, spot, tint) + } + other => panic!("not linked to a swatch: {other:?}"), + } +} + +/// A spot colour added to a library keeps its ink, kept in the library's file: used in a new +/// document (or after its own was closed) it adds the spot swatch of that name and colour and +/// links the paint to it, at its tint; one the document has already is reused (#5812). +#[test] +fn a_spot_colour_stays_spot_in_another_document() { + let dir = std::env::temp_dir().join(format!("vc-libraries-spot-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let folder = dir.to_string_lossy().to_string(); + let mut s = session(); + s.libraries.set_dir(Some(folder.clone())); + let lib = run(&mut s, "library.create", json!({"name": "Inks"}))["id"].as_str().unwrap().to_string(); + let ink = vectorcraft_color::Color::cmyk(0.0, 1.0, 0.0, 0.0); + run(&mut s, "swatch.new", json!({"name": "Kiss-cut", "color": ink, "spot": true})); + let a = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20}))["id"].clone(); + run(&mut s, "paint.setFill", json!({"swatch": "Kiss-cut", "ids": [a]})); + run(&mut s, "select.set", json!({"ids": [a]})); + assert_eq!(run(&mut s, "library.add", json!({"library": lib, "kind": "fillColor"}))["name"], "Kiss-cut"); + // A swatch dropped on the panel: its colour with its spot swatch's name and tint. + let r = run(&mut s, "library.add", json!({"library": lib, "kind": "fillColor", "color": ink, "swatch": "Kiss-cut", "tint": 40, "name": "Light"})); + assert_eq!(r["existing"], false, "a tint is another colour"); + // The same colour as a process colour is another item. + run(&mut s, "paint.setFill", json!({"color": ink, "ids": [a]})); + assert_eq!(run(&mut s, "library.add", json!({"library": lib, "kind": "fillColor"}))["existing"], false); + run(&mut s, "file.close", json!({})); + + // Another session reads the library's file into a new document. + let mut t = session(); + t.libraries.set_dir(Some(folder)); + let got = run(&mut t, "library.get", json!({"library": "Inks"})); + assert_eq!(got["colors"][0]["spot"], "Kiss-cut"); + assert_eq!(got["colors"][2]["spot"], Value::Null); + let b = run(&mut t, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20}))["id"].clone(); + run(&mut t, "select.set", json!({"ids": [b]})); + run(&mut t, "library.use", json!({"library": "Inks", "kind": "fillColor", "item": "Kiss-cut"})); + assert_eq!(spot_of(&t, &b), ("Kiss-cut".to_string(), true, 1.0)); + let doc = &t.doc().unwrap().doc; + assert_eq!(doc.swatch("Kiss-cut").unwrap().paint.color(), Some(ink), "the ink keeps its values"); + assert!(doc.spot_names().contains(&"Kiss-cut".to_string())); + // Its tint links to the same swatch: none is added again. + let swatches = doc.swatches.len(); + run(&mut t, "library.use", json!({"library": "Inks", "kind": "fillColor", "item": "Light"})); + let (name, spot, tint) = spot_of(&t, &b); + assert_eq!((name.as_str(), spot), ("Kiss-cut", true)); + assert!((tint - 0.4).abs() < 1e-4, "{tint}"); + assert_eq!(t.doc().unwrap().doc.swatches.len(), swatches); + // One undo step takes back the new swatch along with the paint. + run(&mut t, "edit.undo", json!({})); + run(&mut t, "edit.undo", json!({})); + assert!(t.doc().unwrap().doc.swatch("Kiss-cut").is_none()); + let _ = std::fs::remove_dir_all(&dir); +} + /// The fill (`stroke`: stroke) paint of object `id`. fn paint_of(s: &Session, id: &Value, stroke: bool) -> vectorcraft_color::Paint { let n = s.doc().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().clone(); diff --git a/crates/ui-egui/src/panels/libraries.rs b/crates/ui-egui/src/panels/libraries.rs index 2439ba8f2..33bcbb047 100644 --- a/crates/ui-egui/src/panels/libraries.rs +++ b/crates/ui-egui/src/panels/libraries.rs @@ -130,13 +130,22 @@ fn grabbed(drag: &PanelDrag, library: &str) -> Option { } /// The kind and `library.add` params of what `drag` holds, to add to library `library`: a color -/// (not one of that library's own), or a gradient or pattern swatch, by its name. +/// (not one of that library's own; a spot colour with its swatch's name), or a gradient or pattern +/// swatch, by its name. fn swatch_add(drag: &PanelDrag, library: &str) -> Option<(ItemKind, Value)> { let PanelDrag::Paint { paint, params, library: from, .. } = drag else { return None }; let swatch = || params.get("swatch").and_then(Value::as_str); match paint { Paint::Solid { color, .. } if from.as_ref().is_none_or(|(l, _)| l != library) => { - Some((ItemKind::Color, json!({ "kind": "fillColor", "color": color }))) + // A spot swatch's name (and tint) come along, so the library keeps the ink. + let mut add = json!({ "kind": "fillColor", "color": color }); + if let Some(s) = swatch() { + add["swatch"] = json!(s); + if let Some(t) = params.get("tint") { + add["tint"] = t.clone(); + } + } + Some((ItemKind::Color, add)) } Paint::Gradient(_) => swatch().map(|s| (ItemKind::Gradient, json!({ "kind": "fillGradient", "swatch": s }))), Paint::Pattern { .. } => swatch().map(|s| (ItemKind::Pattern, json!({ "kind": "fillPattern", "swatch": s }))), diff --git a/crates/ui-egui/src/panels/libraries/tests.rs b/crates/ui-egui/src/panels/libraries/tests.rs index 64c354349..5f7f575c8 100644 --- a/crates/ui-egui/src/panels/libraries/tests.rs +++ b/crates/ui-egui/src/panels/libraries/tests.rs @@ -375,6 +375,29 @@ fn gradients_and_patterns_come_from_swatches_and_paint_the_selection() { assert!(matches!(fill(&app), Paint::Pattern { ref pattern, .. } if pattern == "Checks"), "{:?}", fill(&app)); } +/// A spot swatch dropped on the list (as the Swatches panel drags it, with its name) is added as +/// a spot colour of that ink, at its tint (#5812). +#[test] +fn a_spot_swatch_dropped_on_the_list_stays_spot() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.run("library.create", json!({"name": "Brand"})).unwrap(); + app.run("swatch.new", json!({"name": "Kiss-cut", "color": "#ff00ff", "spot": true})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + crate::panels::set_pstate(&ctx, LIST_VIEW, true); + let (texts, _) = frame_at(&mut app, &ctx, 600.0, vec![]); + let into = at(&texts, "Brand").center() + vec2(0.0, 80.0); + for tint in [1.0, 0.5] { + let paint = app.session.active().unwrap().doc.tint_paint("Kiss-cut", tint).unwrap(); + let drag = PanelDrag::Paint { params: crate::panels::paint_params(&paint), paint, rows: None, library: None }; + drop_at(&mut app, &ctx, drag, into); + } + let lib = app.session.libraries.get("Brand").unwrap(); + let inks: Vec<_> = lib.colors.iter().map(|c| (c.spot.as_deref(), c.tint)).collect(); + assert_eq!(inks, [(Some("Kiss-cut"), 1.0), (Some("Kiss-cut"), 0.5)]); +} + /// New Group… and Rename Group… ask for a name and act on the library and group they came /// from; the dialog shows only the name. #[test] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 09d8d6f66..8855b0e5a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (Eyedropper samples a single visible stroke without rebuilding its path; behavior, scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (Libraries panel colours keep their spot ink in every document, #5812; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) ## Feature submission evidence @@ -31,6 +31,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-05 | minor | Libraries panel: a spot colour added to a library (from the selection or dragged from the Swatches panel) keeps its ink name, values and tint in the library file; used in any document, also a new one or after the original was closed, it links the paint to that document's spot swatch of the same name, adding it when the document has none of that colour, in one undo step (#5812). Test: `tests::library::a_spot_colour_stays_spot_in_another_document`. Scores and estimates unchanged. | | 2026-11-05 | minor | Eyedropper colour sampling from a path or compound path with one visible stroke no longer rebuilds its path merely to choose among stroke layers. Multiple strokes still sample the topmost one that reaches the point; colours, scores and estimates are unchanged. | | 2026-11-05 | minor | Adding a selected colour, gradient or pattern to a library measures the source object's geometry once rather than twice. Gradient placement, library contents, scores and estimates are unchanged. | | 2026-11-05 | minor | Large drawings of solid-colour or pattern-filled paths and compound paths export to SVG faster when their unused geometric bounds are skipped. Gradients and outside-aligned stroke masks retain their placement; SVG output, scores and estimates are unchanged. | @@ -1131,7 +1132,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Several text styles at once:** in the Character Styles and Paragraph Styles panels, Shift-click highlights a range of styles and Ctrl/Cmd-click adds or removes one, without applying them; the panel menu's Select All Unused (`charStyle.unused`, `paraStyle.unused`) highlights the styles no type uses (in the layers, opacity masks, symbols, graph designs, pattern tiles and the art of brushes), never the Normal style; Delete, the button or the menu item, deletes every highlighted style but Normal in one undo step (`charStyle.delete {names}`, `paraStyle.delete {names}`). The menu's Duplicate copies every highlighted style in one undo step and highlights the copies. A highlighted row dragged onto Create New Style or Delete carries every highlighted style: dropped on Create New Style it copies them all and highlights the copies, and dropped on Delete it deletes them all but Normal and clears the highlight; a row from outside the highlight carries only its own style. Public reference: [Character and paragraph styles](https://web.archive.org/web/20261015081534/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). - **Neutral wording:** labels, MCP tool text, docs and packaging use neutral names, and `cargo xtask brands` (part of `cargo xtask ci`) fails on vendor names. - **Colour, type and file workflows:** Recolor Artwork (dialog with harmonies), Edit Colors, Find & Replace, Change Case, Smart Punctuation, Guides, Lock/Hide Above, Transform Each, Rasterize. - The Libraries panel holds gradients and patterns: its + button adds the selection's fill or stroke gradient or pattern, a gradient or pattern swatch dragged from the Swatches panel is added, and a click paints the selection through the active Fill/Stroke proxy, a pattern bringing its definition and swatch into the document. + The Libraries panel holds gradients and patterns: its + button adds the selection's fill or stroke gradient or pattern, a gradient or pattern swatch dragged from the Swatches panel is added, and a click paints the selection through the active Fill/Stroke proxy, a pattern bringing its definition and swatch into the document. A spot colour stays a spot colour: the library keeps its ink name and tint, and using it in any document links the paint to that document's spot swatch of that name, adding it when missing (#5812). Object › Lock › All Artwork Above and Object › Hide › All Artwork Above deselect the selected objects they lock or hide, the key object included. Object › Hide › Unselected Artwork (Cmd/Ctrl+Alt+Shift+3, `object.hide.unselected`) hides, in one undo step, every object Select All takes that is not selected and holds no selected object; the members of a group that holds a selected object are hidden one by one, and Object › Show All shows them again. In isolation mode it hides only unselected objects inside the isolated object. Public reference: [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html), "Hide unselected artwork". Cmd/Ctrl+Alt+Shift+2 (`object.lock.unselected`) locks unselected artwork in one undo step and has no Object › Lock menu item. It locks unselected members of partly selected groups, clipping groups and compound paths, and unselected guides while Lock Guides is on. In ordinary isolation mode it locks only unselected art inside the isolated object. Object › Unlock All unlocks all locked art in the document, or inside the isolated object in isolation mode. The [public shortcut page](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) calls the action "Lock all deselected artwork".