//! Knockout groups, the knockout shape and the page group (M3.46–M3.48): commands, undo, files //! (old bools load) and the PDF and SVG output. use serde_json::{Value, json}; use vectorcraft_doc::Knockout; use super::*; fn session() -> Session { let mut s = Session::new(); s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); s } fn run(s: &mut Session, id: &str, p: Value) -> Value { s.execute(id, &p).unwrap() } fn node(s: &Session, id: u64) -> vectorcraft_doc::Node { s.doc().unwrap().doc.node(NodeId(id)).unwrap().clone() } fn steps(s: &Session) -> usize { s.doc().unwrap().history.undo.len() } /// A group of two overlapping 50% rectangles, selected. Returns the group id. fn knockout_group(s: &mut Session) -> u64 { let a = run(s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 100, "height": 100}))["id"].clone(); let b = run(s, "shape.rectangle", json!({"x": 60, "y": 60, "width": 100, "height": 100}))["id"].clone(); run(s, "transparency.set", json!({"ids": [a, b], "opacity": 50})); run(s, "select.set", json!({"ids": [a, b]})); run(s, "object.group", json!({}))["id"].as_u64().unwrap() } #[test] fn knockout_takes_three_states_names_and_bools() { let mut s = session(); let g = knockout_group(&mut s); assert_eq!(node(&s, g).knockout, Knockout::Neutral, "new groups are neutral"); for (param, want) in [ (json!("on"), Knockout::On), (json!("Off"), Knockout::Off), (json!("neutral"), Knockout::Neutral), (json!(true), Knockout::On), (json!(false), Knockout::Neutral), ] { let before = steps(&s); run(&mut s, "transparency.set", json!({"knockout": param})); assert_eq!((node(&s, g).knockout, steps(&s)), (want, before + 1), "{param}"); } run(&mut s, "transparency.set", json!({"knockout": "off"})); let info = run(&mut s, "transparency.info", json!({})); assert_eq!((info["knockout"].clone(), info["knockoutShape"].clone()), (json!("off"), json!(false))); // A bad state changes nothing, also next to an opacity (which would go to an appearance item). let before = steps(&s); assert!(s.execute("transparency.set", &json!({"knockout": "maybe"})).is_err()); assert!(s.execute("transparency.set", &json!({"knockout": 3, "opacity": 20})).is_err()); assert!(s.execute("object.setProps", &json!({"knockout": "maybe"})).is_err()); assert_eq!((node(&s, g).knockout, node(&s, g).opacity, steps(&s)), (Knockout::Off, 1.0, before)); // Undo is one step per change. run(&mut s, "edit.undo", json!({})); assert_eq!(node(&s, g).knockout, Knockout::Neutral); // The panel's click order. assert_eq!([Knockout::On, Knockout::Neutral, Knockout::Off].map(Knockout::cycle), [Knockout::Neutral, Knockout::Off, Knockout::On]); } #[test] fn knockout_shape_is_an_object_option() { let mut s = session(); let g = knockout_group(&mut s); let child = node(&s, g).children().unwrap()[1].id.0; run(&mut s, "transparency.set", json!({"ids": [child], "knockoutShape": true})); assert!(node(&s, child).knockout_shape && !node(&s, child).has_default_transparency()); let info = run(&mut s, "transparency.info", json!({"id": child})); assert_eq!(info["knockoutShape"], true); let mixed = run(&mut s, "transparency.info", json!({"ids": [child, g]})); assert_eq!(mixed["knockoutShape"], Value::Null); run(&mut s, "edit.undo", json!({})); assert!(!node(&s, child).knockout_shape); } #[test] fn page_group_toggles_are_undoable_document_settings() { let mut s = session(); let doc = |s: &Session| (s.doc().unwrap().doc.page_isolate, s.doc().unwrap().doc.page_knockout); let before = steps(&s); assert_eq!(run(&mut s, "transparency.togglePageIsolatedBlending", json!({})), json!({"value": true})); assert_eq!(run(&mut s, "transparency.togglePageKnockoutGroup", json!({"value": true})), json!({"value": true})); assert_eq!((doc(&s), steps(&s)), ((true, true), before + 2)); // Setting the current value is not a change. run(&mut s, "transparency.togglePageKnockoutGroup", json!({"value": true})); assert_eq!(steps(&s), before + 2); let info = run(&mut s, "transparency.info", json!({})); assert_eq!((info["pageIsolatedBlending"].clone(), info["pageKnockoutGroup"].clone()), (json!(true), json!(true))); run(&mut s, "edit.undo", json!({})); assert_eq!(doc(&s), (true, false)); run(&mut s, "transparency.togglePageIsolatedBlending", json!({})); assert_eq!(doc(&s), (false, false)); } #[test] fn knockout_settings_round_trip_and_old_bools_load() { let mut s = session(); let g = knockout_group(&mut s); let child = node(&s, g).children().unwrap()[0].id.0; run(&mut s, "transparency.set", json!({"ids": [g], "knockout": "off"})); run(&mut s, "transparency.set", json!({"ids": [child], "knockout": "on", "knockoutShape": true})); run(&mut s, "transparency.togglePageIsolatedBlending", json!({})); run(&mut s, "transparency.togglePageKnockoutGroup", json!({})); let bytes = vectorcraft_format::save(&s.doc().unwrap().doc, false); let back = vectorcraft_format::load(&bytes).unwrap(); let (bg, bc) = (back.node(NodeId(g)).unwrap(), back.node(NodeId(child)).unwrap()); assert_eq!((bg.knockout, bc.knockout, bc.knockout_shape, back.page_isolate, back.page_knockout), (Knockout::Off, Knockout::On, true, true, true)); // Defaults are not written. let text = String::from_utf8(vectorcraft_format::save(&session().doc().unwrap().doc, false)).unwrap(); assert!(!text.contains("knockout") && !text.contains("page_isolate"), "{text}"); // Files from before the three states stored a bool: true is On, false is Neutral. let mut v: Value = serde_json::from_slice(&bytes).unwrap(); let mut patched = 0; fn patch(v: &mut Value, n: &mut usize) { match v { Value::Object(m) => { if let Some(k) = m.get_mut("knockout") { *k = json!(*k == json!("on")); *n += 1; } m.values_mut().for_each(|c| patch(c, n)); } Value::Array(a) => a.iter_mut().for_each(|c| patch(c, n)), _ => {} } } patch(&mut v, &mut patched); assert_eq!(patched, 2); let old = vectorcraft_format::load(&serde_json::to_vec(&v).unwrap()).unwrap(); assert_eq!((old.node(NodeId(g)).unwrap().knockout, old.node(NodeId(child)).unwrap().knockout), (Knockout::Neutral, Knockout::On)); } #[test] fn pdf_and_svg_write_knockout_groups_as_masks() { let mut s = session(); let g = knockout_group(&mut s); let plain = s.doc().unwrap().doc.clone(); run(&mut s, "transparency.set", json!({"ids": [g], "knockout": "on"})); let ko = s.doc().unwrap().doc.clone(); let pdf = |d: &Document| vectorcraft_pdf::export_with_report(d, &vectorcraft_pdf::PdfOptions::uncompressed()).unwrap(); let r = pdf(&plain); assert!(!String::from_utf8_lossy(&r.bytes).contains("/SMask") && r.warnings.is_empty()); let r = pdf(&ko); let text = String::from_utf8_lossy(&r.bytes); // The lower rectangle is drawn through a luminosity mask made of the upper one's alpha. assert!(text.contains("/Luminosity") && text.contains("/Alpha"), "soft masks"); assert!(r.warnings.iter().any(|w| w.contains("knockout")), "{:?}", r.warnings); let svg = |d: &Document| vectorcraft_svg::export(d, &Default::default()); assert!(!svg(&plain).contains("knockout")); let out = svg(&ko); assert_eq!(out.matches("mask=\"url(#knockout-").count(), 1, "{out}"); assert!(out.contains("