//! 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 = (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(" std::result::Result, 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#" "#; 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}"); }