mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
Rarely set fields move into one optional boxed NodeExtras, and large NodeKind payloads (images, live shapes, blends, envelopes) are boxed. Every object, and every undo step that copies it, shrinks: a 50k-path move keeps ~24.5 MB per step instead of ~40 MB. Files stay byte-identical (Node keeps its flat JSON through a borrowed Serialize and a from-wire Deserialize, no flatten), and loading is slightly faster. Undo and redo reuse their step's size estimate instead of walking the document again (8.3 -> 5.6 ms for a 50k-path move). Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01VmMmc1J8XeNLxhRihWv9cc # Conflicts: # crates/engine/src/cmd/graph.rs # crates/svg/src/export.rs
619 lines
29 KiB
Rust
619 lines
29 KiB
Rust
//! Copy and paste between documents (resources, swatch conflicts) and paste placement (M4.22,
|
|
//! M4.23).
|
|
|
|
use serde_json::{Value, json};
|
|
use vectorcraft_color::{Color, Paint};
|
|
use vectorcraft_doc::{Document, Node, NodeKind};
|
|
use vectorcraft_geom::{Point, Rect};
|
|
|
|
use super::*;
|
|
|
|
fn session() -> Session {
|
|
let mut s = Session::new();
|
|
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
|
s
|
|
}
|
|
|
|
/// A second document in the same session (as when copying between windows); it becomes active.
|
|
fn new_doc(s: &mut Session) {
|
|
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
|
}
|
|
|
|
fn id_of(v: &Value) -> NodeId {
|
|
NodeId(v["id"].as_u64().unwrap())
|
|
}
|
|
|
|
fn ids_of(v: &Value) -> Vec<NodeId> {
|
|
v["ids"].as_array().unwrap().iter().map(|i| NodeId(i.as_u64().unwrap())).collect()
|
|
}
|
|
|
|
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
|
|
id_of(&s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap())
|
|
}
|
|
|
|
fn ellipse(s: &mut Session) -> NodeId {
|
|
id_of(&s.execute("shape.ellipse", &json!({"x": 300, "y": 200, "width": 40, "height": 20})).unwrap())
|
|
}
|
|
|
|
fn sel(s: &mut Session, ids: &[NodeId]) {
|
|
s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
|
|
}
|
|
|
|
fn copy(s: &mut Session, ids: &[NodeId]) {
|
|
sel(s, ids);
|
|
s.execute("edit.copy", &json!({})).unwrap();
|
|
}
|
|
|
|
fn doc(s: &Session) -> &Document {
|
|
&s.doc().unwrap().doc
|
|
}
|
|
|
|
fn node(s: &Session, id: NodeId) -> Node {
|
|
doc(s).node(id).unwrap().clone()
|
|
}
|
|
|
|
fn fill(s: &Session, id: NodeId) -> Paint {
|
|
node(s, id).appearance.fill_paint()
|
|
}
|
|
|
|
fn linked(color: Color, swatch: &str, tint: f32) -> Paint {
|
|
Paint::Solid { color, swatch: Some(swatch.into()), tint }
|
|
}
|
|
|
|
fn hex(h: &str) -> Color {
|
|
Color::from_hex(h).unwrap()
|
|
}
|
|
|
|
fn render(s: &Session) -> Vec<u8> {
|
|
vectorcraft_render::Renderer::new().render_region(doc(s), Rect::new(0.0, 0.0, 400.0, 300.0), 1.0, true).pixels
|
|
}
|
|
|
|
/// A global swatch `name` of `hex` and a rectangle filled with it.
|
|
fn swatched_rect(s: &mut Session, name: &str, hex: &str) -> NodeId {
|
|
s.execute("swatch.new", &json!({"name": name, "color": hex, "global": true})).unwrap();
|
|
let r = rect(s, 20.0, 20.0, 40.0, 40.0);
|
|
s.execute("paint.setFill", &json!({"ids": [r.0], "swatch": name})).unwrap();
|
|
r
|
|
}
|
|
|
|
fn renamed(v: &Value) -> Vec<(String, String, String)> {
|
|
let s = |r: &Value, k: &str| r[k].as_str().unwrap().to_string();
|
|
v["renamed"].as_array().unwrap().iter().map(|r| (s(r, "kind"), s(r, "from"), s(r, "to"))).collect()
|
|
}
|
|
|
|
// ---------- M4.22: resources ----------
|
|
|
|
#[test]
|
|
fn an_image_symbol_and_pattern_rect_pasted_into_a_new_document_render_the_same() {
|
|
let mut s = session();
|
|
// Pattern "Dots" painting a rectangle.
|
|
let dot = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
|
s.execute("paint.setFill", &json!({"ids": [dot.0], "color": "#ff0000"})).unwrap();
|
|
sel(&mut s, &[dot]);
|
|
s.execute("object.pattern.make", &json!({"name": "Dots", "width": 20, "height": 20})).unwrap();
|
|
s.execute("object.pattern.done", &json!({})).unwrap();
|
|
sel(&mut s, &[dot]);
|
|
s.execute("edit.clear", &json!({})).unwrap();
|
|
let patterned = rect(&mut s, 20.0, 20.0, 100.0, 80.0);
|
|
s.execute("paint.setFill", &json!({"ids": [patterned.0], "swatch": "Dots"})).unwrap();
|
|
// Symbol "Badge" (a green square) placed once.
|
|
let badge = rect(&mut s, 150.0, 20.0, 50.0, 50.0);
|
|
s.execute("paint.setFill", &json!({"ids": [badge.0], "color": "#00aa44"})).unwrap();
|
|
sel(&mut s, &[badge]);
|
|
s.execute("symbol.new", &json!({"name": "Badge"})).unwrap();
|
|
// An embedded image (a rasterized blue ellipse).
|
|
let e = id_of(&s.execute("shape.ellipse", &json!({"x": 240, "y": 40, "width": 90, "height": 60})).unwrap());
|
|
s.execute("paint.setFill", &json!({"ids": [e.0], "color": "#2040ff"})).unwrap();
|
|
sel(&mut s, &[e]);
|
|
s.execute("object.rasterize", &json!({"ppi": 72})).unwrap();
|
|
s.execute("select.all", &json!({})).unwrap();
|
|
s.execute("edit.copy", &json!({})).unwrap();
|
|
assert_eq!(s.clipboard.nodes.len(), 3);
|
|
assert_eq!((s.clipboard.symbols.len(), s.clipboard.patterns.len(), s.clipboard.images.len()), (1, 1, 1));
|
|
let before = render(&s);
|
|
|
|
new_doc(&mut s);
|
|
assert!(before != render(&s));
|
|
let r = s.execute("edit.pasteInPlace", &json!({})).unwrap();
|
|
// The pattern (its swatch comes along), the symbol and the image blob.
|
|
assert_eq!((r["added"].as_u64(), r["merged"].as_u64()), (Some(3), Some(0)), "{r}");
|
|
let d = doc(&s);
|
|
assert!(d.pattern("Dots").is_some() && d.swatch("Dots").is_some());
|
|
assert!(d.symbols.iter().any(|x| x.name == "Badge"));
|
|
assert_eq!(d.images.len(), 1);
|
|
assert!(before == render(&s), "the pasted art renders as it did in its own document");
|
|
}
|
|
|
|
#[test]
|
|
fn a_spot_swatch_brings_its_plate() {
|
|
let mut s = session();
|
|
let r = swatched_rect(&mut s, "Spot Teal", "#008080");
|
|
s.execute("swatch.setSpot", &json!({"name": "Spot Teal"})).unwrap();
|
|
copy(&mut s, &[r]);
|
|
new_doc(&mut s);
|
|
let plates = |s: &Session| vectorcraft_render::proof::plates(doc(s)).iter().filter(|p| p.spot).map(|p| p.name.clone()).collect::<Vec<_>>();
|
|
assert!(plates(&s).is_empty());
|
|
s.execute("edit.paste", &json!({})).unwrap();
|
|
assert_eq!(plates(&s), ["Spot Teal"]);
|
|
let sw = doc(&s).swatch("Spot Teal").unwrap();
|
|
assert!(sw.spot && sw.global);
|
|
}
|
|
|
|
#[test]
|
|
fn swatch_conflicts_merge_relinks_and_add_renames() {
|
|
let mut s = session();
|
|
let r = swatched_rect(&mut s, "Brand", "#ff0000");
|
|
copy(&mut s, &[r]);
|
|
new_doc(&mut s);
|
|
s.execute("swatch.new", &json!({"name": "Brand", "color": "#0000ff", "global": true})).unwrap();
|
|
let c = s.execute("clipboard.conflicts", &json!({})).unwrap();
|
|
assert_eq!(c["swatches"], json!([{"name": "Brand", "document": "#0000ff", "clipboard": "#ff0000", "spot": false}]));
|
|
let swatches = doc(&s).swatches.len();
|
|
|
|
// Merge (the default): the pasted object takes the document's Brand.
|
|
let v = s.execute("edit.pasteInPlace", &json!({"swatchConflict": "merge"})).unwrap();
|
|
assert_eq!((v["merged"].as_u64(), v["added"].as_u64()), (Some(1), Some(0)));
|
|
assert_eq!(fill(&s, ids_of(&v)[0]), linked(hex("#0000ff"), "Brand", 1.0));
|
|
assert_eq!(doc(&s).swatches.len(), swatches);
|
|
s.execute("edit.undo", &json!({})).unwrap();
|
|
|
|
// Add: the pasted swatch joins under a new name and the object follows it.
|
|
let v = s.execute("edit.pasteInPlace", &json!({"swatchConflict": "add"})).unwrap();
|
|
assert_eq!(v["renamed"], json!([{"kind": "swatch", "from": "Brand", "to": "Brand 2"}]));
|
|
assert_eq!(fill(&s, ids_of(&v)[0]), linked(hex("#ff0000"), "Brand 2", 1.0));
|
|
let added = doc(&s).swatch("Brand 2").unwrap();
|
|
assert!(added.global && added.paint.color() == Some(hex("#ff0000")));
|
|
s.execute("edit.undo", &json!({})).unwrap();
|
|
|
|
// Per swatch name; the others merge.
|
|
let v = s.execute("edit.paste", &json!({"swatchConflict": {"Other": "add"}})).unwrap();
|
|
assert_eq!(v["merged"], 1);
|
|
assert!(s.execute("edit.paste", &json!({"swatchConflict": "keep"})).is_err());
|
|
assert!(s.execute("edit.paste", &json!({"swatchConflict": {"Brand": 1}})).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn tints_and_their_tint_swatches_follow_the_base() {
|
|
let mut s = session();
|
|
s.execute("swatch.new", &json!({"name": "Ink", "color": {"c": 0, "m": 100, "y": 100, "k": 0}, "spot": true})).unwrap();
|
|
let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
|
s.execute("paint.setFill", &json!({"ids": [r.0], "swatch": "Ink", "tint": 40})).unwrap();
|
|
// The 40% tint swatch, and one of another tint the art doesn't use.
|
|
assert_eq!(s.execute("swatch.new", &json!({})).unwrap()["name"], "Ink 40%");
|
|
s.execute("paint.setFill", &json!({"ids": [r.0], "swatch": "Ink", "tint": 70})).unwrap();
|
|
s.execute("swatch.new", &json!({})).unwrap();
|
|
s.execute("paint.setFill", &json!({"ids": [r.0], "swatch": "Ink", "tint": 40})).unwrap();
|
|
copy(&mut s, &[r]);
|
|
assert_eq!(s.clipboard.swatches.iter().map(|w| w.name.as_str()).collect::<Vec<_>>(), ["Ink", "Ink 40%"]);
|
|
|
|
// Into a document whose Ink is cyan: merged, the tint is 40% of the document's Ink.
|
|
new_doc(&mut s);
|
|
s.execute("swatch.new", &json!({"name": "Ink", "color": {"c": 100, "m": 0, "y": 0, "k": 0}, "spot": true})).unwrap();
|
|
let v = s.execute("edit.pasteInPlace", &json!({})).unwrap();
|
|
let cyan = Color::cmyk(1.0, 0.0, 0.0, 0.0);
|
|
assert_eq!(fill(&s, ids_of(&v)[0]), linked(cyan.tinted(0.4), "Ink", 0.4));
|
|
let tint = doc(&s).swatch("Ink 40%").unwrap();
|
|
assert_eq!(tint.paint, linked(cyan.tinted(0.4), "Ink", 0.4), "the tint swatch links to the document's Ink");
|
|
assert!(doc(&s).swatch("Ink 70%").is_none(), "unused tints stay behind");
|
|
s.execute("edit.undo", &json!({})).unwrap();
|
|
|
|
// Added: the tint and its tint swatch follow the renamed base.
|
|
let v = s.execute("edit.pasteInPlace", &json!({"swatchConflict": "add"})).unwrap();
|
|
let magenta = Color::cmyk(0.0, 1.0, 1.0, 0.0);
|
|
assert_eq!(fill(&s, ids_of(&v)[0]), linked(magenta.tinted(0.4), "Ink 2", 0.4));
|
|
assert_eq!(doc(&s).swatch("Ink 40%").unwrap().paint, linked(magenta.tinted(0.4), "Ink 2", 0.4));
|
|
}
|
|
|
|
#[test]
|
|
fn gradient_stops_stay_linked_to_their_swatches() {
|
|
let mut s = session();
|
|
s.execute("swatch.new", &json!({"name": "Sky", "color": "#3080ff", "global": true})).unwrap();
|
|
let r = rect(&mut s, 0.0, 0.0, 50.0, 50.0);
|
|
s.execute("paint.setFill", &json!({"ids": [r.0], "gradient": {"kind": "linear"}})).unwrap();
|
|
s.edit("link stop", |d, _| {
|
|
let n = d.node_mut(r).unwrap();
|
|
let Paint::Gradient(mut g) = n.appearance.fill_paint() else { panic!() };
|
|
g.gradient.stops[0].set_color(Color::from_hex("#3080ff").unwrap(), Some(("Sky".into(), 1.0)));
|
|
n.appearance.set_fill(Paint::Gradient(g));
|
|
Ok(())
|
|
})
|
|
.unwrap();
|
|
copy(&mut s, &[r]);
|
|
assert!(s.clipboard.swatches.iter().any(|w| w.name == "Sky"));
|
|
new_doc(&mut s);
|
|
s.execute("swatch.new", &json!({"name": "Sky", "color": "#000080", "global": true})).unwrap();
|
|
let v = s.execute("edit.paste", &json!({"swatchConflict": "add"})).unwrap();
|
|
let Paint::Gradient(g) = fill(&s, ids_of(&v)[0]) else { panic!() };
|
|
assert_eq!((g.gradient.stops[0].swatch.as_deref(), g.gradient.stops[0].color), (Some("Sky 2"), hex("#3080ff")));
|
|
assert!(doc(&s).swatch("Sky 2").is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn gradient_swatches_come_along_with_their_stop_links() {
|
|
let mut s = session();
|
|
s.execute("swatch.new", &json!({"name": "Sky", "color": "#3080ff", "global": true})).unwrap();
|
|
let glow = json!({"stops": [{"offset": 0, "swatch": "Sky"}, {"offset": 1, "color": "#ffffff"}]});
|
|
s.execute("swatch.new", &json!({"name": "Glow", "gradient": glow})).unwrap();
|
|
let r = rect(&mut s, 0.0, 0.0, 50.0, 50.0);
|
|
s.execute("paint.setFill", &json!({"ids": [r.0], "swatch": "Glow"})).unwrap();
|
|
copy(&mut s, &[r]);
|
|
assert_eq!(s.clipboard.swatches.iter().map(|w| w.name.as_str()).collect::<Vec<_>>(), ["Sky", "Glow"]);
|
|
// A document with another Glow and another Sky.
|
|
new_doc(&mut s);
|
|
s.execute("swatch.new", &json!({"name": "Sky", "color": "#000080", "global": true})).unwrap();
|
|
s.execute("swatch.new", &json!({"name": "Glow", "gradient": {"stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#000000"}]}}))
|
|
.unwrap();
|
|
let v = s.execute("edit.paste", &json!({"swatchConflict": "add"})).unwrap();
|
|
let Paint::Gradient(g) = fill(&s, ids_of(&v)[0]) else { panic!() };
|
|
assert_eq!((g.swatch.as_deref(), g.gradient.stops[0].swatch.as_deref()), (Some("Glow 2"), Some("Sky 2")));
|
|
let Paint::Gradient(sw) = doc(&s).swatch("Glow 2").unwrap().paint.clone() else { panic!() };
|
|
assert_eq!(sw.gradient, g.gradient, "the added gradient swatch links its stop to the added Sky too");
|
|
}
|
|
|
|
#[test]
|
|
fn lab_spot_colours_show_as_the_target_documents_spot_option_says() {
|
|
let mut s = session();
|
|
let lab = Color::lab(60.0, 50.0, 20.0);
|
|
s.execute("swatch.new", &json!({"name": "Lab Ink", "color": {"l": 60, "a": 50, "b": 20}, "spot": true})).unwrap();
|
|
let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
|
s.execute("paint.setFill", &json!({"ids": [r.0], "swatch": "Lab Ink"})).unwrap();
|
|
assert_eq!(fill(&s, r).color(), Some(lab));
|
|
copy(&mut s, &[r]);
|
|
new_doc(&mut s);
|
|
s.execute("swatch.spotOptions", &json!({"useLab": false})).unwrap();
|
|
let id = ids_of(&s.execute("edit.paste", &json!({})).unwrap())[0];
|
|
let cmyk = doc(&s).global_color("Lab Ink").unwrap();
|
|
assert!(matches!(cmyk, Color::Cmyk { .. }));
|
|
assert_eq!(fill(&s, id), linked(cmyk, "Lab Ink", 1.0), "the CMYK equivalent, still linked");
|
|
assert_eq!(doc(&s).swatch("Lab Ink").unwrap().paint.color(), Some(lab), "the swatch keeps its Lab definition");
|
|
}
|
|
|
|
#[test]
|
|
fn registration_is_never_copied_as_a_swatch() {
|
|
let mut s = session();
|
|
let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
|
s.edit("registration", |d, _| {
|
|
d.node_mut(r).unwrap().appearance.set_fill(Paint::registration());
|
|
Ok(())
|
|
})
|
|
.unwrap();
|
|
copy(&mut s, &[r]);
|
|
assert!(s.clipboard.swatches.is_empty());
|
|
new_doc(&mut s);
|
|
let swatches = doc(&s).swatches.len();
|
|
let v = s.execute("edit.paste", &json!({})).unwrap();
|
|
assert_eq!(v["added"], 0);
|
|
assert_eq!(doc(&s).swatches.len(), swatches);
|
|
assert!(fill(&s, ids_of(&v)[0]).is_registration());
|
|
}
|
|
|
|
#[test]
|
|
fn graphic_style_links_follow_the_style_into_the_document() {
|
|
let mut s = session();
|
|
let a = rect(&mut s, 0.0, 0.0, 30.0, 30.0);
|
|
s.execute("paint.setFill", &json!({"ids": [a.0], "color": "#ffcc00"})).unwrap();
|
|
sel(&mut s, &[a]);
|
|
s.execute("graphicStyle.new", &json!({"name": "Sun"})).unwrap();
|
|
let src_id = node(&s, a).extras().graphic_style.unwrap();
|
|
copy(&mut s, &[a]);
|
|
assert_eq!(s.clipboard.graphic_styles.iter().map(|g| g.name.as_str()).collect::<Vec<_>>(), ["Sun"]);
|
|
|
|
// A document with another "Sun" and styles taking up the source id.
|
|
new_doc(&mut s);
|
|
let b = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
|
s.execute("paint.setFill", &json!({"ids": [b.0], "color": "#0000ff"})).unwrap();
|
|
sel(&mut s, &[b]);
|
|
s.execute("graphicStyle.new", &json!({"name": "Sun"})).unwrap();
|
|
let v = s.execute("edit.paste", &json!({})).unwrap();
|
|
assert_eq!(renamed(&v), [("graphicStyle".to_string(), "Sun".to_string(), "Sun 2".to_string())]);
|
|
let d = doc(&s);
|
|
let linked_to = d.graphic_style_by_id(node(&s, ids_of(&v)[0]).extras().graphic_style.unwrap()).unwrap().clone();
|
|
assert_eq!(linked_to.name, "Sun 2");
|
|
assert_eq!(linked_to.appearance.fill_paint(), Paint::solid(hex("#ffcc00")));
|
|
assert!(d.graphic_styles.iter().filter(|g| g.id == linked_to.id).count() == 1, "a fresh id");
|
|
// Pasting again finds the same style.
|
|
let v = s.execute("edit.paste", &json!({})).unwrap();
|
|
assert_eq!((v["added"].as_u64(), node(&s, ids_of(&v)[0]).extras().graphic_style), (Some(0), Some(linked_to.id)));
|
|
// Back in the source document the link is kept as it was.
|
|
s.execute("document.activate", &json!({"index": 0})).unwrap();
|
|
let v = s.execute("edit.paste", &json!({})).unwrap();
|
|
assert_eq!(node(&s, ids_of(&v)[0]).extras().graphic_style, Some(src_id));
|
|
}
|
|
|
|
#[test]
|
|
fn undo_removes_everything_the_paste_added() {
|
|
let mut s = session();
|
|
let r = swatched_rect(&mut s, "Brand", "#ff0000");
|
|
sel(&mut s, &[r]);
|
|
s.execute("symbol.new", &json!({"name": "Mark"})).unwrap();
|
|
s.execute("select.all", &json!({})).unwrap();
|
|
s.execute("edit.copy", &json!({})).unwrap();
|
|
new_doc(&mut s);
|
|
let before = doc(&s).clone();
|
|
let v = s.execute("edit.paste", &json!({})).unwrap();
|
|
assert_eq!(v["added"], 2, "{v}");
|
|
assert!(doc(&s).swatch("Brand").is_some() && !doc(&s).symbols.is_empty());
|
|
s.execute("edit.undo", &json!({})).unwrap();
|
|
assert!(*doc(&s) == before);
|
|
}
|
|
|
|
#[test]
|
|
fn styles_symbols_and_images_of_the_same_name_are_renamed() {
|
|
let mut s = session();
|
|
// A character style, a symbol and an image named like the target's own.
|
|
let t = id_of(&s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Hello"})).unwrap());
|
|
s.execute("charStyle.new", &json!({"name": "Emphasis", "attrs": {"size": 24.0}})).unwrap();
|
|
s.execute("charStyle.apply", &json!({"name": "Emphasis", "id": t.0})).unwrap();
|
|
let m = rect(&mut s, 200.0, 100.0, 30.0, 30.0);
|
|
sel(&mut s, &[m]);
|
|
let mark = id_of(&s.execute("symbol.new", &json!({"name": "Mark"})).unwrap());
|
|
let e = rect(&mut s, 300.0, 100.0, 20.0, 20.0);
|
|
sel(&mut s, &[e]);
|
|
let img = id_of(&s.execute("object.rasterize", &json!({"ppi": 72})).unwrap());
|
|
let key = |s: &Session, id: NodeId| match node(s, id).kind {
|
|
NodeKind::Image(im) => im.key,
|
|
_ => panic!("not an image"),
|
|
};
|
|
let src_key = key(&s, img);
|
|
copy(&mut s, &[t, mark, img]);
|
|
|
|
new_doc(&mut s);
|
|
s.execute("charStyle.new", &json!({"name": "Emphasis", "attrs": {"size": 8.0}})).unwrap();
|
|
let m2 = ellipse(&mut s);
|
|
sel(&mut s, &[m2]);
|
|
s.execute("symbol.new", &json!({"name": "Mark"})).unwrap();
|
|
// Another image under the same key.
|
|
let e2 = ellipse(&mut s);
|
|
sel(&mut s, &[e2]);
|
|
let img2 = id_of(&s.execute("object.rasterize", &json!({"ppi": 72})).unwrap());
|
|
let other_key = key(&s, img2);
|
|
s.edit("same key", |d, _| {
|
|
let blob = d.images.remove(&other_key).unwrap();
|
|
d.images.insert(src_key.clone(), blob);
|
|
let NodeKind::Image(im) = &mut d.node_mut(img2).unwrap().kind else { panic!() };
|
|
im.key = src_key.clone();
|
|
Ok(())
|
|
})
|
|
.unwrap();
|
|
|
|
let v = s.execute("edit.pasteInPlace", &json!({})).unwrap();
|
|
let kinds: Vec<(String, String)> = renamed(&v).into_iter().map(|(k, _, to)| (k, to)).collect();
|
|
for want in [("charStyle", "Emphasis 2"), ("symbol", "Mark 2"), ("image", &*format!("{src_key} 2"))] {
|
|
assert!(kinds.contains(&(want.0.to_string(), want.1.to_string())), "{want:?} in {kinds:?}");
|
|
}
|
|
let ids = ids_of(&v);
|
|
let NodeKind::Text(tx) = node(&s, ids[0]).kind else { panic!() };
|
|
assert_eq!(tx.runs[0].style.style_name.as_deref(), Some("Emphasis 2"));
|
|
assert!(matches!(node(&s, ids[1]).kind, NodeKind::SymbolInstance { ref symbol, .. } if symbol == "Mark 2"));
|
|
let new_key = key(&s, ids[2]);
|
|
assert_ne!(new_key, src_key, "a different image under the same key is re-keyed");
|
|
assert!(doc(&s).images[&new_key].bytes != doc(&s).images[&src_key].bytes);
|
|
// Their own objects are untouched.
|
|
assert_eq!(key(&s, img2), src_key);
|
|
}
|
|
|
|
#[test]
|
|
fn pasting_again_reuses_what_an_earlier_paste_added() {
|
|
let mut s = session();
|
|
let r = swatched_rect(&mut s, "Brand", "#ff0000");
|
|
// A character style whose fill links to Brand.
|
|
let t = id_of(&s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Hi"})).unwrap());
|
|
let red = serde_json::to_value(linked(hex("#ff0000"), "Brand", 1.0)).unwrap();
|
|
s.execute("charStyle.new", &json!({"name": "Loud", "attrs": {"fill": red}})).unwrap();
|
|
s.execute("charStyle.apply", &json!({"name": "Loud", "id": t.0})).unwrap();
|
|
copy(&mut s, &[r, t]);
|
|
new_doc(&mut s);
|
|
s.execute("swatch.new", &json!({"name": "Brand", "color": "#0000ff", "global": true})).unwrap();
|
|
let v = s.execute("edit.paste", &json!({"swatchConflict": "add"})).unwrap();
|
|
assert_eq!(v["added"], 2, "Brand 2 and Loud: {v}");
|
|
let style_fill = |s: &Session| serde_json::from_value::<Paint>(doc(s).char_styles[0].attrs["fill"].clone()).unwrap();
|
|
assert_eq!(style_fill(&s), linked(hex("#ff0000"), "Brand 2", 1.0), "the style's colour follows the renamed swatch");
|
|
// Again: Brand 2 and Loud are the pasted ones, nothing piles up.
|
|
let v = s.execute("edit.paste", &json!({"swatchConflict": "add"})).unwrap();
|
|
assert_eq!(v["added"], 0, "{v}");
|
|
assert_eq!(renamed(&v), [("swatch".to_string(), "Brand".to_string(), "Brand 2".to_string())]);
|
|
assert_eq!(fill(&s, ids_of(&v)[0]), linked(hex("#ff0000"), "Brand 2", 1.0));
|
|
assert!(doc(&s).swatch("Brand 3").is_none() && doc(&s).char_styles.len() == 1);
|
|
}
|
|
|
|
#[test]
|
|
fn brushes_travel_with_their_strokes() {
|
|
let mut s = session();
|
|
let name = s
|
|
.execute("brush.new", &json!({"type": "calligraphic", "name": "Wide Nib", "params": {"angle": 30, "roundness": 20, "size": 9}}))
|
|
.unwrap()["name"]
|
|
.as_str()
|
|
.unwrap()
|
|
.to_string();
|
|
let r = rect(&mut s, 10.0, 10.0, 50.0, 50.0);
|
|
s.execute("brush.apply", &json!({"name": name, "ids": [r.0]})).unwrap();
|
|
copy(&mut s, &[r]);
|
|
assert_eq!(s.clipboard.brushes.len(), 1);
|
|
new_doc(&mut s);
|
|
assert!(vectorcraft_brush::find(doc(&s), &name).is_none());
|
|
s.execute("edit.paste", &json!({})).unwrap();
|
|
assert!(vectorcraft_brush::find(doc(&s), &name).is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn pasting_back_into_the_source_document_adds_nothing() {
|
|
let mut s = session();
|
|
let r = swatched_rect(&mut s, "Brand", "#ff0000");
|
|
copy(&mut s, &[r]);
|
|
// Redefining the swatch afterwards is no conflict in its own document.
|
|
s.execute("swatch.edit", &json!({"name": "Brand", "color": "#00ff00"})).unwrap();
|
|
assert_eq!(s.execute("clipboard.conflicts", &json!({})).unwrap()["swatches"], json!([]));
|
|
let v = s.execute("edit.paste", &json!({})).unwrap();
|
|
assert_eq!((v["added"].as_u64(), v["renamed"].as_array().map(Vec::len)), (Some(0), Some(0)));
|
|
assert_eq!(fill(&s, ids_of(&v)[0]), linked(hex("#00ff00"), "Brand", 1.0), "it shows the swatch as it is now");
|
|
}
|
|
|
|
#[test]
|
|
fn paste_without_formatting_leaves_text_styles_behind() {
|
|
let mut s = session();
|
|
let t = id_of(&s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Hello"})).unwrap());
|
|
s.execute("charStyle.new", &json!({"name": "Emphasis", "attrs": {"size": 24.0}})).unwrap();
|
|
s.execute("charStyle.apply", &json!({"name": "Emphasis", "id": t.0})).unwrap();
|
|
copy(&mut s, &[t]);
|
|
new_doc(&mut s);
|
|
s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap();
|
|
assert!(doc(&s).char_styles.is_empty());
|
|
s.execute("edit.paste", &json!({})).unwrap();
|
|
assert_eq!(doc(&s).char_styles.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn clipboard_svg_carries_its_own_patterns() {
|
|
let mut s = session();
|
|
let dot = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
|
sel(&mut s, &[dot]);
|
|
s.execute("object.pattern.make", &json!({"name": "Dots", "width": 20, "height": 20})).unwrap();
|
|
s.execute("object.pattern.done", &json!({})).unwrap();
|
|
let r = rect(&mut s, 20.0, 20.0, 100.0, 80.0);
|
|
s.execute("paint.setFill", &json!({"ids": [r.0], "swatch": "Dots"})).unwrap();
|
|
copy(&mut s, &[r]);
|
|
// Another document is active when the clipboard is published.
|
|
new_doc(&mut s);
|
|
let svg = s.clipboard_svg().unwrap();
|
|
assert!(svg.contains("<pattern"), "{svg}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_failed_batch_puts_the_clipboard_back() {
|
|
let mut s = session();
|
|
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
|
let b = rect(&mut s, 20.0, 0.0, 10.0, 10.0);
|
|
copy(&mut s, &[a]);
|
|
let steps = json!([
|
|
{"command": "select.set", "params": {"ids": [a.0, b.0]}},
|
|
{"command": "edit.copy"},
|
|
{"command": "no.such.command"},
|
|
]);
|
|
assert!(s.execute("command.batch", &json!({"commands": steps})).is_err());
|
|
assert_eq!(s.clipboard.nodes.len(), 1);
|
|
}
|
|
|
|
// ---------- M4.23: placement ----------
|
|
|
|
#[test]
|
|
fn paste_centres_on_the_given_point() {
|
|
let mut s = session();
|
|
let r = rect(&mut s, 0.0, 0.0, 40.0, 20.0);
|
|
copy(&mut s, &[r]);
|
|
let v = s.execute("edit.paste", &json!({"center": [300, 200]})).unwrap();
|
|
let b = doc(&s).bounds_of(&ids_of(&v), false).unwrap();
|
|
assert!((b.center() - Point::new(300.0, 200.0)).hypot() < 1e-9, "{b:?}");
|
|
// Without a centre, the offset applies.
|
|
let v = s.execute("edit.paste", &json!({"dx": 5, "dy": 7})).unwrap();
|
|
assert_eq!(doc(&s).bounds_of(&ids_of(&v), false).unwrap().origin(), Point::new(5.0, 7.0));
|
|
}
|
|
|
|
#[test]
|
|
fn front_and_back_without_a_selection_use_the_current_layers_ends() {
|
|
let mut s = session();
|
|
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
|
let b = rect(&mut s, 20.0, 0.0, 10.0, 10.0);
|
|
copy(&mut s, &[a, b]);
|
|
let layer = |s: &Session| doc(s).layers[0].children().unwrap().iter().map(|n| n.id).collect::<Vec<_>>();
|
|
s.execute("select.none", &json!({})).unwrap();
|
|
let back = ids_of(&s.execute("edit.pasteInBack", &json!({})).unwrap());
|
|
assert_eq!(&layer(&s)[..2], &back[..], "pasted at the bottom (index 0), in order");
|
|
s.execute("select.none", &json!({})).unwrap();
|
|
let front = ids_of(&s.execute("edit.pasteInFront", &json!({})).unwrap());
|
|
let l = layer(&s);
|
|
assert_eq!(&l[l.len() - 2..], &front[..], "pasted on top, in order");
|
|
// With a selection: right above / below it.
|
|
sel(&mut s, &[a]);
|
|
let above = ids_of(&s.execute("edit.pasteInFront", &json!({})).unwrap());
|
|
let l = layer(&s);
|
|
let i = l.iter().position(|id| *id == a).unwrap();
|
|
assert_eq!(&l[i + 1..i + 3], &above[..]);
|
|
sel(&mut s, &[a]);
|
|
let below = ids_of(&s.execute("edit.pasteInBack", &json!({})).unwrap());
|
|
let l = layer(&s);
|
|
let i = l.iter().position(|id| *id == a).unwrap();
|
|
assert_eq!(&l[i - 2..i], &below[..]);
|
|
}
|
|
|
|
#[test]
|
|
fn paste_on_all_artboards_keeps_the_offset_to_the_source_artboard() {
|
|
let mut s = Session::new();
|
|
s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 3})).unwrap();
|
|
let boards: Vec<Rect> = doc(&s).artboards.iter().map(|a| a.rect).collect();
|
|
// On artboard 2, 10 pt right and 15 pt down from its corner.
|
|
let r = rect(&mut s, boards[1].x0 + 10.0, boards[1].y0 + 15.0, 20.0, 20.0);
|
|
copy(&mut s, &[r]);
|
|
assert_eq!(s.clipboard.source_artboard, Some(boards[1]));
|
|
let ids = ids_of(&s.execute("edit.pasteOnAllArtboards", &json!({})).unwrap());
|
|
assert_eq!(ids.len(), 3);
|
|
for (id, b) in ids.iter().zip(&boards) {
|
|
assert_eq!(doc(&s).bounds_of(&[*id], false).unwrap().origin(), Point::new(b.x0 + 10.0, b.y0 + 15.0));
|
|
}
|
|
}
|
|
|
|
/// Paste in Place, in Front and in Back onto the active artboard (#693): where the objects were on
|
|
/// the artboard they were copied from, on the one named; without it, where they were.
|
|
#[test]
|
|
fn in_place_pastes_go_onto_the_artboard_named() {
|
|
let mut s = Session::new();
|
|
s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 3})).unwrap();
|
|
let boards: Vec<Rect> = doc(&s).artboards.iter().map(|a| a.rect).collect();
|
|
let r = rect(&mut s, boards[0].x0 + 10.0, boards[0].y0 + 15.0, 20.0, 20.0);
|
|
copy(&mut s, &[r]);
|
|
for cmd in ["edit.pasteInPlace", "edit.pasteInFront", "edit.pasteInBack"] {
|
|
let ids = ids_of(&s.execute(cmd, &json!({"artboard": 2})).unwrap());
|
|
assert_eq!(doc(&s).bounds_of(&ids, false).unwrap().origin(), Point::new(boards[2].x0 + 10.0, boards[2].y0 + 15.0), "{cmd}");
|
|
let ids = ids_of(&s.execute(cmd, &json!({})).unwrap());
|
|
assert_eq!(doc(&s).bounds_of(&ids, false).unwrap().origin(), Point::new(boards[0].x0 + 10.0, boards[0].y0 + 15.0), "{cmd} without one");
|
|
// An artboard that isn't there: where they were.
|
|
let ids = ids_of(&s.execute(cmd, &json!({"artboard": 9})).unwrap());
|
|
assert_eq!(doc(&s).bounds_of(&ids, false).unwrap().origin(), Point::new(boards[0].x0 + 10.0, boards[0].y0 + 15.0), "{cmd} on no artboard");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn paste_remembers_layers_restores_the_source_layers() {
|
|
let mut s = session();
|
|
let first = doc(&s).layers[0].id;
|
|
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
|
let art = id_of(&s.execute("layer.new", &json!({"name": "Art"})).unwrap());
|
|
let b = rect(&mut s, 20.0, 0.0, 10.0, 10.0);
|
|
copy(&mut s, &[a, b]);
|
|
assert_eq!(s.clipboard.source_layers, [Some("Layer 1".to_string()), Some("Art".to_string())]);
|
|
let other = id_of(&s.execute("layer.new", &json!({"name": "Other"})).unwrap());
|
|
assert_eq!(s.execute("layer.pasteRemembersLayers", &json!({})).unwrap()["on"], true);
|
|
assert!(doc(&s).paste_remembers_layers);
|
|
assert_eq!(s.execute("document.inspect", &json!({})).unwrap()["pasteRemembersLayers"], true);
|
|
let ids = ids_of(&s.execute("edit.paste", &json!({})).unwrap());
|
|
let parents: Vec<_> = ids.iter().map(|id| doc(&s).parent_of(*id)).collect();
|
|
assert_eq!(parents, [Some(first), Some(art)]);
|
|
// In a document without those layers they are made.
|
|
new_doc(&mut s);
|
|
s.execute("layer.pasteRemembersLayers", &json!({"on": true})).unwrap();
|
|
let ids = ids_of(&s.execute("edit.paste", &json!({})).unwrap());
|
|
let names: Vec<_> = ids.iter().map(|id| doc(&s).node(doc(&s).parent_of(*id).unwrap()).unwrap().name.clone().unwrap()).collect();
|
|
assert_eq!(names, ["Layer 1", "Art"]);
|
|
assert_eq!(doc(&s).layers.len(), 2, "Layer 1 exists; Art is new");
|
|
// The option is an undoable document setting.
|
|
s.execute("edit.undo", &json!({})).unwrap();
|
|
s.execute("edit.undo", &json!({})).unwrap();
|
|
assert!(!doc(&s).paste_remembers_layers);
|
|
// Off: everything goes to the current layer.
|
|
s.execute("document.activate", &json!({"index": 0})).unwrap();
|
|
assert_eq!(s.execute("layer.pasteRemembersLayers", &json!({"on": false})).unwrap()["on"], false);
|
|
s.execute("layer.setCurrent", &json!({"id": other.0})).unwrap();
|
|
let ids = ids_of(&s.execute("edit.paste", &json!({})).unwrap());
|
|
assert!(ids.iter().all(|id| doc(&s).parent_of(*id) == Some(other)));
|
|
}
|
|
|
|
#[test]
|
|
fn paste_remembers_layers_survives_save_and_old_files_load_without_it() {
|
|
let mut s = session();
|
|
s.execute("layer.pasteRemembersLayers", &json!({"on": true})).unwrap();
|
|
let bytes = vectorcraft_format::save(doc(&s), false);
|
|
assert!(vectorcraft_format::load(&bytes).unwrap().paste_remembers_layers);
|
|
let v: Value = serde_json::from_slice(&vectorcraft_format::save(&Document::new(10.0, 10.0), false)).unwrap();
|
|
assert!(v.get("paste_remembers_layers").is_none(), "off is not written");
|
|
assert!(!vectorcraft_format::load(v.to_string().as_bytes()).unwrap().paste_remembers_layers);
|
|
}
|