mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 10:47:11 -06:00
Object > Rasterize, Crop Image, Flatten Transparency, Expand Appearance of raster effects and Vector Halftone draw the art with the renderer and replace it with the result. When the art held an image that could not be decoded, the renderer drew nothing for it, and the result lacked the image with no message. A raster export of the same art warns that "an image couldn't be decoded and is missing from the picture". These five commands now compare decode_failures before and after they draw, as raster exports do, and their replies carry that text as `warning`. They still make their result in one undo step. The app shows a command's `warning` in the status bar: after a menu command, after OK in a dialog with a preview, and after a tool runs a command (Crop Image's Apply). Docs: docs/mcp.md (Flatten Transparency, Expand Appearance, Document Raster Effects Settings, Vector halftone).
223 lines
10 KiB
Rust
223 lines
10 KiB
Rust
//! 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);
|
||
}
|
||
|
||
#[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(_)));
|
||
}
|