//! Opacity-mask editing and the Transparency panel's command reach (M3.25): commands act on the //! masked object while its mask is edited, accept ids without a selection, take opacity in percent, //! report mixed values, and saves and exports never carry the editing layer. use serde_json::{Value, json}; use super::*; use crate::cmd::maskedit::MASK_EDIT_LAYER; use crate::tests::{history, select_with_a_step_to_redo}; fn session() -> Session { let mut s = Session::new(); s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap(); s } fn rect(s: &mut Session, x: f64, w: f64) -> NodeId { let r = s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": w, "height": 200})).unwrap(); NodeId(r["id"].as_u64().unwrap()) } /// An object (0..200) masked by a white rectangle (0..100), with the mask being edited. fn editing_mask(s: &mut Session) -> NodeId { let obj = rect(s, 0.0, 200.0); let m = rect(s, 0.0, 100.0); s.execute("paint.setFill", &json!({"color": "#ffffff", "ids": [m.0]})).unwrap(); s.execute("select.set", &json!({"ids": [obj.0, m.0]})).unwrap(); s.execute("transparency.makeOpacityMask", &json!({})).unwrap(); s.execute("transparency.editOpacityMask", &json!({})).unwrap(); obj } fn doc(s: &Session) -> &Document { &s.doc().unwrap().doc } fn mask(s: &Session, id: NodeId) -> Option<&vectorcraft_doc::OpacityMask> { doc(s).node(id).and_then(|n| n.extras().mask.as_deref()) } fn has_edit_layer(d: &Document) -> bool { d.layers.iter().any(|l| l.name.as_deref() == Some(MASK_EDIT_LAYER)) } fn data(v: &Value) -> Vec { vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap() } #[test] fn mask_options_act_on_the_masked_object_while_editing() { let mut s = session(); let obj = editing_mask(&mut s); // The selection is the mask art; the panel's commands still reach the object's mask. assert!(!s.doc().unwrap().selection.contains(obj)); s.execute("transparency.setOpacityMask", &json!({"clip": false, "invert": true})).unwrap(); s.execute("transparency.unlinkOpacityMask", &json!({})).unwrap(); s.execute("transparency.disableOpacityMask", &json!({})).unwrap(); let m = mask(&s, obj).unwrap(); assert!(!m.clip && m.invert && !m.linked && m.disabled); assert!(doc(&s).mask_edit.is_some(), "still editing"); let info = s.execute("transparency.opacityMaskInfo", &json!({})).unwrap(); assert_eq!(info[0]["id"], obj.0); // Opacity and blend go to the masked object too, not to the mask art. s.execute("transparency.set", &json!({"opacity": 40, "blend": "Multiply"})).unwrap(); let n = doc(&s).node(obj).unwrap(); assert!((n.opacity - 0.4).abs() < 1e-6 && n.blend == vectorcraft_color::BlendMode::Multiply); let info = s.execute("transparency.info", &json!({})).unwrap(); assert_eq!((info["ids"].clone(), info["opacity"].clone(), info["editingMask"].clone()), (json!([obj.0]), json!(40.0), json!(obj.0))); } /// Disable, Enable, Unlink and Link Opacity Mask and Opacity Mask Options with the flags the masks /// already have record no undo step, on the selection, with ids and while the mask is being edited: /// the step to redo stays, the document stays unmodified and the journal still has the command. A /// call that changes a flag on one of the masks is one undo step. A target without a mask and a /// call that gives no flag are still errors. #[test] fn mask_options_already_set_record_no_undo_step() { let mut s = session(); let masked = |s: &mut Session, x: f64| { let (obj, m) = (rect(s, x, 100.0), rect(s, x, 50.0)); s.execute("select.set", &json!({"ids": [obj.0, m.0]})).unwrap(); s.execute("transparency.makeOpacityMask", &json!({})).unwrap(); obj }; // `a`'s mask is as made (clipping, not inverted, enabled, linked); `b`'s has each flag the other // way. let (a, b) = (masked(&mut s, 0.0), masked(&mut s, 150.0)); s.execute("transparency.setOpacityMask", &json!({"id": b.0, "clip": false, "invert": true, "disabled": true, "linked": false})).unwrap(); select_with_a_step_to_redo(&mut s, &[a]); let before = history(&s); assert_eq!((before.1, before.2), (1, false), "a step to redo, and nothing unsaved"); let calls = [ ("transparency.enableOpacityMask", json!({})), ("transparency.linkOpacityMask", json!({})), ("transparency.setOpacityMask", json!({"clip": true, "invert": false, "disabled": false, "linked": true})), ("transparency.disableOpacityMask", json!({"id": b.0})), ("transparency.unlinkOpacityMask", json!({"ids": [b.0]})), ("transparency.setOpacityMask", json!({"ids": [b.0], "clip": false, "invert": true})), ]; for (id, p) in calls { assert_eq!(s.execute(id, &p).unwrap(), Value::Null, "{id} {p}"); assert_eq!(history(&s), before, "{id} {p}"); assert_eq!(s.journal.last().unwrap(), &(id.to_string(), p)); } // While `a`'s mask is edited, the selection is its mask art and the commands act on `a`. s.execute("transparency.editOpacityMask", &json!({"id": a.0})).unwrap(); let art = s.doc().unwrap().selection.objects.clone(); select_with_a_step_to_redo(&mut s, &art); let before = history(&s); assert_eq!((before.1, before.2), (1, false), "a step to redo, and nothing unsaved while editing"); let calls = [ ("transparency.enableOpacityMask", json!({})), ("transparency.linkOpacityMask", json!({})), ("transparency.setOpacityMask", json!({"clip": true, "linked": true})), ]; for (id, p) in calls { assert_eq!(s.execute(id, &p).unwrap(), Value::Null, "{id} {p} while editing"); assert_eq!(history(&s), before, "{id} {p} while editing"); assert_eq!(s.journal.last().unwrap(), &(id.to_string(), p)); } assert!(doc(&s).mask_edit.is_some(), "still editing"); // `b`'s mask is disabled and `a`'s is not: disabling both is one undo step, and it clears Redo. s.execute("transparency.stopEditingOpacityMask", &json!({})).unwrap(); select_with_a_step_to_redo(&mut s, &[a, b]); let undo = history(&s).0; s.execute("transparency.disableOpacityMask", &json!({})).unwrap(); assert_eq!(history(&s), (undo + 1, 0, true)); assert!(mask(&s, a).unwrap().disabled && mask(&s, b).unwrap().disabled); let plain = rect(&mut s, 0.0, 20.0); let err = s.execute("transparency.enableOpacityMask", &json!({"id": plain.0})).unwrap_err(); assert!(err.to_string().contains("no target object has an opacity mask"), "{err}"); assert!(s.execute("transparency.setOpacityMask", &json!({"id": a.0})).is_err(), "no flag given"); } #[test] fn release_while_editing_leaves_editing_first() { let mut s = session(); let obj = editing_mask(&mut s); // Widen the mask while editing; releasing keeps that edit. s.execute("object.move", &json!({"dx": 50, "dy": 0})).unwrap(); s.execute("transparency.releaseOpacityMask", &json!({})).unwrap(); let st = s.doc().unwrap(); assert!(st.doc.mask_edit.is_none() && st.isolation.is_none() && !has_edit_layer(&st.doc)); assert!(mask(&s, obj).is_none()); let layer = &st.doc.layers[0]; let kids = layer.children().unwrap(); assert_eq!(kids.len(), 2, "the object and its released mask art"); assert_eq!(kids[1].geometric_bounds().unwrap().x0, 50.0); assert_eq!(st.active_layer, Some(layer.id)); // One undo step restores the mask and editing mode. s.execute("edit.undo", &json!({})).unwrap(); assert!(mask(&s, obj).is_some() && doc(&s).mask_edit.is_some()); } /// Releasing the masks of a group and of an object inside it puts each mask's art directly above /// its object, as releasing one mask at a time does. #[test] fn release_of_a_masked_group_and_a_masked_object_inside_it_keeps_order_and_ids() { let mut s = session(); let (a, m1) = (rect(&mut s, 0.0, 100.0), rect(&mut s, 0.0, 50.0)); s.execute("transparency.makeOpacityMask", &json!({"ids": [a.0, m1.0]})).unwrap(); let b = rect(&mut s, 150.0, 100.0); s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); let group = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); let m2 = rect(&mut s, 0.0, 300.0); s.execute("transparency.makeOpacityMask", &json!({"ids": [group.0, m2.0]})).unwrap(); let before = doc(&s).clone(); let n = before.peek_next_id(); s.execute("transparency.releaseOpacityMask", &json!({"ids": [group.0, a.0]})).unwrap(); let ids = |n: &vectorcraft_doc::Node| n.children().unwrap().iter().map(|c| c.id).collect::>(); assert_eq!(ids(&doc(&s).layers[0]), [group, NodeId(n)]); assert_eq!(ids(doc(&s).node(group).unwrap()), [a, NodeId(n + 1), b]); assert!(mask(&s, group).is_none() && mask(&s, a).is_none()); assert_eq!(s.doc().unwrap().selection.objects, [group, NodeId(n), a, NodeId(n + 1)]); s.execute("edit.undo", &json!({})).unwrap(); assert!(*doc(&s) == before, "Undo gives back the document"); } #[test] fn one_object_gets_an_empty_mask_drawn_in_editing_mode() { let mut s = session(); let obj = rect(&mut s, 0.0, 200.0); let r = s.execute("transparency.makeOpacityMask", &json!({})).unwrap(); assert_eq!(r, json!({"id": obj.0, "editing": true})); let me = doc(&s).mask_edit.unwrap(); assert_eq!(me.object, obj); assert_eq!(s.doc().unwrap().isolation, Some(me.layer)); assert!(s.doc().unwrap().selection.is_empty()); // An empty clipping mask hides the object; drawing white in editing mode reveals it there. let render = |s: &Session| vectorcraft_render::Renderer::new().render(doc(s), 300, 300, vectorcraft_geom::Affine::IDENTITY, &Default::default()); assert_eq!(render(&s).pixel(50, 100)[3], 0); let m = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 200})).unwrap()["id"].as_u64().unwrap(); s.execute("paint.setFill", &json!({"color": "#ffffff", "ids": [m]})).unwrap(); let img = render(&s); assert!(img.pixel(50, 100)[3] > 200); assert_eq!(img.pixel(150, 100)[3], 0); s.execute("transparency.stopEditingOpacityMask", &json!({})).unwrap(); assert_eq!(mask(&s, obj).unwrap().art.geometric_bounds().unwrap().x1, 100.0); // Making a mask while editing one is refused. s.execute("transparency.editOpacityMask", &json!({"id": obj.0})).unwrap(); assert!(s.execute("transparency.makeOpacityMask", &json!({"ids": [obj.0]})).is_err()); // One undo step undoes making the empty mask and entering editing; isolation follows. let mut s = session(); let obj = rect(&mut s, 0.0, 200.0); s.execute("transparency.makeOpacityMask", &json!({})).unwrap(); let layer = doc(&s).mask_edit.unwrap().layer; s.execute("edit.undo", &json!({})).unwrap(); assert!(mask(&s, obj).is_none() && doc(&s).mask_edit.is_none()); assert_eq!(s.doc().unwrap().isolation, None); s.execute("edit.redo", &json!({})).unwrap(); assert_eq!((s.doc().unwrap().isolation, s.doc().unwrap().active_layer), (Some(layer), Some(layer))); } #[test] fn mask_and_transparency_commands_take_ids_without_a_selection() { let mut s = session(); let (a, b) = (rect(&mut s, 0.0, 200.0), rect(&mut s, 0.0, 100.0)); s.execute("select.none", &json!({})).unwrap(); let r = s.execute("transparency.makeOpacityMask", &json!({"ids": [b.0, a.0], "invert": true})).unwrap(); assert_eq!(r["id"], a.0, "the top object (b) masks the one below, whatever the order given"); s.execute("select.none", &json!({})).unwrap(); s.execute("transparency.setOpacityMask", &json!({"id": a.0, "clip": false})).unwrap(); s.execute("transparency.disableOpacityMask", &json!({"ids": [a.0]})).unwrap(); let m = mask(&s, a).unwrap(); assert!(!m.clip && m.invert && m.disabled); s.execute("transparency.set", &json!({"ids": [a.0], "opacity": 25})).unwrap(); assert!((doc(&s).node(a).unwrap().opacity - 0.25).abs() < 1e-6); assert!(s.execute("transparency.set", &json!({"opacity": 25})).is_err(), "no selection, no ids"); let layer = s.execute("transparency.editOpacityMask", &json!({"id": a.0})).unwrap()["layer"].as_u64().unwrap(); assert_eq!(doc(&s).mask_edit.unwrap().layer, NodeId(layer)); s.execute("transparency.stopEditingOpacityMask", &json!({})).unwrap(); s.execute("select.none", &json!({})).unwrap(); s.execute("transparency.releaseOpacityMask", &json!({"id": a.0})).unwrap(); assert!(mask(&s, a).is_none()); let err = s.execute("transparency.releaseOpacityMask", &json!({"id": a.0})).unwrap_err(); assert!(err.to_string().contains("no target object has an opacity mask"), "{err}"); } #[test] fn opacity_is_a_percentage_everywhere() { let mut s = session(); let a = rect(&mut s, 0.0, 100.0); s.execute("appearance.setItem", &json!({"index": 0, "opacity": 1})).unwrap(); let fill_opacity = |s: &Session| match &doc(s).node(a).unwrap().appearance.items[0] { vectorcraft_doc::AppearanceItem::Fill(f) => f.opacity, vectorcraft_doc::AppearanceItem::Stroke(st) => st.opacity, }; assert!((fill_opacity(&s) - 0.01).abs() < 1e-6); s.execute("appearance.setItem", &json!({"index": 0, "opacity": 100})).unwrap(); assert_eq!(fill_opacity(&s), 1.0); s.execute("object.setProps", &json!({"opacity": 1})).unwrap(); assert!((doc(&s).node(a).unwrap().opacity - 0.01).abs() < 1e-6); s.execute("object.setProps", &json!({"opacity": 150})).unwrap(); assert_eq!(doc(&s).node(a).unwrap().opacity, 1.0); s.execute("transparency.set", &json!({"opacity": 0.5})).unwrap(); assert!((doc(&s).node(a).unwrap().opacity - 0.005).abs() < 1e-6); // Appearance items take ids without a selection too. s.execute("select.none", &json!({})).unwrap(); assert!(s.execute("appearance.setItem", &json!({"index": 0, "opacity": 30})).is_err(), "no selection, no ids"); s.execute("appearance.setItem", &json!({"index": 0, "ids": [a.0], "opacity": 30})).unwrap(); assert!((fill_opacity(&s) - 0.3).abs() < 1e-6); } #[test] fn info_reports_mixed_values_as_null() { let mut s = session(); let (a, b) = (rect(&mut s, 0.0, 100.0), rect(&mut s, 120.0, 100.0)); s.execute("transparency.set", &json!({"ids": [a.0], "opacity": 50, "isolate": true})).unwrap(); s.execute("transparency.set", &json!({"ids": [a.0, b.0], "blend": "Screen"})).unwrap(); s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); let info = s.execute("transparency.info", &json!({})).unwrap(); assert_eq!( info, json!({"ids": [a.0, b.0], "opacity": null, "blend": "Screen", "isolate": null, "knockout": "neutral", "knockoutShape": false, "editingMask": null, "pageIsolatedBlending": false, "pageKnockoutGroup": false}) ); let one = s.execute("transparency.info", &json!({"id": a.0})).unwrap(); assert_eq!((one["opacity"].clone(), one["isolate"].clone()), (json!(50.0), json!(true))); let i = s.transparency_info(); assert_eq!((i.opacity, i.blend, i.knockout), (None, Some(vectorcraft_color::BlendMode::Screen), Some(vectorcraft_doc::Knockout::Neutral))); s.execute("select.none", &json!({})).unwrap(); assert_eq!(s.execute("transparency.info", &json!({})).unwrap()["opacity"], Value::Null); // Many targets are gathered in one walk of the tree. let ids: Vec = (0..12).map(|i| rect(&mut s, i as f64 * 20.0, 10.0).0).collect(); s.execute("transparency.set", &json!({"ids": ids, "opacity": 30})).unwrap(); let many = s.execute("transparency.info", &json!({"ids": ids})).unwrap(); assert_eq!(many["opacity"], json!(30.0)); } #[test] fn saving_while_editing_writes_no_editing_layer() { let mut s = session(); let obj = editing_mask(&mut s); s.execute("object.move", &json!({"dx": 50, "dy": 0})).unwrap(); let bytes = data(&s.execute("document.save", &json!({})).unwrap()); let back = vectorcraft_format::load(&bytes).unwrap(); assert!(back.mask_edit.is_none() && !has_edit_layer(&back)); assert_eq!(back.layers.len(), 1); // The edit made while editing is in the saved mask. assert_eq!(back.node(obj).unwrap().extras().mask.as_ref().unwrap().art.geometric_bounds().unwrap().x0, 50.0); // Still editing in the session. assert!(doc(&s).mask_edit.is_some() && has_edit_layer(doc(&s))); // A file saved mid-edit by an older version (editing record and layer written) opens clean. let mut v: Value = serde_json::from_slice(&vectorcraft_format::save(doc(&s), false)).unwrap(); let full = serde_json::to_value(doc(&s)).unwrap(); v["document"]["layers"] = full["layers"].clone(); v["document"]["mask_edit"] = json!({"object": obj.0, "layer": doc(&s).mask_edit.unwrap().layer.0}); let old = vectorcraft_format::load(&serde_json::to_vec(&v).unwrap()).unwrap(); assert!(old.mask_edit.is_none() && !has_edit_layer(&old) && old.layers.len() == 1); } #[test] fn exports_while_editing_leave_the_editing_layer_out() { let mut s = session(); editing_mask(&mut s); let svg = |s: &mut Session| s.execute("document.serialize", &json!({"format": "svg"})).unwrap()["text"].as_str().unwrap().to_string(); let pdf = |s: &mut Session| data(&s.execute("document.export", &json!({"format": "pdf"})).unwrap()); let png = |s: &mut Session| data(&s.execute("document.export", &json!({"format": "png"})).unwrap()); let (svg_editing, pdf_editing, png_editing) = (svg(&mut s), pdf(&mut s), png(&mut s)); assert!(!svg_editing.contains("Opacity Mask Editing") && !svg_editing.contains("Opacity_Mask_Editing")); // Before the fix the editing layer's art was written too: the PDF would differ from the one // the document gives without it (same length: only the creation date may differ). let raw = crate::export_pdf(doc(&s), &Default::default()).unwrap(); assert_ne!(raw.len(), pdf_editing.len(), "the editing layer would add content"); s.execute("transparency.stopEditingOpacityMask", &json!({})).unwrap(); assert_eq!(svg(&mut s), svg_editing); assert_eq!(pdf(&mut s).len(), pdf_editing.len()); assert_eq!(png(&mut s), png_editing); } #[test] fn undo_and_redo_walk_in_and_out_of_mask_editing() { let mut s = session(); let obj = rect(&mut s, 0.0, 200.0); s.execute("transparency.makeOpacityMask", &json!({})).unwrap(); let layer = doc(&s).mask_edit.unwrap().layer; s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 200})).unwrap(); s.execute("transparency.stopEditingOpacityMask", &json!({})).unwrap(); let state = |s: &Session| { let st = s.doc().unwrap(); (st.doc.mask_edit.map(|m| m.layer), st.isolation, st.active_layer == Some(layer), has_edit_layer(&st.doc)) }; let editing = (Some(layer), Some(layer), true, true); assert_eq!(state(&s), (None, None, false, false)); // Back into editing with the drawn art, then before it, then before the mask. s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(state(&s), editing); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(state(&s), editing); assert!(doc(&s).node(layer).unwrap().children().unwrap().is_empty()); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(state(&s), (None, None, false, false)); assert!(mask(&s, obj).is_none()); // And forward again. s.execute("edit.redo", &json!({})).unwrap(); s.execute("edit.redo", &json!({})).unwrap(); assert_eq!(state(&s), editing); s.execute("edit.redo", &json!({})).unwrap(); assert_eq!(state(&s), (None, None, false, false)); assert_eq!(mask(&s, obj).unwrap().art.geometric_bounds().unwrap().x1, 100.0); }