diff --git a/crates/engine/src/cmd/colormgmt.rs b/crates/engine/src/cmd/colormgmt.rs index 9b3378fc..a71a404a 100644 --- a/crates/engine/src/cmd/colormgmt.rs +++ b/crates/engine/src/cmd/colormgmt.rs @@ -111,7 +111,7 @@ pub fn specs() -> Vec { "Overprint Black…", ["Edit", "Edit Colors"], None, - "{remove?: false, percentage?: 100, ids?} make the black fills and strokes (black-only ink ≥ percentage) of the selection (or ids; groups: their contents) overprint, or stop (remove) → {changed: objects}", + "{remove?: false, percentage?: 100, fill?: true, stroke?: true, includeCmyBlacks?: false, includeSpotBlacks?: false, ids?} make the black fills and/or strokes of the selection (or ids; groups: their contents; type: its characters) overprint, or stop (remove). Black: K ≥ percentage with no C, M or Y (any with includeCmyBlacks), not linked to a spot swatch (unless includeSpotBlacks); a gradient is black when every stop is → {changed: objects}", has_doc, super::overprint::overprint_black ), diff --git a/crates/engine/src/cmd/overprint.rs b/crates/engine/src/cmd/overprint.rs index 60fda0cf..eae8acb3 100644 --- a/crates/engine/src/cmd/overprint.rs +++ b/crates/engine/src/cmd/overprint.rs @@ -189,7 +189,10 @@ pub(crate) fn overprint_black(s: &mut Session, p: &Value) -> Result { let op = OverprintBlack { remove: bool_or(p, "remove", false), min_k: (f64_or(p, "percentage", 100.0) / 100.0).clamp(0.0, 1.0) as f32, - ..Default::default() + fill: bool_or(p, "fill", true), + stroke: bool_or(p, "stroke", true), + rich: bool_or(p, "includeCmyBlacks", false), + spot: bool_or(p, "includeSpotBlacks", false), }; let ids = match ids_param(p, "ids") { Some(v) => v, diff --git a/crates/engine/src/tests_overprint.rs b/crates/engine/src/tests_overprint.rs index 81570e6f..78950a83 100644 --- a/crates/engine/src/tests_overprint.rs +++ b/crates/engine/src/tests_overprint.rs @@ -192,3 +192,63 @@ fn old_documents_with_an_overprint_black_list_still_preview() { let back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap(); assert_eq!(back.node(k).unwrap().appearance, n.appearance); } + +fn overprints(s: &Session, id: NodeId) -> (bool, bool) { + let a = &node(s, id).appearance; + (a.fill().unwrap().overprint, a.stroke().unwrap().overprint) +} + +#[test] +fn overprint_black_options() { + let mut s = session(); + let k = rect(&mut s, [0.0, 0.0, 10.0, 10.0], black_ink(), Some(black_ink()), 1.0); + let rich = rect(&mut s, [20.0, 0.0, 10.0, 10.0], ink(60, 40, 40, 100), Some(ink(0, 0, 0, 80)), 1.0); + let rgb = rect(&mut s, [40.0, 0.0, 10.0, 10.0], json!("#000000"), Some(cyan_ink()), 1.0); + let ids = json!([k.0, rich.0, rgb.0]); + + let r = s.execute("edit.colors.overprintBlack", &json!({"ids": ids, "fill": false})).unwrap(); + assert_eq!(r["changed"], 1); + assert_eq!(overprints(&s, k), (false, true), "fill: false marks strokes only"); + s.execute("edit.colors.overprintBlack", &json!({"ids": ids})).unwrap(); + assert_eq!(overprints(&s, k), (true, true)); + assert_eq!(overprints(&s, rich), (false, false), "rich black and 80% K stay out at 100%"); + assert_eq!(overprints(&s, rgb), (false, false), "RGB black is not black ink"); + + let before = undo_len(&s); + s.execute("edit.colors.overprintBlack", &json!({"ids": ids, "includeCmyBlacks": true})).unwrap(); + assert_eq!(overprints(&s, rich), (true, false), "rich black included with CMY"); + assert_eq!(undo_len(&s), before + 1, "one undo step"); + s.execute("edit.colors.overprintBlack", &json!({"ids": ids, "percentage": 75})).unwrap(); + assert_eq!(overprints(&s, rich), (true, true), "80% K at 75%"); + + s.execute("edit.colors.overprintBlack", &json!({"ids": ids, "remove": true, "stroke": false, "includeCmyBlacks": true})).unwrap(); + assert_eq!((overprints(&s, k), overprints(&s, rich)), ((false, true), (false, true)), "remove from fills only"); + let before = undo_len(&s); + let r = s.execute("edit.colors.overprintBlack", &json!({"ids": ids, "remove": true, "stroke": false})).unwrap(); + assert_eq!((r["changed"].clone(), undo_len(&s)), (json!(0), before), "nothing to change, no undo step"); +} + +#[test] +fn overprint_black_covers_type_gradients_spots_and_groups() { + let mut s = session(); + let t = NodeId(s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Hi"})).unwrap()["id"].as_u64().unwrap()); + s.execute("paint.setFill", &json!({"ids": [t.0], "color": black_ink()})).unwrap(); + let g = rect(&mut s, [0.0, 0.0, 10.0, 10.0], black_ink(), None, 0.0); + let stops = json!([{"offset": 0, "color": black_ink()}, {"offset": 1, "color": ink(50, 0, 0, 100)}]); + s.execute("paint.setFill", &json!({"ids": [g.0], "gradient": {"stops": stops}})).unwrap(); + let spot = rect(&mut s, [20.0, 0.0, 10.0, 10.0], black_ink(), None, 0.0); + let name = + s.execute("swatch.new", &json!({"name": "Press Black", "color": black_ink(), "spot": true})).unwrap()["name"].as_str().unwrap().to_string(); + s.execute("paint.setFill", &json!({"ids": [spot.0], "swatch": name})).unwrap(); + s.execute("select.set", &json!({"ids": [t.0, g.0, spot.0]})).unwrap(); + s.execute("object.group", &json!({})).unwrap(); + + s.execute("edit.colors.overprintBlack", &json!({})).unwrap(); + let NodeKind::Text(tx) = &node(&s, t).kind else { panic!() }; + assert!(tx.runs.iter().all(|r| r.style.overprint_fill), "type characters, inside the selected group"); + let fill = |s: &Session, id| node(s, id).appearance.fill().unwrap().overprint; + assert!(!fill(&s, g), "a gradient with a rich-black stop"); + assert!(!fill(&s, spot), "spot blacks need includeSpotBlacks"); + s.execute("edit.colors.overprintBlack", &json!({"includeCmyBlacks": true, "includeSpotBlacks": true})).unwrap(); + assert!(fill(&s, g) && fill(&s, spot)); +} diff --git a/crates/ui-egui/src/dialogs/form.rs b/crates/ui-egui/src/dialogs/form.rs index e6b7aa06..5a2744d7 100644 --- a/crates/ui-egui/src/dialogs/form.rs +++ b/crates/ui-egui/src/dialogs/form.rs @@ -133,6 +133,7 @@ pub(super) fn humanize(k: &str) -> String { "Sy" => "Vertical %".into(), "Radius1" => "Radius 1".into(), "Radius2" => "Radius 2".into(), + "Include Cmy Blacks" => "Include Blacks with CMY:".into(), _ => format!("{s}:"), } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 08a13ff2..4fea0cde 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -33,6 +33,8 @@ pub mod workspaces; #[cfg(test)] mod tests_labels; +#[cfg(test)] +mod tests_overprint; use std::sync::mpsc::{Receiver, Sender}; use std::sync::{Arc, Mutex}; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index bce706c9..5c682e7b 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -788,7 +788,11 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { c("Convert to Grayscale", "edit.colors.toGrayscale"), c("Convert to RGB", "edit.colors.toRGB"), c("Invert Colors", "edit.colors.invert"), - c("Overprint Black…", "edit.colors.overprintBlack"), + cp( + "Overprint Black…", + "edit.colors.overprintBlack", + json!({"remove": false, "percentage": 100, "fill": true, "stroke": true, "includeCmyBlacks": false, "includeSpotBlacks": false}), + ), cp("Saturate…", "edit.colors.saturate", json!({"intensity": 20})), ], ), diff --git a/crates/ui-egui/src/tests_labels.rs b/crates/ui-egui/src/tests_labels.rs index 7907abce..f2f6ea21 100644 --- a/crates/ui-egui/src/tests_labels.rs +++ b/crates/ui-egui/src/tests_labels.rs @@ -13,8 +13,9 @@ fn app() -> VectorcraftApp { app } -/// Every string painted by `f` in one headless frame, one per line. -fn painted_text(app: &mut VectorcraftApp, mut f: impl FnMut(&mut VectorcraftApp, &mut egui::Ui)) -> String { +/// Every string painted by `f` in a headless frame (app fonts installed), one per line. The second +/// of two frames: windows size themselves invisibly in their first. +pub(crate) fn painted_text(app: &mut VectorcraftApp, mut f: impl FnMut(&mut VectorcraftApp, &mut egui::Ui)) -> String { fn collect(s: &Shape, out: &mut String) { match s { Shape::Text(t) => { @@ -26,6 +27,8 @@ fn painted_text(app: &mut VectorcraftApp, mut f: impl FnMut(&mut VectorcraftApp, } } let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + ctx.run_ui(egui::RawInput::default(), |ui| f(app, ui)).textures_delta.clear(); let mut out = ctx.run_ui(egui::RawInput::default(), |ui| f(app, ui)); out.textures_delta.clear(); let mut text = String::new(); diff --git a/crates/ui-egui/src/tests_overprint.rs b/crates/ui-egui/src/tests_overprint.rs new file mode 100644 index 00000000..3ebc43a5 --- /dev/null +++ b/crates/ui-egui/src/tests_overprint.rs @@ -0,0 +1,46 @@ +//! Edit → Edit Colors → Overprint Black… opens its parameter dialog, which applies the command. + +use serde_json::{Value, json}; +use vectorcraft_doc::NodeId; +use vectorcraft_engine::Session; + +use crate::{VectorcraftApp, dialogs, menus}; + +#[test] +fn overprint_black_menu_opens_the_dialog() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + let id = NodeId(app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap()); + let rich = json!({"c": 60, "m": 40, "y": 40, "k": 100}); + app.run("paint.setFill", json!({"ids": [id.0], "color": rich})).unwrap(); + app.run("paint.setStroke", json!({"ids": [id.0], "color": rich})).unwrap(); + app.run("select.set", json!({"ids": [id.0]})).unwrap(); + + let entry = menus::menu_entries(&app).into_iter().find(|e| e.label == "Overprint Black…").expect("menu item"); + assert_eq!(entry.path, ["Edit", "Edit Colors"]); + assert!(entry.enabled); + let (cmd, params) = menus::click_target(&entry.label, entry.command.as_deref().unwrap(), &entry.params); + menus::invoke(&mut app, &cmd, params); + let d = app.ui.dialog.as_ref().expect("a dialog opens"); + assert_eq!((d.str("__command"), d.str("__label")), ("edit.colors.overprintBlack".to_string(), "Overprint Black".to_string())); + for (k, v) in + [("remove", json!(false)), ("percentage", json!(100)), ("fill", json!(true)), ("stroke", json!(true)), ("includeCmyBlacks", json!(false))] + { + assert_eq!(d.fields.get(k), Some(&v), "{k}"); + } + + // It draws headlessly, with its options labelled. + let text = crate::tests_labels::painted_text(&mut app, |app, ui| dialogs::show(app, ui.ctx())); + for label in ["Overprint Black", "Percentage:", "Include Blacks with CMY:", "Include Spot Blacks:", "Remove:"] { + assert!(text.contains(label), "{label} in {text}"); + } + + // Fill only, with rich blacks: the stroke stays knocked out. + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("includeCmyBlacks".into(), Value::Bool(true)); + d.fields.insert("stroke".into(), Value::Bool(false)); + dialogs::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + let a = &app.session.doc().unwrap().doc.node(id).unwrap().appearance; + assert!(a.fill().unwrap().overprint && !a.stroke().unwrap().overprint); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 6cfa113f..5369c64a 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -60,3 +60,7 @@ effects) prefilled with its values, and `ui.dialog.confirm` runs `effect.setPara `effect.apply`. Choosing an effect that the list already has returns `{"pending": "effectExists"}` and opens the `effectExists` question: `ui.dialog.confirm` opens the applied effect's dialog, `ui.dialog.set {field: "discard", value: true}` then confirm opens a fresh one that adds another, `ui.dialog.cancel` drops it. + +Overprint Black: the Edit → Edit Colors → Overprint Black… menu item opens a `command` parameter dialog for +`edit.colors.overprintBlack` (fields `remove`, `percentage`, `fill`, `stroke`, `includeCmyBlacks`, +`includeSpotBlacks`); `ui.dialog.set` then `ui.dialog.confirm` runs it on the selection.