vectorcraft/crates/engine/src/tests_opacitymask.rs

272 lines
14 KiB
Rust

//! 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<u8> {
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<u64> = (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);
}