Files
vectorcraft/crates/engine/src/tests_symbolbounds.rs
T
dotCooCoo 67b47c7262 Artboards: Rearrange and Select All on Active Artboard measure a symbol instance by its art
Rearrange and Select › All on Active Artboard measured a symbol
instance as its transform applied to the 20 × 20 square around the
symbol's origin, not by the symbol's art. An instance whose art lies
away from that square moved with, or was selected on, the artboard
the square was on. Convert to Artboards, Transform Each, Lock and
Hide › All Artwork Above, Paste and Place from Library at a point,
and the clipboard imports measured the instance the same way, as did
Copy and Cut when they recorded the source artboard and library.add
when it sized a graphic.

These commands now measure an instance by its art through
Document::symbol_boxes, as Document::art_on_artboard, Fit to Artwork
Bounds and the Align commands do. Clipboard::bounds measures with the
symbols the clipboard holds. clipboard.exportPng, clipboard.exportPdf
and the PNG and PDF that Copy offers other apps are now cropped to
the instance's art.

Docs: docs/mcp.md.
2026-10-17 16:38:43 +07:00

560 lines
31 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Symbol instances measured by their symbol's art: Align, the Transform panel and the bounding
//! box, Use Preview Bounds, Fit to Artwork Bounds, Rearrange, Convert to Artboards, All on Active
//! Artboard, Transform Each, Lock and Hide › All Artwork Above, EPS export of the art, an
//! artboard's SVG, Export Selection, copy and paste, library graphics and the raster effect images
//! of exports.
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, Document, Effect, Node, NodeId, NodeKind, Symbol};
use vectorcraft_geom::{Affine, Point, Rect, shapes};
use super::*;
/// The symbol of the SVG import tests: a circle of radius 4 around (5, 5) with a 2 pt stroke, and
/// an instance of it at x 80.
const DOT: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="120"><defs><symbol id="Dot"><circle cx="5" cy="5" r="4" stroke="#000" stroke-width="2"/></symbol></defs><use href="#Dot" x="80" fill="red"/></svg>"##;
/// A symbol whose art, a red rectangle from (100, 60) to (140, 80), lies outside the 20 × 20
/// square around the origin, and an instance of it.
const FAR: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="120"><defs><symbol id="Far"><rect x="100" y="60" width="40" height="20" fill="#f00"/></symbol></defs><use href="#Far"/></svg>"##;
/// The same symbol with its instance moved by (-50, -50): the art, (50, 10) to (90, 30), lies on
/// the artboard, and the 20 × 20 square, (-60, -60) to (-40, -40), lies off it.
const FAR_MOVED: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="120"><defs><symbol id="Far"><rect x="100" y="60" width="40" height="20" fill="#f00"/></symbol></defs><use href="#Far" x="-50" y="-50"/></svg>"##;
fn run(s: &mut Session, id: &str, p: Value) -> Value {
s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}"))
}
/// `svg` opened, and its symbol instance.
fn open(svg: &str) -> (Session, NodeId) {
let mut s = Session::new();
run(&mut s, "document.open", json!({"name": "symbols.svg", "dataBase64": vectorcraft_format::base64_encode(svg.as_bytes())}));
let mut found = None;
s.doc().unwrap().doc.walk(|n| {
if matches!(n.kind, NodeKind::SymbolInstance { .. }) {
found = Some(n.id);
}
});
(s, found.expect("a symbol instance"))
}
fn close(a: Option<Rect>, b: Rect) -> bool {
a.is_some_and(|a| [(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(x, y)| (x - y).abs() < 1e-9))
}
#[test]
fn align_left_puts_the_dot_instances_circle_on_the_rectangles_edge() {
let (mut s, dot) = open(DOT);
// The Transform panel and the bounding box measure the circle, (81, 1) to (89, 9), not the
// 20 × 20 square, (70, -10) to (90, 10).
assert!(close(s.transform_bounds(&[dot]), Rect::new(81.0, 1.0, 89.0, 9.0)), "{:?}", s.transform_bounds(&[dot]));
assert!(close(s.transform_box(&[dot]).map(|b| b.rect), Rect::new(81.0, 1.0, 89.0, 9.0)));
run(&mut s, "shape.rectangle", json!({"x": 0, "y": 40, "width": 20, "height": 10}));
run(&mut s, "select.all", json!({}));
run(&mut s, "object.align", json!({"horizontal": "left"}));
let NodeKind::SymbolInstance { xf, .. } = &s.doc().unwrap().doc.node(dot).unwrap().kind else { panic!("the instance") };
// The circle's left edge, 1 pt right of where the instance draws the art's (0, 0), is at x 0.
assert!(((*xf * Point::ZERO).x + 1.0).abs() < 1e-9, "{xf:?}");
}
#[test]
fn document_inspect_reports_the_instances_art_bounds() {
let (mut s, dot) = open(DOT);
let rect = |v: &Value| {
Some(Rect::new(v["x"].as_f64()?, v["y"].as_f64()?, v["x"].as_f64()? + v["width"].as_f64()?, v["y"].as_f64()? + v["height"].as_f64()?))
};
let node = run(&mut s, "document.node", json!({"id": dot.0, "summary": true}));
assert!(close(rect(&node["bounds"]), Rect::new(81.0, 1.0, 89.0, 9.0)), "{node}");
run(&mut s, "select.all", json!({}));
let doc = run(&mut s, "document.inspect", json!({}));
assert!(close(rect(&doc["selectionBounds"]), Rect::new(81.0, 1.0, 89.0, 9.0)), "{doc}");
let layer = &doc["layers"][0];
assert!(close(rect(&layer["children"][0]["bounds"]), Rect::new(81.0, 1.0, 89.0, 9.0)), "{layer}");
}
#[test]
fn use_preview_bounds_takes_in_the_strokes_inside_the_symbol() {
let (mut s, dot) = open(DOT);
run(&mut s, "prefs.set", json!({"key": "usePreviewBounds", "value": true}));
// The 2 pt stroke paints 1 pt around the circle.
assert!(close(s.transform_bounds(&[dot]), Rect::new(80.0, 0.0, 90.0, 10.0)), "{:?}", s.transform_bounds(&[dot]));
}
#[test]
fn set_bounds_with_use_preview_bounds_keeps_the_strokes_in_the_symbol_at_their_weight() {
// The circle's visual box, (80, 0) to (90, 10), set to 20 × 20 around its center and from its
// top-left corner: the stroke still paints 1 pt around the circle, whether strokes scale or not.
for strokes in [true, false] {
for (reference, want) in [(4, Rect::new(75.0, -5.0, 95.0, 15.0)), (0, Rect::new(80.0, 0.0, 100.0, 20.0))] {
let (mut s, dot) = open(DOT);
run(&mut s, "prefs.set", json!({"key": "usePreviewBounds", "value": true}));
run(&mut s, "select.set", json!({"ids": [dot.0]}));
run(&mut s, "object.setBounds", json!({"width": 20, "height": 20, "strokes": strokes, "reference": reference}));
let b = s.transform_bounds(&[dot]);
assert!(close(b, want), "strokes {strokes}, reference {reference}: {b:?}");
}
}
// A width of 2 pt, what the stroke adds, or less, with strokes that scale: the circle gets no
// width, and the visual box is the stroke's 2 pt. The instance does not turn over.
for width in [2.0, 1.0, 0.0] {
let (mut s, dot) = open(DOT);
run(&mut s, "prefs.set", json!({"key": "usePreviewBounds", "value": true}));
run(&mut s, "select.set", json!({"ids": [dot.0]}));
run(&mut s, "object.setBounds", json!({"width": width, "strokes": true}));
let NodeKind::SymbolInstance { xf, .. } = &s.doc().unwrap().doc.node(dot).unwrap().kind else { panic!("the instance") };
assert_eq!(xf.as_coeffs()[0], 0.0, "width {width}: {xf:?}");
let b = s.transform_bounds(&[dot]).unwrap();
assert!((b.width() - 2.0).abs() < 1e-9, "width {width}: {b:?}");
}
// An instance with a 2 pt stroke of its own, which scales, around art with a 2 pt stroke, which
// keeps its weight: doubled, the visual box is twice as wide and high.
let mut d = Document::new(200.0, 120.0);
let stroked = Appearance::basic(Paint::solid(Color::BLACK), Paint::solid(Color::BLACK), 2.0);
let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 8.0, 8.0)), stroked);
d.symbols.push(Symbol { name: "Box".into(), art: Arc::new(art) });
let mut instance = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Box".into(), xf: Affine::translate((50.0, 50.0)) });
*instance.appearance = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0);
let (layer, id) = (d.layers[0].id, instance.id);
d.insert(Some(layer), 0, instance).unwrap();
let mut s = Session::new();
s.add_document(d, None);
run(&mut s, "prefs.set", json!({"key": "usePreviewBounds", "value": true}));
run(&mut s, "select.set", json!({"ids": [id.0]}));
let before = s.transform_bounds(&[id]).unwrap();
run(&mut s, "object.setBounds", json!({"width": 2.0 * before.width(), "height": 2.0 * before.height(), "strokes": true}));
let after = s.transform_bounds(&[id]).unwrap();
assert!((after.width() - 2.0 * before.width()).abs() < 1e-9 && (after.height() - 2.0 * before.height()).abs() < 1e-9, "{before:?} {after:?}");
// A symbol of an 8 × 8 square with a 2 pt round-joined stroke, and an instance of it at
// (50, 50) as the one copy of a one-by-one grid repeat. With strokes that scale,
// `object.setBounds` sets the repeat's visual box, (49, 49) to (59, 59), to 20 × 20 around its
// center, (44, 44) to (64, 64), and the stroke in the symbol keeps its weight.
let mut d = Document::new(200.0, 120.0);
let mut round = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0);
round.stroke_mut().unwrap().join = vectorcraft_doc::LineJoin::Round;
let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 8.0, 8.0)), round);
d.symbols.push(Symbol { name: "Ring".into(), art: Arc::new(art) });
let instance = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Ring".into(), xf: Affine::translate((50.0, 50.0)) });
let (layer, id) = (d.layers[0].id, instance.id);
d.insert(Some(layer), 0, instance).unwrap();
let mut s = Session::new();
s.add_document(d, None);
run(&mut s, "prefs.set", json!({"key": "usePreviewBounds", "value": true}));
run(&mut s, "select.set", json!({"ids": [id.0]}));
let repeat = NodeId(run(&mut s, "object.repeat.grid", json!({"rows": 1, "cols": 1}))["id"].as_u64().expect("the repeat"));
run(&mut s, "select.set", json!({"ids": [repeat.0]}));
assert!(close(s.transform_bounds(&[repeat]), Rect::new(49.0, 49.0, 59.0, 59.0)), "{:?}", s.transform_bounds(&[repeat]));
run(&mut s, "object.setBounds", json!({"width": 20, "height": 20, "strokes": true}));
let b = s.transform_bounds(&[repeat]);
assert!(close(b, Rect::new(44.0, 44.0, 64.0, 64.0)), "the repeat: {b:?}");
}
#[test]
fn set_bounds_sizes_an_instance_and_a_path_whose_strokes_differ() {
// An instance at (50, 50) of a 10 × 10 square with a 10 pt stroke, which keeps its weight, and a
// path of the same square there with a 2 pt stroke, which scales. Setting their visual box,
// (45, 45) to (65, 65), to 40 × 40 around its center gives (35, 35) to (75, 75): the instance's
// stroke still sets every edge.
let mut d = Document::new(200.0, 120.0);
let square = |d: &mut Document, at: f64, weight: f64| {
let stroked = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), weight);
Node::path(d.alloc_id(), shapes::rectangle(Rect::new(at, at, at + 10.0, at + 10.0)), stroked)
};
let art = square(&mut d, 0.0, 10.0);
d.symbols.push(Symbol { name: "Thick".into(), art: Arc::new(art) });
let instance = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Thick".into(), xf: Affine::translate((50.0, 50.0)) });
let path = square(&mut d, 50.0, 2.0);
let (layer, ids) = (d.layers[0].id, [instance.id, path.id]);
d.insert(Some(layer), 0, instance).unwrap();
d.insert(Some(layer), 1, path).unwrap();
let mut s = Session::new();
s.add_document(d, None);
run(&mut s, "prefs.set", json!({"key": "usePreviewBounds", "value": true}));
run(&mut s, "select.set", json!({"ids": [ids[0].0, ids[1].0]}));
assert!(close(s.transform_bounds(&ids), Rect::new(45.0, 45.0, 65.0, 65.0)), "{:?}", s.transform_bounds(&ids));
run(&mut s, "object.setBounds", json!({"width": 40, "height": 40, "strokes": true}));
let b = s.transform_bounds(&ids);
assert!(close(b, Rect::new(35.0, 35.0, 75.0, 75.0)), "{b:?}");
}
#[test]
fn set_bounds_measures_the_objects_in_an_instances_art_one_by_one() {
// A symbol whose art is a group of a rectangle from x 0 to 10 with a 2 pt stroke and one from
// x 3 to 10 with a 10 pt stroke, both 50 pt high, and an instance of it 100 pt down, in a group
// when `grouped`, selected with an unstroked rectangle from x 100 to 110 when `plain`. The
// instance's visual box runs from (-2, 95) to (15, 155). With `own`, the art's group and the
// instance each have a 1 pt stroke of their own, which reaches 5 pt at its miter limit of 10.
let selection = |grouped: bool, plain: bool, own: bool| {
let mut d = Document::new(400.0, 400.0);
let stroked = |d: &mut Document, x0: f64, weight: f64| {
Node::path(
d.alloc_id(),
shapes::rectangle(Rect::new(x0, 0.0, 10.0, 50.0)),
Appearance::basic(Paint::None, Paint::solid(Color::BLACK), weight),
)
};
let mut art = Node::group(d.alloc_id(), vec![Arc::new(stroked(&mut d, 0.0, 2.0)), Arc::new(stroked(&mut d, 3.0, 10.0))]);
let mut instance = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Mixed".into(), xf: Affine::translate((0.0, 100.0)) });
if own {
*art.appearance = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0);
*instance.appearance = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0);
}
d.symbols.push(Symbol { name: "Mixed".into(), art: Arc::new(art) });
let mut roots = vec![if grouped { Node::group(d.alloc_id(), vec![Arc::new(instance)]) } else { instance }];
if plain {
let filled = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0);
roots.push(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(100.0, 100.0, 110.0, 150.0)), filled));
}
let (layer, ids): (NodeId, Vec<NodeId>) = (d.layers[0].id, roots.iter().map(|n| n.id).collect());
for (i, n) in roots.into_iter().enumerate() {
d.insert(Some(layer), i, n).unwrap();
}
let mut s = Session::new();
s.add_document(d, None);
run(&mut s, "prefs.set", json!({"key": "usePreviewBounds", "value": true}));
run(&mut s, "select.set", json!({"ids": ids.iter().map(|id| id.0).collect::<Vec<_>>()}));
(s, ids)
};
// With the unstroked rectangle, the visual box runs from (-2, 95) to (110, 155). Set to 224 wide
// from its left edge, it is 224 wide and its left edge stays at -2, with strokes kept or
// scaling, and with the instance in a group. The instance alone set to 37 wide is 37 wide. With
// the strokes of their own, which paint around each rectangle, the visual box runs from
// (-12, 85) to (110, 165): set to 224 wide, it is 224 wide and its left edge stays at -12.
let mut wrong = vec![];
for (grouped, plain, own, width, strokes) in [
(false, true, false, 224.0, false),
(false, true, false, 224.0, true),
(true, true, false, 224.0, false),
(false, false, false, 37.0, false),
(false, true, true, 224.0, false),
(false, true, true, 224.0, true),
] {
let (mut s, ids) = selection(grouped, plain, own);
let (left, top, bottom) = if own { (-12.0, 85.0, 165.0) } else { (-2.0, 95.0, 155.0) };
let before = s.transform_bounds(&ids);
if !close(before, Rect::new(left, top, if plain { 110.0 } else { 15.0 }, bottom)) {
wrong.push(format!("grouped {grouped}, plain {plain}, own {own}, before: {before:?}"));
}
run(&mut s, "object.setBounds", json!({"width": width, "reference": 0, "strokes": strokes}));
let b = s.transform_bounds(&ids);
if !close(b, Rect::new(left, top, left + width, bottom)) {
wrong.push(format!("grouped {grouped}, plain {plain}, own {own}, strokes {strokes}: {b:?}"));
}
}
assert!(wrong.is_empty(), "{wrong:?}");
}
#[test]
fn set_bounds_measures_an_instance_as_one_object_past_4096_objects_in_its_art() {
// An instance 100 pt down of a symbol whose art is a rectangle from x 0 to 10 with a 2 pt
// stroke, one from x 3 to 10 with a 10 pt stroke, both 50 pt high, and unstroked 1 × 1 squares
// inside them: 4,096 objects, then 4,097. Its visual box runs from (-2, 95) to (15, 155). Set to
// 37 wide from the left edge, it is 37 wide with 4,096 objects. With 4,097 it is measured as one
// object: its 2 pt margin on the left is measured from its left edge, though the stroke of the
// thick rectangle at x 3 paints it, so it is 36 wide and its left edge moves to -1.
for (objects, want) in [(4096, Rect::new(-2.0, 95.0, 35.0, 155.0)), (4097, Rect::new(-1.0, 95.0, 35.0, 155.0))] {
let mut d = Document::new(400.0, 400.0);
let stroked = |d: &mut Document, x0: f64, weight: f64| {
Arc::new(Node::path(
d.alloc_id(),
shapes::rectangle(Rect::new(x0, 0.0, 10.0, 50.0)),
Appearance::basic(Paint::None, Paint::solid(Color::BLACK), weight),
))
};
let mut art = vec![stroked(&mut d, 0.0, 2.0), stroked(&mut d, 3.0, 10.0)];
for i in 2..objects {
let (x, y) = (f64::from(4 + i % 5), f64::from(1 + i % 47));
let square = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0);
art.push(Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, y, x + 1.0, y + 1.0)), square)));
}
let art = Node::group(d.alloc_id(), art);
d.symbols.push(Symbol { name: "Many".into(), art: Arc::new(art) });
let instance = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Many".into(), xf: Affine::translate((0.0, 100.0)) });
let (layer, id) = (d.layers[0].id, instance.id);
d.insert(Some(layer), 0, instance).unwrap();
let mut s = Session::new();
s.add_document(d, None);
run(&mut s, "prefs.set", json!({"key": "usePreviewBounds", "value": true}));
run(&mut s, "select.set", json!({"ids": [id.0]}));
run(&mut s, "object.setBounds", json!({"width": 37, "reference": 0, "strokes": false}));
let b = s.transform_bounds(&[id]);
assert!(close(b, want), "{objects} objects: {b:?}");
}
}
#[test]
fn set_bounds_measures_a_turned_instance_by_the_box_around_its_turned_art_box() {
// A symbol whose art is a filled circle of radius 10 around the origin, and an instance of it
// turned 30° at (100, 100) whose bounding box stands square to the page: the box around the
// art's 20 × 20 box turned 30°, 27.32 wide. Set to 40 wide from its left edge, it is 40 wide.
let mut d = Document::new(400.0, 400.0);
let filled = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0);
let circle = Node::path(d.alloc_id(), shapes::ellipse(Rect::new(-10.0, -10.0, 10.0, 10.0)), filled);
d.symbols.push(Symbol { name: "Disc".into(), art: Arc::new(circle) });
let xf = Affine::translate((100.0, 100.0)) * Affine::rotate(30f64.to_radians());
let instance = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Disc".into(), xf });
let (layer, id) = (d.layers[0].id, instance.id);
d.insert(Some(layer), 0, instance).unwrap();
let mut s = Session::new();
s.add_document(d, None);
run(&mut s, "prefs.set", json!({"key": "usePreviewBounds", "value": true}));
run(&mut s, "select.set", json!({"ids": [id.0]}));
let before = s.transform_bounds(&[id]).unwrap();
let turned = 20.0 * (30f64.to_radians().cos() + 30f64.to_radians().sin());
assert!((before.width() - turned).abs() < 1e-9, "{before:?}");
run(&mut s, "object.setBounds", json!({"width": 40, "reference": 0}));
let after = s.transform_bounds(&[id]).unwrap();
assert!((after.width() - 40.0).abs() < 1e-9 && (after.x0 - before.x0).abs() < 1e-9, "{before:?} {after:?}");
}
#[test]
fn fit_to_artwork_bounds_fits_the_instances_art() {
let (mut s, _) = open(DOT);
run(&mut s, "artboard.fitToArt", json!({}));
let board = s.doc().unwrap().doc.artboards[0].rect;
assert!(close(Some(board), Rect::new(80.0, 0.0, 90.0, 10.0)), "{board:?}");
}
/// Returns a session whose document has artboard 1 from (0, 0) to (800, 600), artboard 2 from
/// (1000, 0) to (1400, 300), and an instance of a symbol whose art, a black rectangle from
/// (1100, 50) to (1140, 70), lies far from the symbol's origin, and returns the instance's id. With
/// the instance moved by (100, 100), the art, (1200, 150) to (1240, 170), is on artboard 2, and the
/// 20 × 20 square around the origin, (90, 90) to (110, 110), is on artboard 1.
fn far_on_artboard_2() -> (Session, NodeId) {
let mut d = Document::new(800.0, 600.0);
let rect = shapes::rectangle(Rect::new(1100.0, 50.0, 1140.0, 70.0));
let art = Node::path(d.alloc_id(), rect, Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0));
d.symbols.push(Symbol { name: "Far".into(), art: Arc::new(art) });
let (layer, id) = (d.layers[0].id, d.alloc_id());
d.insert(Some(layer), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Far".into(), xf: Affine::translate((100.0, 100.0)) })).unwrap();
let mut s = Session::new();
s.add_document(d, None);
run(&mut s, "artboard.new", json!({"x": 1000, "y": 0, "width": 400, "height": 300}));
(s, id)
}
/// Draws a 10 × 10 square at (`x`, `y`), on top, and returns its id.
fn square(s: &mut Session, x: f64, y: f64) -> NodeId {
NodeId(run(s, "shape.rectangle", json!({"x": x, "y": y, "width": 10, "height": 10}))["id"].as_u64().expect("the square"))
}
fn bounds(s: &Session, ids: &[NodeId]) -> Option<Rect> {
s.doc().unwrap().doc.bounds_of(ids, false)
}
#[test]
fn rearrange_moves_an_instance_with_the_artboard_its_art_is_on() {
let (mut s, far) = far_on_artboard_2();
run(&mut s, "artboard.rearrange", json!({"columns": 1, "spacing": 50}));
assert_eq!(s.doc().unwrap().doc.artboards[1].rect, Rect::new(0.0, 650.0, 400.0, 950.0));
assert!(close(bounds(&s, &[far]), Rect::new(200.0, 800.0, 240.0, 820.0)), "{:?}", bounds(&s, &[far]));
}
#[test]
fn all_on_active_artboard_selects_an_instance_on_the_artboard_its_art_is_on() {
let (mut s, far) = far_on_artboard_2();
run(&mut s, "select.allOnArtboard", json!({"artboard": 1}));
assert_eq!(s.doc().unwrap().selection.objects, [far]);
run(&mut s, "select.allOnArtboard", json!({"artboard": 0}));
assert!(s.doc().unwrap().selection.objects.is_empty());
}
#[test]
fn convert_to_artboards_makes_an_artboard_of_the_instances_art() {
let (mut s, far) = far_on_artboard_2();
run(&mut s, "select.set", json!({"ids": [far.0]}));
run(&mut s, "artboard.convertToArtboards", json!({}));
let board = s.doc().unwrap().doc.artboards[2].rect;
assert!(close(Some(board), Rect::new(1200.0, 150.0, 1240.0, 170.0)), "{board:?}");
}
#[test]
fn transform_each_scales_an_instance_about_the_center_of_its_art() {
let (mut s, far) = far_on_artboard_2();
run(&mut s, "select.set", json!({"ids": [far.0]}));
run(&mut s, "object.transformEach", json!({"scaleH": 200, "scaleV": 200}));
assert!(close(bounds(&s, &[far]), Rect::new(1180.0, 140.0, 1260.0, 180.0)), "{:?}", bounds(&s, &[far]));
}
#[test]
fn lock_and_hide_all_artwork_above_measure_the_instances_art() {
// Above the selected instance, the square on its art is locked and the square on the 20 × 20
// square is not.
let (mut s, far) = far_on_artboard_2();
let (on_art, on_square) = (square(&mut s, 1205.0, 155.0), square(&mut s, 95.0, 95.0));
run(&mut s, "select.set", json!({"ids": [far.0]}));
run(&mut s, "object.lock.above", json!({}));
let node = |s: &Session, id: NodeId| s.doc().unwrap().doc.node(id).unwrap().clone();
assert!(node(&s, on_art).locked && !node(&s, on_square).locked);
// Brought in front of both squares, the instance is hidden above the square on its art only.
let (mut s, far) = far_on_artboard_2();
let (on_art, on_square) = (square(&mut s, 1205.0, 155.0), square(&mut s, 95.0, 95.0));
run(&mut s, "select.set", json!({"ids": [far.0]}));
run(&mut s, "object.arrange.bringToFront", json!({}));
run(&mut s, "select.set", json!({"ids": [on_square.0]}));
run(&mut s, "object.hide.above", json!({}));
assert!(node(&s, far).visible, "the instance's art does not overlap the selected square");
run(&mut s, "select.set", json!({"ids": [on_art.0]}));
run(&mut s, "object.hide.above", json!({}));
assert!(!node(&s, far).visible);
}
#[test]
fn eps_of_the_art_bounds_and_writes_an_instances_art() {
let (mut s, _) = open(FAR);
let r = run(&mut s, "document.export", json!({"format": "eps", "previewFormat": "none", "cmykPostScript": false}));
let bytes = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap();
let ps = String::from_utf8(vectorcraft_eps::sections(&bytes).unwrap().0.to_vec()).unwrap();
let bbox = ps.lines().find_map(|l| l.strip_prefix("%%BoundingBox:")).map(str::trim);
// The art, 60 to 80 down from the artboard's top, is 40 to 60 up from its bottom (120 high).
assert_eq!(bbox, Some("100 40 140 60"), "{ps}");
assert!(ps.contains("1 0 0 rg"), "the art is written: {ps}");
}
#[test]
fn an_artboards_svg_keeps_an_instance_whose_art_is_on_it() {
let (mut s, _) = open(FAR_MOVED);
let svg = run(&mut s, "document.serialize", json!({"format": "svg", "artboard": 0}));
let text = svg["text"].as_str().unwrap();
assert!(text.contains("<use"), "{text}");
}
#[test]
fn an_artboards_svg_keeps_an_instance_whose_brush_art_is_on_it() {
// The symbol's art: a line from (0, 40) to (0, 60) with a 1 pt stroke painted by a 20 pt round
// nib. Drawn from (-5, 0), the line lies left of the artboard, and the nib paints 10 pt either
// side of it, onto the artboard.
let mut d = Document::new(200.0, 120.0);
let mut lib = vectorcraft_brush::library(&d);
let Some(vectorcraft_brush::BrushKind::Calligraphic(c)) = lib.iter_mut().find(|b| b.name == "3 pt. Round").map(|b| &mut b.kind) else {
panic!("the 3 pt. Round brush")
};
c.size = 20.0;
vectorcraft_brush::store(&mut d, &lib);
let mut stroke = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0);
stroke.stroke_mut().unwrap().brush = Some("3 pt. Round".into());
let line = Node::path(d.alloc_id(), shapes::line((0.0, 40.0).into(), (0.0, 60.0).into()), stroke);
d.symbols.push(Symbol { name: "Nib".into(), art: Arc::new(line) });
let (layer, id) = (d.layers[0].id, d.alloc_id());
d.insert(Some(layer), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Nib".into(), xf: Affine::translate((-5.0, 0.0)) })).unwrap();
let mut s = Session::new();
s.add_document(d, None);
let svg = run(&mut s, "document.serialize", json!({"format": "svg", "artboard": 0}));
let text = svg["text"].as_str().unwrap();
assert!(text.contains("<use"), "{text}");
}
#[test]
fn an_artboards_svg_keeps_an_instance_whose_shadow_is_on_it() {
// The symbol's art, a black square from (-1, -1) to (1, 1), drawn from (-5, 20): left of the
// artboard. A drop shadow 8 pt to the right, on the art or on the instance, reaches onto it.
for in_art in [true, false] {
let mut d = Document::new(200.0, 120.0);
let shadow = Effect { id: "stylize.dropShadow".into(), params: json!({"x": 8, "y": 0, "blur": 0}), visible: true };
let square = shapes::rectangle(Rect::new(-1.0, -1.0, 1.0, 1.0));
let mut art = Node::path(d.alloc_id(), square, Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0));
let mut instance = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Dot".into(), xf: Affine::translate((-5.0, 20.0)) });
if in_art {
art.appearance.effects.push(shadow);
} else {
instance.appearance.effects.push(shadow);
}
d.symbols.push(Symbol { name: "Dot".into(), art: Arc::new(art) });
let layer = d.layers[0].id;
d.insert(Some(layer), 0, instance).unwrap();
let mut s = Session::new();
s.add_document(d, None);
let svg = run(&mut s, "document.serialize", json!({"format": "svg", "artboard": 0}));
let text = svg["text"].as_str().unwrap();
assert!(text.contains("<use"), "the shadow on the art: {in_art}; {text}");
}
}
#[test]
fn export_selection_crops_to_the_instances_art() {
let (mut s, _) = open(FAR);
run(&mut s, "select.all", json!({}));
let v = run(&mut s, "document.exportSelection", json!({"format": "svg"}));
let bounds: Vec<f64> = v["bounds"].as_array().unwrap().iter().filter_map(Value::as_f64).collect();
// x, y, width and height: the art, not the 20 × 20 square from (-10, -10).
assert_eq!(bounds, [100.0, 60.0, 40.0, 20.0], "{v}");
}
fn ids(v: &Value) -> Vec<NodeId> {
v["ids"].as_array().unwrap().iter().filter_map(Value::as_u64).map(NodeId).collect()
}
#[test]
fn a_copied_instance_comes_from_the_artboard_its_art_is_on() {
let (mut s, far) = far_on_artboard_2();
run(&mut s, "select.set", json!({"ids": [far.0]}));
run(&mut s, "edit.copy", json!({}));
assert_eq!(s.clipboard.source_artboard, Some(Rect::new(1000.0, 0.0, 1400.0, 300.0)));
// Pasted in place onto artboard 2, it lands where it was.
let pasted = ids(&run(&mut s, "edit.pasteInPlace", json!({"artboard": 1})));
assert!(close(bounds(&s, &pasted), Rect::new(1200.0, 150.0, 1240.0, 170.0)), "{:?}", bounds(&s, &pasted));
}
#[test]
fn a_copied_instances_png_is_cropped_to_its_art() {
let (mut s, far) = far_on_artboard_2();
run(&mut s, "select.set", json!({"ids": [far.0]}));
run(&mut s, "edit.copy", json!({}));
let r = run(&mut s, "clipboard.exportPng", json!({}));
assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(40), Some(20)));
let png = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap();
let img = image::load_from_memory(&png).unwrap().to_rgba8();
assert_eq!(img.get_pixel(20, 10).0, [0, 0, 0, 255], "the art fills the picture");
}
#[test]
fn an_instance_pasted_at_a_point_has_its_art_centered_there() {
let (mut s, far) = far_on_artboard_2();
run(&mut s, "select.set", json!({"ids": [far.0]}));
run(&mut s, "edit.copy", json!({}));
let pasted = ids(&run(&mut s, "edit.paste", json!({"center": [300, 200]})));
assert!(close(bounds(&s, &pasted), Rect::new(280.0, 190.0, 320.0, 210.0)), "{:?}", bounds(&s, &pasted));
}
#[test]
fn svg_markup_on_the_clipboard_is_centered_by_the_instances_art() {
let mut s = Session::new();
run(&mut s, "file.new", json!({"width": 400, "height": 300}));
run(&mut s, "clipboard.importSvg", json!({"dataBase64": vectorcraft_format::base64_encode(FAR.as_bytes()), "center": [200, 150]}));
let pasted = ids(&run(&mut s, "edit.pasteInPlace", json!({})));
assert!(close(bounds(&s, &pasted), Rect::new(180.0, 140.0, 220.0, 160.0)), "{:?}", bounds(&s, &pasted));
}
#[test]
fn a_library_graphic_of_an_instance_is_measured_by_its_art() {
let (mut s, far) = far_on_artboard_2();
run(&mut s, "select.set", json!({"ids": [far.0]}));
let added = run(&mut s, "library.add", json!({"kind": "graphic"}));
let (lib, item) = (added["library"].clone(), added["id"].clone());
let g = &run(&mut s, "library.get", json!({"library": lib}))["graphics"][0];
assert_eq!((g["width"].as_f64(), g["height"].as_f64()), (Some(40.0), Some(20.0)), "{g}");
run(&mut s, "file.new", json!({"width": 300, "height": 300}));
let placed = ids(&run(&mut s, "library.use", json!({"library": lib, "kind": "graphic", "item": item, "center": [150, 100]})));
assert!(close(bounds(&s, &placed), Rect::new(130.0, 90.0, 170.0, 110.0)), "{:?}", bounds(&s, &placed));
}
#[test]
fn a_groups_shadow_image_covers_the_art_of_the_instance_in_it() {
let (mut s, far) = open(FAR);
run(&mut s, "select.set", json!({"ids": [far.0]}));
let group = run(&mut s, "object.group", json!({}))["id"].as_u64().expect("the group");
run(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow", "ids": [group]}));
let flat = crate::flatten_raster_effects(&s.doc().unwrap().doc).unwrap();
let mut frame = None;
flat.walk(|n| {
if let NodeKind::Image(im) = &n.kind {
frame = Some(im.xf.transform_rect_bbox(Rect::new(0.0, 0.0, f64::from(im.width), f64::from(im.height))));
}
});
let frame = frame.expect("the shadow's image");
assert!(frame.x0 <= 100.0 && frame.y0 <= 60.0 && frame.x1 >= 140.0 && frame.y1 >= 80.0, "{frame:?}");
}