//! Object → Expand Appearance: fills and strokes as objects, baked geometry effects, raster //! effects as images, brushes as art, and the enable rule. use serde_json::{Value, json}; use vectorcraft_color::BlendMode; use vectorcraft_doc::{Node, NodeKind}; use super::tests_menucmds::{place_undecodable_png, warns_undecoded}; 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_or_else(|e| panic!("{id} {p}: {e}")) } fn node(s: &Session, id: NodeId) -> Node { s.doc().unwrap().doc.node(id).unwrap().clone() } /// A selected 100×100 square at (50, 50) with a red fill and a 4 pt blue stroke. fn square(s: &mut Session) -> NodeId { let id = NodeId(run(s, "shape.rectangle", json!({"x": 50, "y": 50, "width": 100, "height": 100}))["id"].as_u64().unwrap()); run(s, "paint.setFill", json!({"color": "#ff0000"})); run(s, "paint.setStroke", json!({"color": "#0000ff"})); run(s, "stroke.set", json!({"weight": 4})); id } fn render(s: &Session) -> vectorcraft_render::Rendered { let doc = s.doc().unwrap().doc.clone(); let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; vectorcraft_render::Renderer::new().render(&doc, 200, 200, vectorcraft_geom::Affine::IDENTITY, &opts) } /// Pixels (of 200×200) whose colour differs by more than `tol` in any channel. fn differing(a: &vectorcraft_render::Rendered, b: &vectorcraft_render::Rendered, tol: u8) -> usize { a.pixels .as_chunks::<4>() .0 .iter() .zip(b.pixels.as_chunks::<4>().0) .filter(|(p, q)| p.iter().zip(q.iter()).any(|(x, y)| x.abs_diff(*y) > tol)) .count() } fn expandable(s: &Session) -> bool { s.commands().iter().find(|c| c.id == "effect.expandAppearance").unwrap().enabled } #[test] fn two_fills_and_an_offset_stroke_become_three_paths_that_look_the_same() { let mut s = session(); let id = square(&mut s); assert!(!expandable(&s), "a basic appearance has nothing to expand"); // A second fill on top (half-transparent green) and an outset stroke. run(&mut s, "appearance.addFill", json!({})); run(&mut s, "appearance.setItem", json!({"index": 2, "color": "#00ff00", "opacity": 50})); run(&mut s, "effect.apply", json!({"effect": "path.offsetPath", "params": {"offset": 10}, "item": 1})); assert!(expandable(&s)); let before = render(&s); let r = run(&mut s, "effect.expandAppearance", json!({})); assert_eq!(r["ids"], json!([id.0])); let n = node(&s, id); let ch = n.children().expect("a group of the fills and the stroke"); assert_eq!(ch.len(), 3); assert!(ch.iter().all(|c| matches!(c.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) && c.appearance.items.len() == 1)); // In paint order: red fill, the outlined stroke (a filled ring 10 pt out), the green fill. assert!(ch.iter().all(|c| c.appearance.items[0].is_fill() && c.appearance.items[0].effects().is_empty())); let ring = ch[1].geometric_bounds().unwrap(); assert!((ring.width() - 124.0).abs() < 0.5, "{ring:?}"); assert!((ch[2].opacity - 0.5).abs() < 1e-6, "the fill's opacity is the path's own"); assert!(ch[2].appearance.is_basic()); let after = render(&s); assert!(differing(&before, &after, 8) < 200, "{} pixels differ", differing(&before, &after, 8)); assert!(!expandable(&s)); // One undo step. run(&mut s, "edit.undo", json!({})); assert!(matches!(node(&s, id).kind, NodeKind::Path { .. })); assert_eq!(node(&s, id).appearance.items.len(), 3); } /// The objects [`random_transforms`] makes. pub(super) const RANDOM_CASES: [&str; 5] = ["copies", "group copies", "nested group copies", "crop marks", "group crop marks"]; /// Selected art with Random in Transform effects, from 50 pt squares at y 60 with an 8 pt black /// stroke, as `case` says. "copies": a square at x 20 filled with a gradient, a random Transform /// effect on its stroke and a Transform effect that copies it 80 pt right. "group copies": the /// same square without the second effect, in a group that has it. "nested group copies": the same /// square in a group with a random Transform effect, in a group with the second effect. "crop /// marks": a red square at x 20 with a random Transform effect and crop marks. "group crop marks": /// red squares at x 20 and x 110 in a group with a random Transform effect and crop marks. Returns /// the object's id. pub(super) fn random_transforms(s: &mut Session, case: &str) -> NodeId { let random = json!({"moveH": 15, "moveV": 15, "rotate": 40, "random": true}); let square = |s: &mut Session, x: f64, fill: Value| { let id = run(s, "shape.rectangle", json!({"x": x, "y": 60, "width": 50, "height": 50}))["id"].as_u64().unwrap(); run(s, "paint.setFill", fill); run(s, "paint.setStroke", json!({"color": "#000000"})); run(s, "stroke.set", json!({"weight": 8})); id }; let red = json!({"color": "#ff0000"}); let id = match case { "copies" | "group copies" | "nested group copies" => { let a = square(s, 20.0, json!({"gradient": {"kind": "linear"}})); run(s, "effect.apply", json!({"effect": "distort.transform", "params": random, "item": 1})); if case == "nested group copies" { run(s, "object.group", json!({})); run(s, "effect.apply", json!({"effect": "distort.transform", "params": random, "item": null})); } if case == "copies" { a } else { run(s, "object.group", json!({}))["id"].as_u64().unwrap() } } "crop marks" => square(s, 20.0, red), _ => { let a = square(s, 20.0, red.clone()); let b = square(s, 110.0, red); run(s, "select.set", json!({"ids": [a, b]})); run(s, "object.group", json!({}))["id"].as_u64().unwrap() } }; if case.ends_with("copies") { run(s, "effect.apply", json!({"effect": "distort.transform", "params": {"moveH": 80, "copies": 1}, "item": null})); } else { run(s, "effect.apply", json!({"effect": "distort.transform", "params": random, "item": null})); run(s, "effect.apply", json!({"effect": "cropMarks", "item": null})); } NodeId(id) } /// Random in a Transform effect takes its seed from the object's id. The expanded art draws as the /// canvas drew each of [`random_transforms`]' objects. #[test] fn expanded_art_keeps_the_random_transforms_the_canvas_drew() { let mut differ = vec![]; for case in RANDOM_CASES { let mut s = session(); let id = random_transforms(&mut s, case); let before = render(&s); let r = run(&mut s, "effect.expandAppearance", json!({})); differ.push((case, differing(&before, &render(&s), 64))); let mut art = node(&s, id); // Crop marks: the reply names the group of the object and its marks, then the object. if case.ends_with("crop marks") { art = (*art.children().unwrap()[0]).clone(); assert_eq!((&r["ids"][0], &r["ids"][1]), (&json!(id.0), &json!(art.id.0)), "{case}"); } // No object keeps an effect, and in the copies cases no object keeps a stroke: every copy // is expanded. art.walk(&mut |n| { let stroked = case.ends_with("copies") && n.appearance.stroke().is_some_and(|st| !st.paint.is_none()); assert!(n.appearance.effects.is_empty() && !stroked, "{case}: {n:?}"); }); } assert_eq!(differ, RANDOM_CASES.map(|case| (case, 0))); } /// The reply lists each object whose appearance expanded once, by the id it has in the document, /// starting with the selected object. In "group copies" the two copies of the square share an id /// until Expand Appearance gives them ids of their own, and the reply names the group, then each /// copy by its new id. #[test] fn the_reply_names_each_expanded_copy_by_its_own_id() { for case in RANDOM_CASES { let mut s = session(); let id = random_transforms(&mut s, case); let r = run(&mut s, "effect.expandAppearance", json!({})); let ids: Vec = r["ids"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect(); assert_eq!(ids.first(), Some(&id.0), "{case}: {ids:?}"); let unique: std::collections::HashSet<_> = ids.iter().collect(); assert_eq!(unique.len(), ids.len(), "{case}: {ids:?}"); assert!(ids.iter().all(|&i| s.doc().unwrap().doc.node(NodeId(i)).is_some()), "{case}: {ids:?}"); if case == "group copies" { let art = node(&s, id); let copies: Vec = art.children().unwrap()[0].children().unwrap().iter().map(|c| c.id.0).collect(); assert_eq!(ids, [vec![id.0], copies].concat(), "{case}"); } } } /// A square with a color adjustment and crop marks: the reply names the group of the square and its /// marks once. The adjusted square has a basic appearance, so the reply does not name it. #[test] fn the_reply_names_an_adjusted_object_with_crop_marks_once() { let mut s = session(); let id = square(&mut s); run(&mut s, "effect.apply", json!({"effect": "adjust.hueSaturation", "params": {"hue": 120}})); run(&mut s, "effect.apply", json!({"effect": "cropMarks", "item": null})); let r = run(&mut s, "effect.expandAppearance", json!({})); assert!(node(&s, id).children().is_some_and(|c| c.len() > 1)); assert_eq!(r["ids"], json!([id.0])); } #[test] fn a_shadow_becomes_an_image_under_the_art_and_transparency_stays_on_the_group() { let mut s = session(); let id = square(&mut s); run(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow", "params": {"x": 8, "y": 8, "blur": 2, "opacity": 100}})); run(&mut s, "transparency.set", json!({"opacity": 60, "blend": "multiply"})); let before = render(&s); run(&mut s, "effect.expandAppearance", json!({})); let n = node(&s, id); assert!((n.opacity - 0.6).abs() < 1e-6 && n.blend == BlendMode::Multiply); assert!(n.appearance.effects.is_empty() && n.appearance.items.is_empty()); let ch = n.children().unwrap(); assert_eq!(ch.len(), 3, "the shadow image, the fill, the outlined stroke"); let NodeKind::Image(im) = &ch[0].kind else { panic!("the shadow is an image: {}", ch[0].kind_label()) }; assert!(s.doc().unwrap().doc.images.contains_key(&im.key)); assert!(ch[1..].iter().all(|c| c.opacity == 1.0 && c.appearance.effects.is_empty())); let after = render(&s); assert!(differing(&before, &after, 24) < 400, "{} pixels differ", differing(&before, &after, 24)); // The shadow is still there, below and right of the square. let p = after.pixel(155, 155); assert!(p[0] < 200, "{p:?}"); } #[test] fn a_blur_turns_the_object_into_an_image() { let mut s = session(); let id = square(&mut s); run(&mut s, "effect.apply", json!({"effect": "blur.gaussian", "params": {"radius": 3}})); run(&mut s, "effect.expandAppearance", json!({})); let n = node(&s, id); assert!(matches!(n.kind, NodeKind::Image(_)), "{}", n.kind_label()); assert!(n.appearance.effects.is_empty()); } #[test] fn expanding_a_blur_says_when_an_image_could_not_be_decoded() { let mut s = session(); let cut = place_undecodable_png(&mut s); run(&mut s, "effect.apply", json!({"effect": "blur.gaussian", "params": {"radius": 3}, "ids": [cut.0]})); let r = run(&mut s, "effect.expandAppearance", json!({"ids": [cut.0]})); assert!(warns_undecoded(&r), "{r}"); assert!(matches!(node(&s, cut).kind, NodeKind::Image(_))); } #[test] fn an_art_brush_becomes_a_group_of_its_art() { let mut s = session(); let id = NodeId(run(&mut s, "shape.line", json!({"x1": 20, "y1": 100, "x2": 180, "y2": 100}))["id"].as_u64().unwrap()); run(&mut s, "select.set", json!({"ids": [id.0]})); run(&mut s, "paint.setFill", json!({"none": true})); run(&mut s, "brush.apply", json!({"name": "Charcoal"})); assert!(expandable(&s)); let before = render(&s); run(&mut s, "effect.expandAppearance", json!({})); let n = node(&s, id); assert!(matches!(n.kind, NodeKind::Group { .. }), "{}", n.kind_label()); let mut brushed = false; n.walk(&mut |c| brushed |= vectorcraft_brush::has_brush(c)); assert!(!brushed, "no brush strokes are left"); let after = render(&s); assert!(differing(&before, &after, 24) < 200, "{} pixels differ", differing(&before, &after, 24)); run(&mut s, "edit.undo", json!({})); assert!(vectorcraft_brush::has_brush(&node(&s, id))); } #[test] fn a_targeted_layer_with_a_fill_expands_into_art_among_its_members() { let mut s = session(); let a = square(&mut s); let layer = s.doc().unwrap().doc.layers[0].id; run(&mut s, "layer.target", json!({"id": layer.0})); run(&mut s, "appearance.addStroke", json!({})); run(&mut s, "stroke.set", json!({"item": 0, "weight": 6})); assert!(expandable(&s)); let before = render(&s); run(&mut s, "effect.expandAppearance", json!({})); let l = node(&s, layer); assert!(l.appearance.items.is_empty()); let ch = l.children().unwrap(); assert_eq!(ch.len(), 2, "the square, then the layer's stroke above it"); assert_eq!(ch[0].id, a); // The layer's stroke is outlined: a filled path. assert!(ch[1].appearance.items.iter().all(|i| i.is_fill())); let after = render(&s); assert!(differing(&before, &after, 8) < 200, "{} pixels differ", differing(&before, &after, 8)); } #[test] fn type_with_a_shadow_stays_type_and_type_with_its_own_stroke_is_outlined() { let mut s = session(); let t = NodeId(run(&mut s, "text.create", json!({"x": 20, "y": 80, "text": "Hi", "size": 48}))["id"].as_u64().unwrap()); run(&mut s, "select.set", json!({"ids": [t.0]})); run(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow"})); run(&mut s, "effect.expandAppearance", json!({})); let n = node(&s, t); let ch = n.children().unwrap(); assert!(matches!(ch[0].kind, NodeKind::Image(_)) && matches!(ch[1].kind, NodeKind::Text(_))); // A stroke of its own: outlined glyphs. let u = NodeId(run(&mut s, "text.create", json!({"x": 20, "y": 160, "text": "Yo", "size": 48}))["id"].as_u64().unwrap()); run(&mut s, "select.set", json!({"ids": [u.0]})); run(&mut s, "appearance.addStroke", json!({})); run(&mut s, "effect.expandAppearance", json!({})); let mut text = false; node(&s, u).walk(&mut |c| text |= matches!(c.kind, NodeKind::Text(_))); assert!(!text, "the type is outlined"); assert!(!expandable(&s)); } #[test] fn type_with_its_own_stroke_expands_with_its_underline() { let mut s = session(); let t = NodeId(run(&mut s, "text.create", json!({"x": 20, "y": 160, "text": "Hi", "size": 48}))["id"].as_u64().unwrap()); run(&mut s, "select.set", json!({"ids": [t.0]})); run(&mut s, "text.setFormat", json!({"underline": true})); run(&mut s, "appearance.addStroke", json!({})); run(&mut s, "effect.expandAppearance", json!({})); // "Hi" has no ink below its baseline (y 160): the pieces reach the bar under it. let b = node(&s, t).geometric_bounds().unwrap(); assert!(b.y1 > 162.0, "{b:?}"); } #[test] fn a_blurred_layer_keeps_its_image_as_its_only_member() { let mut s = session(); square(&mut s); let layer = s.doc().unwrap().doc.layers[0].id; run(&mut s, "layer.clippingMask.toggle", json!({"id": layer.0})); run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 200, "height": 200})); run(&mut s, "layer.target", json!({"id": layer.0})); run(&mut s, "effect.apply", json!({"effect": "blur.gaussian"})); run(&mut s, "effect.expandAppearance", json!({})); let l = node(&s, layer); assert!(l.is_layer() && !l.clips() && l.appearance.effects.is_empty()); let ch = l.children().unwrap(); assert!(ch.len() == 1 && matches!(ch[0].kind, NodeKind::Image(_))); }