use super::*; use vectorcraft_color::{Color, Gradient, GradientPaint, Paint}; use vectorcraft_doc::{Appearance, Node}; use vectorcraft_geom::shapes; fn rect_node(d: &mut Document, r: Rect, fill: Paint, stroke: Paint, w: f64) -> Node { let id = d.alloc_id(); Node::path(id, shapes::rectangle(r), Appearance::basic(fill, stroke, w)) } fn render(d: &Document) -> Rendered { Renderer::new().render(d, 100, 100, Affine::IDENTITY, &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }) } #[test] fn fills_red_rect() { let mut d = Document::new(100.0, 100.0); let n = rect_node(&mut d, Rect::new(10.0, 10.0, 50.0, 50.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); let d = { let mut d2 = d.clone(); d2.insert(Some(d.layers[0].id), 0, n).unwrap(); d2 }; let r = render(&d); assert_eq!(r.pixel(30, 30), [255, 0, 0, 255]); assert_eq!(r.pixel(70, 70), [255, 255, 255, 255]); } #[test] fn raster_size_limits() { let r = Rect::new(0.0, 0.0, 100.0, 50.0); assert_eq!(raster_size(r, 2.0), Ok((200, 100))); assert_eq!(raster_size(r, 0.001), Ok((1, 1))); assert_eq!(raster_size(Rect::new(0.0, 0.0, MAX_RASTER_SIDE as f64, 1.0), 1.0), Ok((MAX_RASTER_SIDE, 1))); assert!(raster_size(Rect::new(0.0, 0.0, MAX_RASTER_SIDE as f64 + 1.0, 1.0), 1.0).is_err()); assert!(raster_size(Rect::new(0.0, 0.0, 20_000.0, 20_000.0), 1.0).is_err(), "400 megapixels"); assert!(raster_size(r, f64::NAN).is_err()); assert!(raster_size(r, f64::INFINITY).is_err()); assert!(raster_size(r, 0.0).is_err()); } #[test] fn stroke_draws_outline_only() { let mut d = Document::new(100.0, 100.0); let n = rect_node(&mut d, Rect::new(10.0, 10.0, 90.0, 90.0), Paint::None, Paint::solid(Color::BLACK), 4.0); let l = d.layers[0].id; d.insert(Some(l), 0, n).unwrap(); let r = render(&d); assert_eq!(r.pixel(10, 50), [0, 0, 0, 255]); assert_eq!(r.pixel(50, 50), [255, 255, 255, 255]); } #[test] fn inside_stroke_stays_inside() { let mut d = Document::new(100.0, 100.0); let mut n = rect_node(&mut d, Rect::new(20.0, 20.0, 80.0, 80.0), Paint::None, Paint::solid(Color::BLACK), 10.0); n.appearance.stroke_mut().unwrap().align = StrokeAlign::Inside; let l = d.layers[0].id; d.insert(Some(l), 0, n).unwrap(); let r = render(&d); assert_eq!(r.pixel(17, 50), [255, 255, 255, 255]); assert_eq!(r.pixel(25, 50), [0, 0, 0, 255]); } #[test] fn outside_stroke_stays_outside() { let mut d = Document::new(100.0, 100.0); let mut n = rect_node(&mut d, Rect::new(20.0, 20.0, 80.0, 80.0), Paint::solid(Color::WHITE), Paint::solid(Color::BLACK), 10.0); n.appearance.stroke_mut().unwrap().align = StrokeAlign::Outside; let l = d.layers[0].id; d.insert(Some(l), 0, n).unwrap(); let r = render(&d); assert_eq!(r.pixel(15, 50), [0, 0, 0, 255]); assert_eq!(r.pixel(23, 50), [255, 255, 255, 255]); } #[test] fn opacity_blends() { let mut d = Document::new(100.0, 100.0); let mut n = rect_node(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), Paint::solid(Color::BLACK), Paint::None, 0.0); n.opacity = 0.5; let l = d.layers[0].id; d.insert(Some(l), 0, n).unwrap(); let p = render(&d).pixel(50, 50); assert!((p[0] as i32 - 128).abs() <= 2, "{p:?}"); } #[test] fn multiply_blend() { let mut d = Document::new(100.0, 100.0); let a = rect_node(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), Paint::solid(Color::rgb(1.0, 1.0, 0.0)), Paint::None, 0.0); let mut b = rect_node(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), Paint::solid(Color::rgb(0.0, 1.0, 1.0)), Paint::None, 0.0); b.blend = vectorcraft_color::BlendMode::Multiply; let l = d.layers[0].id; d.insert(Some(l), 0, a).unwrap(); d.insert(Some(l), 1, b).unwrap(); assert_eq!(render(&d).pixel(50, 50), [0, 255, 0, 255]); } #[test] fn linear_gradient_goes_white_to_black() { let mut d = Document::new(100.0, 100.0); let n = rect_node(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))), Paint::None, 0.0); let l = d.layers[0].id; d.insert(Some(l), 0, n).unwrap(); let r = render(&d); assert!(r.pixel(2, 50)[0] > 240); assert!(r.pixel(97, 50)[0] < 15); let m = r.pixel(50, 50)[0] as i32; assert!((m - 128).abs() < 12, "{m}"); } #[test] fn clip_group_clips() { let mut d = Document::new(100.0, 100.0); let mut clip = rect_node(&mut d, Rect::new(0.0, 0.0, 50.0, 100.0), Paint::None, Paint::None, 0.0); if let NodeKind::Path { clipping, .. } = &mut clip.kind { *clipping = true; } let art = rect_node(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), Paint::solid(Color::BLACK), Paint::None, 0.0); let gid = d.alloc_id(); let g = Node::new(gid, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(art)], clip: true }); let l = d.layers[0].id; d.insert(Some(l), 0, g).unwrap(); let r = render(&d); assert_eq!(r.pixel(25, 50), [0, 0, 0, 255]); assert_eq!(r.pixel(75, 50), [255, 255, 255, 255]); } #[test] fn hidden_and_culled() { let mut d = Document::new(100.0, 100.0); let mut n = rect_node(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), Paint::solid(Color::BLACK), Paint::None, 0.0); n.visible = false; let far = rect_node(&mut d, Rect::new(1000.0, 1000.0, 1100.0, 1100.0), Paint::solid(Color::BLACK), Paint::None, 0.0); let l = d.layers[0].id; d.insert(Some(l), 0, n).unwrap(); d.insert(Some(l), 1, far).unwrap(); let mut rr = Renderer::new(); let r = rr.render(&d, 100, 100, Affine::IDENTITY, &RenderOptions::default()); assert_eq!(r.pixel(50, 50), [0, 0, 0, 0]); assert_eq!(rr.stats.culled, 1); } #[test] fn view_transform_zooms() { let mut d = Document::new(100.0, 100.0); let n = rect_node(&mut d, Rect::new(0.0, 0.0, 10.0, 10.0), Paint::solid(Color::BLACK), Paint::None, 0.0); let l = d.layers[0].id; d.insert(Some(l), 0, n).unwrap(); let r = Renderer::new().render(&d, 100, 100, Affine::scale(5.0), &RenderOptions::default()); assert_eq!(r.pixel(45, 45)[3], 255); assert_eq!(r.pixel(55, 55)[3], 0); } #[test] fn outline_mode_no_fill() { let mut d = Document::new(100.0, 100.0); let n = rect_node(&mut d, Rect::new(10.0, 10.0, 90.0, 90.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); let l = d.layers[0].id; d.insert(Some(l), 0, n).unwrap(); let r = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions { outline: true, background: Some([255; 4]), ..Default::default() }); assert_eq!(r.pixel(50, 50), [255, 255, 255, 255]); assert!(r.pixel(10, 50)[0] < 200); } #[test] fn artboards_white_on_pasteboard() { let d = Document::new(50.0, 50.0); let r = Renderer::new().render( &d, 100, 100, Affine::IDENTITY, &RenderOptions { artboards: true, background: Some([30, 30, 30, 255]), ..Default::default() }, ); assert_eq!(r.pixel(25, 25), [255, 255, 255, 255]); assert_eq!(r.pixel(75, 75), [30, 30, 30, 255]); } #[test] fn region_export_png() { let mut d = Document::new(100.0, 100.0); let n = rect_node(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), Paint::solid(Color::BLACK), Paint::None, 0.0); let l = d.layers[0].id; d.insert(Some(l), 0, n).unwrap(); let r = Renderer::new().render_region(&d, d.artboards[0].rect, 2.0, true); assert_eq!((r.width, r.height), (200, 200)); let png = r.to_png().unwrap(); assert_eq!(&png[1..4], b"PNG"); } #[test] fn arrowheads_render() { let mut d = Document::new(100.0, 100.0); let id = d.alloc_id(); let mut n = Node::path(id, shapes::line(Point::new(10.0, 50.0), Point::new(80.0, 50.0)), Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0)); n.appearance.stroke_mut().unwrap().end_arrow = Some(Arrowhead::Triangle); let l = d.layers[0].id; d.insert(Some(l), 0, n).unwrap(); let r = render(&d); // The arrowhead (past the end point by default: tip at 84) is wider than the 2 pt line. assert!(r.pixel(78, 48)[0] < 128); assert!(r.pixel(85, 50)[0] > 200, "nothing past the tip"); } use vectorcraft_doc::Arrowhead; use vectorcraft_geom::Point; /// A red 10..90 square masked by a white square covering only its left half (10..50), /// plus a mid-grey strip (50..70) — rendered both multi- and single-threaded. fn masked_doc(clip: bool, invert: bool) -> Document { let mut d = Document::new(100.0, 100.0); let mut n = rect_node(&mut d, Rect::new(10.0, 10.0, 90.0, 90.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); let white = rect_node(&mut d, Rect::new(10.0, 10.0, 50.0, 90.0), Paint::solid(Color::WHITE), Paint::None, 0.0); let grey = rect_node(&mut d, Rect::new(50.0, 10.0, 70.0, 90.0), Paint::solid(Color::rgb(0.5, 0.5, 0.5)), Paint::None, 0.0); let gid = d.alloc_id(); let art = Node::group(gid, vec![Arc::new(white), Arc::new(grey)]); let mut m = vectorcraft_doc::OpacityMask::new(art, clip); m.invert = invert; n.extras_mut().mask = Some(Box::new(m)); let l = d.layers[0].id; d.insert(Some(l), 0, n).unwrap(); d } #[test] fn opacity_mask_uses_luminance_and_clips() { for threads in [0, 2] { let mut r = Renderer::new(); r.threads = threads; let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; let img = r.render(&masked_doc(true, false), 100, 100, Affine::IDENTITY, &opts); assert_eq!(img.pixel(30, 50), [255, 0, 0, 255], "white mask → opaque ({threads} threads)"); let mid = img.pixel(60, 50); assert!((100..160).contains(&mid[1]), "grey mask → about half opacity, got {mid:?}"); assert_eq!(img.pixel(80, 50), [255, 255, 255, 255], "clip hides outside the mask art"); } } #[test] fn opacity_mask_without_clip_and_inverted() { let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; let img = Renderer::new().render(&masked_doc(false, false), 100, 100, Affine::IDENTITY, &opts); assert_eq!(img.pixel(80, 50), [255, 0, 0, 255], "no clip: outside the mask art stays visible"); let img = Renderer::new().render(&masked_doc(true, true), 100, 100, Affine::IDENTITY, &opts); assert_eq!(img.pixel(30, 50), [255, 255, 255, 255], "inverted white → hidden"); assert_eq!(img.pixel(80, 50), [255, 0, 0, 255], "inverted clip background → visible"); } #[test] fn disabled_or_outline_mask_draws_unmasked() { let mut d = masked_doc(true, false); let id = d.layers[0].children().unwrap()[0].id; let n = d.node_mut(id).unwrap(); n.extras_mut().mask.as_mut().unwrap().disabled = true; let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; let img = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &opts); assert_eq!(img.pixel(80, 50), [255, 0, 0, 255]); } #[test] fn linked_mask_moves_with_object() { let mut d = masked_doc(true, false); let id = d.layers[0].children().unwrap()[0].id; d.node_mut(id).unwrap().transform(Affine::translate((5.0, 0.0)), false); let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; let img = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &opts); assert_eq!(img.pixel(52, 50), [255, 0, 0, 255], "mask art moved by 5 too"); } #[test] fn trim_view_clips_to_artboards() { let mut d = Document::new(50.0, 50.0); let n = rect_node(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); let l = d.layers[0].id; d.insert(Some(l), 0, n).unwrap(); let opts = RenderOptions { background: Some([255, 255, 255, 255]), trim: true, ..Default::default() }; let r = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &opts); assert_eq!(r.pixel(25, 25), [255, 0, 0, 255]); assert_eq!(r.pixel(75, 75), [255, 255, 255, 255]); assert_eq!(render(&d).pixel(75, 75), [255, 0, 0, 255]); } #[test] fn drop_shadow_profile_and_cache() { let mut d = Document::new(100.0, 100.0); let mut n = rect_node(&mut d, Rect::new(20.0, 20.0, 60.0, 60.0), Paint::solid(Color::WHITE), Paint::None, 0.0); n.appearance.effects.push(vectorcraft_doc::Effect { id: "stylize.dropShadow".into(), params: serde_json::json!({"x": 10.0, "y": 10.0, "blur": 4.0, "opacity": 75.0}), visible: true, }); let l = d.layers[0].id; d.insert(Some(l), 0, n).unwrap(); let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; for threads in [0, 4] { let mut r = Renderer::new(); r.threads = threads; let a = r.render(&d, 100, 100, Affine::IDENTITY, &opts); // Inside the shadow (not under the object): 75% black. At its blurred edge: about half. let inside = a.pixel(65, 50)[0] as i32; assert!((inside - 64).abs() <= 6, "inside {inside}"); let edge = a.pixel(70, 50)[0] as i32; assert!((edge - 160).abs() <= 20, "edge {edge}"); assert_eq!(a.pixel(85, 50), [255, 255, 255, 255]); // Second frame comes from the cache: identical. A pan moves it rigidly. let b = r.render(&d, 100, 100, Affine::IDENTITY, &opts); assert_eq!(a.pixels, b.pixels); let c = r.render(&d, 100, 100, Affine::translate((-5.0, 0.0)), &opts); assert_eq!(c.pixel(60, 50), a.pixel(65, 50)); assert_eq!(c.pixel(65, 50), a.pixel(70, 50)); } } #[test] fn blend_mode_on_a_single_fill_is_exact_without_a_layer() { let mut d = Document::new(100.0, 100.0); let red = rect_node(&mut d, Rect::new(0.0, 0.0, 50.0, 100.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); let mut over = rect_node(&mut d, Rect::new(25.0, 0.0, 75.0, 100.0), Paint::solid(Color::rgb(0.5, 0.5, 1.0)), Paint::None, 0.0); over.blend = vectorcraft_color::BlendMode::Multiply; let l = d.layers[0].id; d.insert(Some(l), 0, red).unwrap(); d.insert(Some(l), 1, over).unwrap(); for threads in [0, 4] { let mut r = Renderer::new(); r.threads = threads; let img = r.render(&d, 100, 100, Affine::IDENTITY, &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }); let [rr, g, b, _] = img.pixel(35, 50); assert!((rr as i32 - 128).abs() <= 2 && g <= 1 && b <= 1, "multiply over red: {:?}", img.pixel(35, 50)); let [rr, g, b, _] = img.pixel(65, 50); assert!((rr as i32 - 128).abs() <= 2 && (g as i32 - 128).abs() <= 2 && b >= 253, "multiply over white"); assert_eq!(img.pixel(10, 50), [255, 0, 0, 255]); } } #[test] fn invalid_dash_pattern_strokes_solid() { // A negative dash value makes the pattern invalid (PDF `d`, SVG `stroke-dasharray`): the stroke // is drawn solid. [-5, 3] used to send the dasher into an endless loop. let line = |dash: Option>| { let mut d = Document::new(100.0, 100.0); let mut bp = BezPath::new(); bp.move_to((10.0, 50.0)); bp.line_to((90.0, 50.0)); let id = d.alloc_id(); let mut n = Node::path(id, vectorcraft_geom::PathData::from_bezpath(&bp), Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 6.0)); n.appearance.stroke_mut().unwrap().dash = dash.map(|pattern| vectorcraft_doc::Dash { pattern, ..Default::default() }); let l = d.layers[0].id; d.insert(Some(l), 0, n).unwrap(); d }; let solid = render(&line(None)); for pattern in [vec![-5.0, 3.0], vec![3.0, -5.0], vec![4.0, f64::NAN], vec![0.0, 0.0]] { let d = line(Some(pattern.clone())); let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || tx.send(render(&d)).ok()); let img = rx.recv_timeout(std::time::Duration::from_secs(20)).unwrap_or_else(|_| panic!("rendering dash {pattern:?} did not finish")); assert_eq!(img.pixels, solid.pixels, "dash {pattern:?}"); } } /// An image drawn larger than its pixels samples smoothly, or with `smooth_images` off each screen /// pixel takes its nearest image pixel (Pixel Preview with File Handling › Display Bitmaps as /// Anti-aliased Images off, #394). #[test] fn images_take_their_nearest_pixel_without_smoothing() { use vectorcraft_doc::{ImageBlob, ImageObject, NodeKind}; let img = image::RgbaImage::from_fn(2, 1, |x, _| image::Rgba(if x == 0 { [0, 0, 0, 255] } else { [255, 255, 255, 255] })); let mut png = vec![]; img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); let mut d = Document::new(40.0, 20.0); d.images.insert("bw".into(), ImageBlob::new("image/png", png)); let id = d.alloc_id(); let im = ImageObject { key: "bw".into(), width: 2, height: 1, xf: Affine::scale(20.0), link: None, placement: Default::default() }; d.insert(Some(d.layers[0].id), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap(); // How many pixels across the image are neither black nor white. let greys = |smooth_images| { let r = Renderer::new().render(&d, 40, 20, Affine::IDENTITY, &RenderOptions { smooth_images, ..Default::default() }); (0..40).filter(|x| (16..240).contains(&r.pixel(*x, 10)[0])).count() }; assert!(greys(true) >= 4, "smooth: a ramp from black to white"); assert_eq!(greys(false), 0, "nearest: black, then white"); } /// Art Optimized exports are supersampled (#983): two red shapes meeting along a diagonal leave no /// seam of the white background between them, where shape-by-shape smoothing shows one; the outer /// edges stay smooth. #[test] fn supersampled_art_leaves_no_seam_between_shapes_that_meet() { let red = || Paint::solid(Color::rgb(1.0, 0.0, 0.0)); let mut d = Document::new(100.0, 100.0); let layer = d.layers[0].id; // Two triangles sharing the diagonal of the square (10, 10)–(90, 90). for pts in [[(10.0, 10.0), (90.0, 10.0), (90.0, 90.0)], [(10.0, 10.0), (90.0, 90.0), (10.0, 90.0)]] { let mut b = vectorcraft_geom::BezPath::new(); b.move_to(pts[0]); b.line_to(pts[1]); b.line_to(pts[2]); b.close_path(); let id = d.alloc_id(); d.insert(Some(layer), 0, Node::path(id, vectorcraft_geom::PathData::from_bezpath(&b), Appearance::basic(red(), Paint::None, 0.0))).unwrap(); } let draw = |supersample: bool| { let opts = RenderOptions { background: Some([255, 255, 255, 255]), precise: true, supersample, ..Default::default() }; Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &opts) }; // The palest pixel on the shared diagonal, away from the corners. let palest = |r: &Rendered| (20..80).map(|i| r.pixel(i, i)[1].max(r.pixel(i + 1, i)[1])).max().unwrap(); assert!(palest(&draw(false)) > 20, "shape-by-shape smoothing shows the seam"); assert_eq!(palest(&draw(true)), 0, "supersampled: no seam"); // The outer edge is still smoothed: a pixel half covered is between white and red. let ss = draw(true); let edge = ss.pixel(50, 9); assert!(edge == [255, 255, 255, 255] || (edge[1] > 0 && edge[1] < 255) || ss.pixel(50, 10)[1] < 255, "{edge:?}"); // The factor fits the size and the renderer's limits. assert_eq!(supersample_factor(100, 100), Some(4)); assert_eq!(supersample_factor(2000, 2000), Some(3)); assert_eq!(supersample_factor(3000, 3000), Some(2)); assert_eq!(supersample_factor(5000, 5000), None); assert_eq!(supersample_factor(40_000, 10), None); }