//! 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; 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.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))); } #[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()); } #[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().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); }