mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 07:46:07 +00:00
select.byNote compares letter case one character at a time, so a final sigma matches σ, and Whole Word checks its boundaries against the note's own characters, so İ neither ends a word early nor matches half of itself. The search runs in time proportional to the note's length. Set Selection passes its fields to ui.action.insertSelectObject as they are, so a note or option of another type set through ui.dialog.set is an error and the dialog stays open.
383 lines
19 KiB
Rust
383 lines
19 KiB
Rust
//! Window → Attributes (`attributes.set`, `attributes.info`, `path.setFillRule`, `path.reverse`,
|
||
//! and `select.byNote`, which selects objects by their note),
|
||
//! the links SVG export writes for objects with a URL, and what objects keep for other software
|
||
//! (their `data-*` attributes and their attributes in other applications' namespaces).
|
||
|
||
use serde_json::{Value, json};
|
||
use vectorcraft_doc::{ImageMap, NodeKind};
|
||
use vectorcraft_geom::{Affine, FillRule};
|
||
use vectorcraft_render::{RenderOptions, Renderer};
|
||
|
||
use crate::*;
|
||
|
||
fn session() -> Session {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||
s
|
||
}
|
||
|
||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||
s.execute(id, &p).unwrap()
|
||
}
|
||
|
||
fn rect(s: &mut Session, x: f64) -> NodeId {
|
||
NodeId(run(s, "shape.rectangle", json!({"x": x, "y": 10, "width": 30, "height": 30}))["id"].as_u64().unwrap())
|
||
}
|
||
|
||
fn info(s: &mut Session, p: Value) -> Value {
|
||
run(s, "attributes.info", p)
|
||
}
|
||
|
||
#[test]
|
||
fn each_attribute_sets_reads_back_and_undoes_in_one_step() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 10.0);
|
||
let i = info(&mut s, json!({}));
|
||
assert_eq!((i["overprintFill"].clone(), i["showCenter"].clone(), i["imageMap"].clone()), (json!(false), json!(true), json!("none")));
|
||
assert_eq!((i["url"].clone(), i["note"].clone(), i["fillRule"].clone()), (json!(""), json!(""), json!("nonZero")));
|
||
let changed = run(
|
||
&mut s,
|
||
"attributes.set",
|
||
json!({"overprintFill": true, "overprintStroke": true, "showCenter": false, "imageMap": "Rectangle", "url": " https://example.com ", "note": "hello"}),
|
||
);
|
||
assert_eq!(changed["changed"], 1);
|
||
let i = info(&mut s, json!({}));
|
||
for (k, v) in [
|
||
("overprintFill", json!(true)),
|
||
("overprintStroke", json!(true)),
|
||
("showCenter", json!(false)),
|
||
("imageMap", json!("rectangle")),
|
||
("url", json!("https://example.com")),
|
||
("note", json!("hello")),
|
||
("recentUrls", json!(["https://example.com"])),
|
||
] {
|
||
assert_eq!(i[k], v, "{k}");
|
||
}
|
||
let n = s.doc().unwrap().doc.node(a).unwrap().clone();
|
||
let at = n.extras().attrs.as_deref().unwrap();
|
||
assert_eq!((at.show_center, at.image_map, n.url()), (Some(false), ImageMap::Rectangle, Some("https://example.com")));
|
||
assert!(!n.shows_center());
|
||
// One undo step takes everything back, and the attributes go away entirely.
|
||
run(&mut s, "edit.undo", json!({}));
|
||
let n = s.doc().unwrap().doc.node(a).unwrap().clone();
|
||
assert!(n.extras().attrs.is_none() && !n.has_overprint());
|
||
// Nothing to change: no undo step; bad values are refused.
|
||
let before = s.doc().unwrap().revision;
|
||
assert_eq!(run(&mut s, "attributes.set", json!({"showCenter": true}))["changed"], 0);
|
||
assert_eq!(s.doc().unwrap().revision, before);
|
||
assert!(s.execute("attributes.set", &json!({"imageMap": "circle"})).is_err());
|
||
assert!(s.execute("attributes.set", &json!({})).is_err());
|
||
// Clearing the URL and note drops the attributes.
|
||
run(&mut s, "attributes.set", json!({"url": "u", "note": "n"}));
|
||
run(&mut s, "attributes.set", json!({"url": "", "note": ""}));
|
||
assert!(s.doc().unwrap().doc.node(a).unwrap().extras().attrs.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn values_that_differ_read_null_and_recent_urls_keep_the_newest_first() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 0.0);
|
||
let b = rect(&mut s, 50.0);
|
||
run(&mut s, "attributes.set", json!({"ids": [a.0], "url": "https://a.example", "note": "a"}));
|
||
run(&mut s, "attributes.set", json!({"ids": [b.0], "url": "https://b.example"}));
|
||
run(&mut s, "attributes.set", json!({"ids": [a.0], "url": "https://a.example"}));
|
||
let i = info(&mut s, json!({"ids": [a.0, b.0]}));
|
||
assert_eq!((i["url"].clone(), i["note"].clone(), i["imageMap"].clone()), (Value::Null, Value::Null, json!("none")));
|
||
assert_eq!(i["recentUrls"], json!(["https://a.example", "https://b.example"]));
|
||
// A pen path doesn't show its centre by default; a rectangle does.
|
||
let p = NodeId(run(&mut s, "path.create", json!({"anchors": [{"x": 0, "y": 0}, {"x": 10, "y": 10}]}))["id"].as_u64().unwrap());
|
||
assert_eq!(info(&mut s, json!({"ids": [p.0]}))["showCenter"], false);
|
||
assert_eq!(info(&mut s, json!({"ids": [p.0, a.0]}))["showCenter"], Value::Null);
|
||
}
|
||
|
||
/// A five-pointed star drawn as one self-intersecting path (its centre is wound twice).
|
||
fn star(s: &mut Session) -> NodeId {
|
||
let (c, r) = (50.0f64, 45.0f64);
|
||
let anchors: Vec<Value> = (0..5)
|
||
.map(|i| {
|
||
let a = (-90.0 + 144.0 * i as f64).to_radians();
|
||
json!({"x": c + r * a.cos(), "y": c + r * a.sin()})
|
||
})
|
||
.collect();
|
||
let id = NodeId(run(s, "path.create", json!({"anchors": anchors, "closed": true}))["id"].as_u64().unwrap());
|
||
run(s, "paint.setFill", json!({"ids": [id.0], "color": "#ff0000"}));
|
||
run(s, "paint.setStroke", json!({"ids": [id.0], "none": true}));
|
||
id
|
||
}
|
||
|
||
fn red_at_centre(s: &Session) -> bool {
|
||
let doc = s.doc().unwrap().doc.clone();
|
||
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
||
let p = Renderer::new().render(&doc, 100, 100, Affine::IDENTITY, &opts).pixel(50, 52);
|
||
p[0] > 200 && p[1] < 80
|
||
}
|
||
|
||
#[test]
|
||
fn an_even_odd_star_has_a_hollow_centre() {
|
||
let mut s = session();
|
||
let id = star(&mut s);
|
||
assert!(red_at_centre(&s), "non-zero fills the centre");
|
||
assert_eq!(run(&mut s, "path.setFillRule", json!({"rule": "evenOdd"}))["changed"], 1);
|
||
assert!(matches!(s.doc().unwrap().doc.node(id).unwrap().kind, NodeKind::Path { rule: FillRule::EvenOdd, .. }));
|
||
assert_eq!(info(&mut s, json!({}))["fillRule"], "evenOdd");
|
||
assert!(!red_at_centre(&s), "even-odd leaves it empty");
|
||
assert_eq!(run(&mut s, "path.setFillRule", json!({"rule": "evenOdd"}))["changed"], 0);
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert!(red_at_centre(&s));
|
||
assert!(s.execute("path.setFillRule", &json!({"rule": "odd"})).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn fill_rule_reaches_compound_paths_and_the_paths_in_groups() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 0.0);
|
||
let b = rect(&mut s, 50.0);
|
||
run(&mut s, "select.set", json!({"ids": [a.0, b.0]}));
|
||
run(&mut s, "object.compoundPath.make", json!({}));
|
||
let compound = s.doc().unwrap().selection.objects[0];
|
||
let d = rect(&mut s, 0.0);
|
||
run(&mut s, "select.set", json!({"ids": [compound.0, d.0]}));
|
||
run(&mut s, "object.group", json!({}));
|
||
assert_eq!(run(&mut s, "path.setFillRule", json!({"rule": "evenOdd"}))["changed"], 2, "the compound path and the rectangle");
|
||
let doc = &s.doc().unwrap().doc;
|
||
assert!(matches!(doc.node(compound).unwrap().kind, NodeKind::Compound { rule: FillRule::EvenOdd, .. }));
|
||
assert!(matches!(doc.node(d).unwrap().kind, NodeKind::Path { rule: FillRule::EvenOdd, .. }));
|
||
}
|
||
|
||
#[test]
|
||
fn reverse_path_direction_on_and_off_set_the_winding() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 10.0);
|
||
let ccw = |s: &Session| s.doc().unwrap().doc.node(a).unwrap().path_data().unwrap().subpaths[0].signed_area() < 0.0;
|
||
let start = ccw(&s);
|
||
assert_eq!(info(&mut s, json!({}))["reversed"], start);
|
||
// Off / On set it; asking again changes nothing.
|
||
assert_eq!(run(&mut s, "path.reverse", json!({"reversed": !start}))["changed"], 1);
|
||
assert_eq!(ccw(&s), !start);
|
||
assert_eq!(info(&mut s, json!({}))["reversed"], !start);
|
||
assert_eq!(run(&mut s, "path.reverse", json!({"reversed": !start}))["changed"], 0);
|
||
// Without `reversed` it flips.
|
||
run(&mut s, "path.reverse", json!({}));
|
||
assert_eq!(ccw(&s), start);
|
||
run(&mut s, "select.none", json!({}));
|
||
assert!(s.execute("path.reverse", &json!({})).is_err(), "nothing to reverse");
|
||
}
|
||
|
||
#[test]
|
||
fn attributes_survive_a_native_round_trip() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 10.0);
|
||
run(&mut s, "attributes.set", json!({"showCenter": false, "imageMap": "polygon", "url": "https://example.com/x", "note": "keep"}));
|
||
let doc = s.doc().unwrap().doc.clone();
|
||
let back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap();
|
||
assert_eq!(back.node(a).unwrap().extras().attrs, doc.node(a).unwrap().extras().attrs);
|
||
assert!(back.node(a).unwrap().extras().attrs.is_some());
|
||
}
|
||
|
||
#[test]
|
||
fn svg_export_links_objects_and_import_reads_the_links_back() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 10.0);
|
||
rect(&mut s, 50.0);
|
||
run(&mut s, "attributes.set", json!({"ids": [a.0], "url": "https://example.com/?a=1&b=2"}));
|
||
let svg = vectorcraft_svg::export(&s.doc().unwrap().doc, &Default::default());
|
||
assert_eq!(svg.matches("<a ").count(), 1, "{svg}");
|
||
assert!(svg.contains("xlink:href=\"https://example.com/?a=1&b=2\""), "{svg}");
|
||
let back = vectorcraft_svg::import(&svg).unwrap();
|
||
let mut urls = vec![];
|
||
back.walk(|n| {
|
||
if let NodeKind::Path { .. } = n.kind {
|
||
urls.push(n.url().map(str::to_string));
|
||
}
|
||
});
|
||
assert_eq!(urls, [Some("https://example.com/?a=1&b=2".to_string()), None], "the link wraps the path alone");
|
||
}
|
||
|
||
/// #522: an object's own data (SVG `data-*` attributes) is set with `object.setProps {data}`,
|
||
/// undone in one step, written to SVG and kept by the native format.
|
||
#[test]
|
||
fn object_data_is_set_saved_and_written_to_svg() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 10.0);
|
||
let data = |s: &Session| s.doc().unwrap().doc.node(a).unwrap().extras().attrs.as_deref().map(|x| x.data.clone()).unwrap_or_default();
|
||
let pair = |k: &str, v: &str| (k.to_string(), v.to_string());
|
||
run(&mut s, "object.setProps", json!({"data": {"pivot": "60,100", "z": 3}}));
|
||
assert_eq!(data(&s), [pair("pivot", "60,100"), pair("z", "3")], "values are text");
|
||
run(&mut s, "object.setProps", json!({"data": {"data-pivot": "1,2", "z": null}}));
|
||
assert_eq!(data(&s), [pair("pivot", "1,2")], "a data- prefix is optional; null removes a key");
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert_eq!(data(&s).len(), 2, "one step");
|
||
for bad in [json!({"data": {"vc-x": "1"}}), json!({"data": {"name": "x"}}), json!({"data": {"a b": "1"}}), json!({"data": "pivot"})] {
|
||
assert!(s.execute("object.setProps", &bad).is_err(), "{bad}");
|
||
}
|
||
let svg = run(&mut s, "document.serialize", json!({"format": "svg"}))["text"].as_str().unwrap().to_string();
|
||
assert!(svg.contains("data-pivot=\"60,100\" data-z=\"3\""), "{svg}");
|
||
let native = vectorcraft_format::save(&s.doc().unwrap().doc, false);
|
||
let back = vectorcraft_format::load(&native).unwrap();
|
||
assert_eq!(back.node(a).unwrap().extras().attrs, s.doc().unwrap().doc.node(a).unwrap().extras().attrs);
|
||
}
|
||
|
||
/// The selected objects after `select.byNote` with `p`, or its error.
|
||
fn by_note(s: &mut Session, p: Value) -> std::result::Result<Vec<u64>, String> {
|
||
s.execute("select.byNote", &p).map_err(|e| e.to_string())?;
|
||
Ok(s.doc().unwrap().selection.objects.iter().map(|id| id.0).collect())
|
||
}
|
||
|
||
fn note(s: &mut Session, id: NodeId, note: &str) {
|
||
run(s, "attributes.set", json!({"ids": [id.0], "note": note}));
|
||
}
|
||
|
||
/// Select by Note finds a note anywhere and in any letter case by default, replaces the selection
|
||
/// with every object it finds, in paint order, and is not an undo step. Case Sensitive and Whole
|
||
/// Word narrow it; Reselect repeats it.
|
||
#[test]
|
||
fn select_by_note_matches_part_of_a_note_in_any_case_unless_asked() {
|
||
let mut s = session();
|
||
let [a, b, c, d] = [0.0, 20.0, 40.0, 60.0].map(|x| rect(&mut s, x));
|
||
note(&mut s, a, "Logo front");
|
||
note(&mut s, b, "logo");
|
||
note(&mut s, c, "Logotype");
|
||
note(&mut s, d, "back");
|
||
run(&mut s, "select.set", json!({"ids": [d.0]}));
|
||
let undo = s.doc().unwrap().history.undo.len();
|
||
assert_eq!(by_note(&mut s, json!({"note": " LOGO "})), Ok(vec![a.0, b.0, c.0]), "outer spaces are left out");
|
||
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no undo step");
|
||
assert_eq!(by_note(&mut s, json!({"note": "logo", "caseSensitive": true})), Ok(vec![b.0]));
|
||
assert_eq!(by_note(&mut s, json!({"note": "logo", "wholeWord": true})), Ok(vec![a.0, b.0]));
|
||
assert_eq!(by_note(&mut s, json!({"note": "Logo", "caseSensitive": true, "wholeWord": true})), Ok(vec![a.0]));
|
||
assert_eq!(by_note(&mut s, json!({"note": "o f"})), Ok(vec![a.0]), "spaces inside the note count");
|
||
run(&mut s, "select.none", json!({}));
|
||
run(&mut s, "select.reselect", json!({}));
|
||
assert_eq!(s.doc().unwrap().selection.objects, [a]);
|
||
}
|
||
|
||
/// Whole Word looks at every place the text occurs, also places that overlap.
|
||
#[test]
|
||
fn select_by_note_whole_word_matches_overlapping_places_and_other_scripts() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 0.0);
|
||
let matches = |s: &mut Session, text: &str, p: Value| {
|
||
note(s, a, text);
|
||
by_note(s, p).is_ok()
|
||
};
|
||
assert!(matches(&mut s, "a aa a a", json!({"note": "a a", "wholeWord": true})));
|
||
assert!(!matches(&mut s, "aa_a", json!({"note": "a", "wholeWord": true})));
|
||
assert!(matches(&mut s, "x-ray, x", json!({"note": "x", "wholeWord": true})));
|
||
assert!(matches(&mut s, "ÉTIQUETTE", json!({"note": "étiquette"})));
|
||
assert!(!matches(&mut s, "ÉTIQUETTE", json!({"note": "étiquette", "caseSensitive": true})));
|
||
assert!(matches(&mut s, "标签 一", json!({"note": "一", "wholeWord": true})));
|
||
assert!(!matches(&mut s, "", json!({"note": "a"})));
|
||
}
|
||
|
||
/// Letter case is compared one character at a time: a final Σ, whose lowercase is ς, matches Σ
|
||
/// and σ. Whole Word looks at the characters of the note itself, so a letter whose lowercase
|
||
/// takes two characters (İ) neither ends a word early nor matches half of itself.
|
||
#[test]
|
||
fn select_by_note_compares_letter_case_per_character() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 0.0);
|
||
let matches = |s: &mut Session, text: &str, p: Value| {
|
||
note(s, a, text);
|
||
by_note(s, p).is_ok()
|
||
};
|
||
assert!(matches(&mut s, "ΟΣ", json!({"note": "Σ"})));
|
||
assert!(matches(&mut s, "ΟΣ", json!({"note": "σ"})));
|
||
assert!(matches(&mut s, "ΟΣ", json!({"note": "ος", "wholeWord": true})));
|
||
assert!(!matches(&mut s, "ΟΣ", json!({"note": "σ", "caseSensitive": true})));
|
||
assert!(!matches(&mut s, "İx", json!({"note": "i", "wholeWord": true})));
|
||
assert!(matches(&mut s, "İ x", json!({"note": "İ", "wholeWord": true})));
|
||
assert!(!matches(&mut s, "İ x", json!({"note": "i", "wholeWord": true})));
|
||
}
|
||
|
||
/// Select by Note leaves out hidden and locked objects, selects a group whose note matches without
|
||
/// its members and a member whose note matches on its own, keeps to the isolated group, and leaves
|
||
/// the selection as it was when it finds nothing. Bad params are errors that change nothing.
|
||
#[test]
|
||
fn select_by_note_takes_only_objects_that_can_be_selected() {
|
||
let mut s = session();
|
||
let [a, b, c, d, e] = [0.0, 10.0, 20.0, 30.0, 40.0].map(|x| rect(&mut s, x));
|
||
for id in [a, b, c, d, e] {
|
||
note(&mut s, id, "tag");
|
||
}
|
||
run(&mut s, "select.set", json!({"ids": [a.0]}));
|
||
run(&mut s, "object.hide", json!({}));
|
||
run(&mut s, "select.set", json!({"ids": [b.0]}));
|
||
run(&mut s, "object.lock", json!({}));
|
||
run(&mut s, "select.set", json!({"ids": [c.0, d.0]}));
|
||
let g = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap());
|
||
note(&mut s, g, "tag group");
|
||
assert_eq!(by_note(&mut s, json!({"note": "tag"})), Ok(vec![g.0, e.0]), "the group, not its members");
|
||
note(&mut s, g, "box");
|
||
assert_eq!(by_note(&mut s, json!({"note": "tag"})), Ok(vec![c.0, d.0, e.0]));
|
||
run(&mut s, "object.isolate", json!({"id": g.0}));
|
||
assert_eq!(by_note(&mut s, json!({"note": "tag"})), Ok(vec![c.0, d.0]), "inside the isolated group only");
|
||
run(&mut s, "select.set", json!({"ids": [c.0]}));
|
||
let missing = by_note(&mut s, json!({"note": "nothing"})).unwrap_err();
|
||
assert!(missing.contains("no object that can be selected has a note matching “nothing”"), "{missing}");
|
||
assert_eq!(s.doc().unwrap().selection.objects, [c], "the selection stays");
|
||
for bad in [
|
||
json!({}),
|
||
json!({"note": " "}),
|
||
json!({"note": 5}),
|
||
json!({"note": "x".repeat(1025)}),
|
||
json!({"note": "tag", "caseSensitive": "yes"}),
|
||
json!({"note": "tag", "wholeWord": 1}),
|
||
] {
|
||
assert!(s.execute("select.byNote", &bad).is_err(), "{bad}");
|
||
assert_eq!(s.doc().unwrap().selection.objects, [c], "{bad}");
|
||
}
|
||
assert!(by_note(&mut s, json!({"note": "x".repeat(1024)})).is_err_and(|e| e.contains("no object")), "1,024 bytes is allowed");
|
||
}
|
||
|
||
/// Under Lock Guides a guide whose note matches is not a match, and a search that finds only such
|
||
/// guides leaves the selection and the Puppet Warp pins on it as they were.
|
||
#[test]
|
||
fn select_by_note_leaves_locked_guides_out_before_changing_the_selection() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 10.0);
|
||
let g = rect(&mut s, 50.0);
|
||
note(&mut s, g, "edge");
|
||
run(&mut s, "select.set", json!({"ids": [g.0]}));
|
||
run(&mut s, "view.guides.make", json!({}));
|
||
run(&mut s, "view.guides.lock", json!({"locked": true}));
|
||
run(&mut s, "select.set", json!({"ids": [a.0]}));
|
||
run(&mut s, "object.puppetWarp", json!({"id": a.0, "rest": true, "pins": [[25, 25]], "moved": [[25, 25]]}));
|
||
run(&mut s, "select.set", json!({"ids": [a.0]}));
|
||
assert!(s.doc().unwrap().doc.puppet.is_some(), "a pin on the rectangle");
|
||
let missing = by_note(&mut s, json!({"note": "edge"})).unwrap_err();
|
||
assert!(missing.contains("no object that can be selected"), "{missing}");
|
||
let st = s.doc().unwrap();
|
||
assert_eq!(st.selection.objects, [a], "the selection stays");
|
||
assert!(st.doc.puppet.is_some(), "the pins stay");
|
||
}
|
||
|
||
/// An object's attributes in other applications' XML namespaces are its own, as its `data-*`
|
||
/// attributes are: a pasted copy and a duplicate keep them, deleting the object takes them with
|
||
/// it, and the native file and SVG export keep them.
|
||
#[test]
|
||
fn other_applications_attributes_go_with_their_object() {
|
||
let mut s = Session::new();
|
||
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" xmlns:plotter="https://example.org/plotter" width="100" height="100">
|
||
<rect width="20" height="20" plotter:pen="2"/></svg>"#;
|
||
run(&mut s, "document.open", json!({"name": "pens.svg", "dataBase64": vectorcraft_format::base64_encode(svg.as_bytes())}));
|
||
// The `plotter:pen` values on the document's objects.
|
||
let pens = |d: &vectorcraft_doc::Document| {
|
||
let mut out = vec![];
|
||
d.walk(|n| out.extend(n.extras().attrs.iter().flat_map(|a| a.foreign.iter().map(|f| f.value.clone()))));
|
||
out
|
||
};
|
||
assert_eq!(pens(&s.doc().unwrap().doc), ["2"]);
|
||
run(&mut s, "select.all", json!({}));
|
||
run(&mut s, "edit.copy", json!({}));
|
||
run(&mut s, "edit.paste", json!({}));
|
||
assert_eq!(pens(&s.doc().unwrap().doc), ["2", "2"], "a pasted copy keeps them");
|
||
run(&mut s, "edit.duplicate", json!({}));
|
||
assert_eq!(pens(&s.doc().unwrap().doc), ["2", "2", "2"], "so does a duplicate");
|
||
run(&mut s, "edit.clear", json!({}));
|
||
assert_eq!(pens(&s.doc().unwrap().doc), ["2", "2"], "a deleted object leaves none behind");
|
||
let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
|
||
assert_eq!(pens(&back), ["2", "2"], "the native file keeps them");
|
||
let out = run(&mut s, "document.serialize", json!({"format": "svg"}))["text"].as_str().unwrap().to_string();
|
||
assert_eq!((out.matches("xmlns:plotter=\"https://example.org/plotter\"").count(), out.matches("plotter:pen=\"2\"").count()), (1, 2), "{out}");
|
||
}
|