use super::*; fn session_with_text(text: &str) -> (Session, u64) { let mut s = Session::new(); s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": text})).unwrap()["id"].as_u64().unwrap(); s.execute("select.set", &json!({"ids": [id]})).unwrap(); (s, id) } fn runs(s: &Session, id: u64) -> Vec { match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { NodeKind::Text(t) => t.runs.clone(), _ => panic!(), } } fn embedded_style(s: &Session, container: &str) -> (Option, f64, Option) { let d = &s.doc().unwrap().doc; let brushes = vectorcraft_brush::library(d); let roots: Vec<&Node> = match container { "symbol" => d.symbols.iter().map(|s| &*s.art).collect(), "pattern" => d.patterns.iter().flat_map(|p| p.art.iter().map(|a| &**a)).collect(), "graph" => d.graph_designs.iter().map(|g| &*g.art).collect(), "brush" => brushes.iter().flat_map(|b| b.art()).collect(), _ => panic!("unknown container"), }; let mut found = None; for root in roots { vectorcraft_doc::swatches::walk_with_masks(root, &mut |n| { if let NodeKind::Text(t) = &n.kind { found = Some((t.runs[0].style.style_name.clone(), t.runs[0].style.size, t.para.style_name.clone())); } }); } found.unwrap() } fn embedded_style_session(kind: &str, container: &str) -> Session { let (mut s, id) = session_with_text("Styled"); s.execute(&format!("{kind}Style.new"), &json!({"name": "Body", "attrs": if kind == "char" { json!({"size": 24}) } else { json!({}) }})).unwrap(); s.execute(&format!("{kind}Style.apply"), &json!({"name": "Body", "id": id})).unwrap(); s.execute("select.set", &json!({"ids": [id]})).unwrap(); match container { "symbol" => { let symbol = s.execute("symbol.new", &json!({"name": "Mark"})).unwrap(); s.execute("edit.clear", &json!({"ids": [symbol["id"]]})).unwrap(); } "pattern" => { s.execute("object.pattern.make", &json!({"edit": false})).unwrap(); if s.doc().unwrap().doc.node(NodeId(id)).is_some() { s.execute("edit.clear", &json!({"ids": [id]})).unwrap(); } } "graph" => { s.execute("graph.design", &json!({"new": "Bar"})).unwrap(); s.execute("edit.clear", &json!({"ids": [id]})).unwrap(); } "brush" => { s.execute("brush.new", &json!({"type": "art", "name": "Lettering"})).unwrap(); s.execute("edit.clear", &json!({"ids": [id]})).unwrap(); } _ => panic!("unknown container"), } s } #[test] fn character_style_options_update_embedded_art() { for container in ["symbol", "pattern", "graph", "brush"] { let mut s = embedded_style_session("char", container); assert_eq!(embedded_style(&s, container).0.as_deref(), Some("Body"), "{container}"); let before = s.doc().unwrap().doc.clone(); let undo = s.doc().unwrap().history.undo.len(); s.execute("charStyle.edit", &json!({"name": "Body", "to": "Heading", "attrs": {"size": 18}})).unwrap(); assert_eq!(embedded_style(&s, container).0.as_deref(), Some("Heading"), "{container}"); assert_eq!(embedded_style(&s, container).1, 18.0, "{container}"); assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "{container}"); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(s.doc().unwrap().doc, before, "{container}"); s.execute("edit.redo", &json!({})).unwrap(); let undo = s.doc().unwrap().history.undo.len(); s.execute("charStyle.delete", &json!({"name": "Heading"})).unwrap(); assert_eq!(embedded_style(&s, container).0, None, "{container}"); assert_eq!(embedded_style(&s, container).1, 18.0, "{container}"); assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "{container}"); } } #[test] fn character_style_edit_survives_canceling_pattern_edit() { let mut s = embedded_style_session("char", "pattern"); let name = s.doc().unwrap().doc.patterns[0].name.clone(); s.execute("object.pattern.edit", &json!({"name": name})).unwrap(); s.execute("charStyle.edit", &json!({"name": "Body", "to": "Heading", "attrs": {"size": 18}})).unwrap(); s.execute("object.pattern.cancel", &json!({})).unwrap(); assert_eq!(embedded_style(&s, "pattern").0.as_deref(), Some("Heading")); assert_eq!(embedded_style(&s, "pattern").1, 18.0); } #[test] fn paragraph_style_rename_updates_symbol_art() { let mut s = embedded_style_session("para", "symbol"); assert_eq!(embedded_style(&s, "symbol").2.as_deref(), Some("Body")); s.execute("paraStyle.rename", &json!({"name": "Body", "to": "Heading"})).unwrap(); assert_eq!(embedded_style(&s, "symbol").2.as_deref(), Some("Heading")); } #[test] fn character_style_options_keep_text_free_layer_shared() { let (mut s, id) = session_with_text("Styled"); s.execute("charStyle.new", &json!({"name": "Body", "attrs": {"size": 24}})).unwrap(); s.execute("charStyle.apply", &json!({"name": "Body", "id": id})).unwrap(); let empty_id = s.execute("layer.new", &json!({"name": "Empty"})).unwrap()["id"].as_u64().unwrap(); let before = s.doc().unwrap().doc.layers.iter().find(|layer| layer.id == NodeId(empty_id)).unwrap().clone(); s.execute("charStyle.edit", &json!({"name": "Body", "to": "Heading", "attrs": {"size": 18}})).unwrap(); let d = &s.doc().unwrap().doc; let after = d.layers.iter().find(|layer| layer.id == NodeId(empty_id)).unwrap(); assert!(std::sync::Arc::ptr_eq(&before, after)); assert_eq!(runs(&s, id)[0].style.style_name.as_deref(), Some("Heading")); assert_eq!(runs(&s, id)[0].style.size, 18.0); } #[test] fn unchanged_style_options_keep_undo_and_redo_with_and_without_preview() { for preview in [false, true] { let (mut s, _) = session_with_text("Text"); s.execute("charStyle.new", &json!({"name": "Body", "attrs": {"size": 18, "leading": null}})).unwrap(); s.execute("charStyle.new", &json!({"name": "Other", "attrs": {}})).unwrap(); s.execute("edit.undo", &json!({})).unwrap(); let before = s.doc().unwrap().doc.clone(); let undo = s.doc().unwrap().history.undo.len(); let redo = s.doc().unwrap().history.redo.len(); assert!(redo > 0); let params = json!({"name": "Body", "to": " Body ", "attrs": {"size": 18.0, "leading": null}}); if preview { s.begin_interaction("Character Style Options").unwrap(); s.preview("charStyle.edit", ¶ms).unwrap(); s.commit_interaction().unwrap(); } else { s.execute("charStyle.edit", ¶ms).unwrap(); } assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc), "preview={preview}"); assert_eq!(s.doc().unwrap().history.undo.len(), undo, "preview={preview}"); assert_eq!(s.doc().unwrap().history.redo.len(), redo, "preview={preview}"); s.execute("edit.redo", &json!({})).unwrap(); assert!(s.doc().unwrap().doc.char_styles.iter().any(|style| style.name == "Other")); } } #[test] fn unchanged_style_attributes_keep_undo_and_redo_for_both_kinds() { for (command, para) in [("charStyle", false), ("paraStyle", true)] { let (mut s, _) = session_with_text("Text"); let attrs = if para { json!({"space_before": 18}) } else { json!({"size": 18}) }; s.execute(&format!("{command}.new"), &json!({"name": "Body", "attrs": attrs})).unwrap(); s.execute(&format!("{command}.new"), &json!({"name": "Other", "attrs": {}})).unwrap(); s.execute("edit.undo", &json!({})).unwrap(); let before = s.doc().unwrap().doc.clone(); let undo = s.doc().unwrap().history.undo.len(); let redo = s.doc().unwrap().history.redo.len(); assert!(redo > 0); let attrs = if para { json!({"space_before": 18.0}) } else { json!({"size": 18.0}) }; s.execute(&format!("{command}.setAttrs"), &json!({"name": "Body", "attrs": attrs})).unwrap(); assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc), "{command}"); assert_eq!(s.doc().unwrap().history.undo.len(), undo, "{command}"); assert_eq!(s.doc().unwrap().history.redo.len(), redo, "{command}"); } } #[test] fn character_style_options_edit_applies_to_users_and_is_one_saved_undo_step() { let (mut s, id) = session_with_text("First second"); s.execute("charStyle.new", &json!({"name":"Body", "attrs":{"size":24.0,"tracking":20.0,"baseline_shift":3.0}})).unwrap(); s.execute("charStyle.apply", &json!({"name":"Body", "id":id, "start":0, "end":5})).unwrap(); s.execute("charStyle.apply", &json!({"name":"Body", "id":id, "start":6, "end":12})).unwrap(); s.execute("text.setRangeStyle", &json!({"id":id,"start":6,"end":12,"size":31.0})).unwrap(); let before = s.doc().unwrap().doc.clone(); let undo = s.doc().unwrap().history.undo.len(); s.execute("charStyle.edit", &json!({"name":"Body","to":"Text","attrs":{"size":18.0,"tracking":40.0,"baseline_shift":3.0}})).unwrap(); let changed = s.doc().unwrap().doc.clone(); assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); assert_eq!( runs(&s, id).iter().map(|r| (r.style.style_name.as_deref(), r.style.size, r.style.tracking)).collect::>(), [(Some("Text"), 18.0, 40.0), (None, 12.0, 0.0), (Some("Text"), 31.0, 40.0)] ); let future = s.execute("text.create", &json!({"x":20,"y":90,"text":""})).unwrap()["id"].as_u64().unwrap(); s.execute("charStyle.apply", &json!({"name":"Text","id":future})).unwrap(); s.execute("text.editRange", &json!({"id":future,"start":0,"end":0,"insert":"Later"})).unwrap(); assert_eq!((runs(&s, future)[0].style.size, runs(&s, future)[0].style.tracking), (18.0, 40.0)); let saved = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); assert_eq!( saved.char_styles.iter().find(|d| d.name == "Text").unwrap().attrs, s.doc().unwrap().doc.char_styles.iter().find(|d| d.name == "Text").unwrap().attrs ); s.execute("edit.undo", &json!({})).unwrap(); s.execute("edit.undo", &json!({})).unwrap(); s.execute("edit.undo", &json!({})).unwrap(); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(s.doc().unwrap().doc, before); s.execute("edit.redo", &json!({})).unwrap(); assert_eq!(s.doc().unwrap().doc, changed); } #[test] fn character_style_options_edit_sets_the_advanced_character_formats() { let (mut s, id) = session_with_text("First second"); s.execute("charStyle.new", &json!({"name":"Body", "attrs":{"h_scale":100.0,"rotation":0.0}})).unwrap(); s.execute("charStyle.apply", &json!({"name":"Body", "id":id})).unwrap(); s.execute("text.setRangeStyle", &json!({"id":id,"start":6,"end":12,"rotation":30.0})).unwrap(); let before = s.doc().unwrap().doc.clone(); let attrs = json!({"h_scale":80.0,"v_scale":120.0,"baseline_shift":-2.0,"rotation":15.0,"language":"German"}); s.execute("charStyle.edit", &json!({"name":"Body","to":"Body","attrs":attrs})).unwrap(); let changed = s.doc().unwrap().doc.clone(); let def = changed.char_styles.iter().find(|d| d.name == "Body").unwrap(); assert_eq!(def.attrs["language"], json!("de")); assert_eq!( runs(&s, id) .iter() .map(|r| (r.style.h_scale, r.style.v_scale, r.style.baseline_shift, r.style.rotation, r.style.language.clone())) .collect::>(), [(80.0, 120.0, -2.0, 15.0, Some("de".to_string())), (80.0, 120.0, -2.0, 30.0, Some("de".to_string()))] ); let saved = vectorcraft_format::load(&vectorcraft_format::save(&changed, false)).unwrap(); assert_eq!(saved.char_styles.iter().find(|d| d.name == "Body").unwrap().attrs, def.attrs); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(s.doc().unwrap().doc, before); s.execute("edit.redo", &json!({})).unwrap(); assert_eq!(s.doc().unwrap().doc, changed); } #[test] fn character_style_options_edit_keeps_a_language_tag_from_a_file() { let (mut s, _) = session_with_text("Text"); s.execute("charStyle.new", &json!({"name":"Body", "attrs":{}})).unwrap(); if let Some(def) = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).char_styles.iter_mut().find(|d| d.name == "Body") { def.attrs.insert("language".into(), json!("x-file")); } s.execute("charStyle.edit", &json!({"name":"Body","to":"Body","attrs":{"size":18.0,"language":"x-file"}})).unwrap(); assert_eq!(s.doc().unwrap().doc.char_styles.iter().find(|d| d.name == "Body").unwrap().attrs["language"], json!("x-file")); let error = s.execute("charStyle.edit", &json!({"name":"Body","to":"Body","attrs":{"language":"x-other"}})).unwrap_err().to_string(); assert!(error.contains("unknown language"), "{error}"); } #[test] fn new_character_style_keeps_a_language_tag_from_the_text() { let (mut s, id) = session_with_text("Text"); if let Some(NodeKind::Text(t)) = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(NodeId(id)).map(|n| &mut n.kind) { t.runs[0].style.language = Some("x-file".into()); } s.execute("charStyle.new", &json!({"name":"Body","id":id,"attrs":{"size":18.0,"language":"x-file"}})).unwrap(); assert_eq!(s.doc().unwrap().doc.char_styles.iter().find(|d| d.name == "Body").unwrap().attrs["language"], json!("x-file")); let error = s.execute("charStyle.new", &json!({"name":"Other","id":id,"attrs":{"language":"x-other"}})).unwrap_err().to_string(); assert!(error.contains("unknown language"), "{error}"); } /// The fill of the first run of `id` and its overprint flags, its stroke and stroke weight. fn run_paints(s: &Session, id: u64) -> (vectorcraft_color::Paint, vectorcraft_color::Paint, f64, bool, bool) { let st = runs(s, id)[0].style.clone(); (st.fill, st.stroke, st.stroke_width, st.overprint_fill, st.overprint_stroke) } #[test] fn character_style_color_and_features_reach_text_and_follow_swatch_edits() { let (mut s, id) = session_with_text("Text 1/2"); s.execute("swatch.new", &json!({"name": "Brand", "color": "#2050a0", "global": true})).unwrap(); let brand = s.doc().unwrap().doc.tint_paint("Brand", 1.0).unwrap(); let mut tinted = serde_json::to_value(s.doc().unwrap().doc.tint_paint("Brand", 1.0).unwrap()).unwrap(); // The command takes the color of the link at the tint, whatever color is sent. tinted["tint"] = json!(0.4); let attrs = json!({"fill": brand, "stroke": tinted, "stroke_width": 2.0, "overprint_fill": true, "overprint_stroke": false, "features": ["dlig", "-liga", "tnum", "lnum", "-onum", "-pnum", "ss02", "-ss01"]}); s.execute("charStyle.new", &json!({"name": "Ink", "attrs": attrs})).unwrap(); s.execute("charStyle.apply", &json!({"name": "Ink", "id": id})).unwrap(); let want_stroke = s.doc().unwrap().doc.tint_paint("Brand", 0.4).unwrap(); assert_eq!(run_paints(&s, id), (brand, want_stroke.clone(), 2.0, true, false)); assert_eq!(runs(&s, id)[0].style.features, ["dlig", "-liga", "tnum", "lnum", "-onum", "-pnum", "ss02", "-ss01"]); let def = |s: &Session| s.doc().unwrap().doc.char_styles.iter().find(|d| d.name == "Ink").unwrap().attrs.clone(); assert_eq!(def(&s)["stroke"], serde_json::to_value(&want_stroke).unwrap()); let saved = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); assert_eq!(saved.char_styles.iter().find(|d| d.name == "Ink").unwrap().attrs, def(&s)); s.execute("swatch.edit", &json!({"name": "Brand", "color": "#a02050"})).unwrap(); let (brand, tint40) = (s.doc().unwrap().doc.tint_paint("Brand", 1.0).unwrap(), s.doc().unwrap().doc.tint_paint("Brand", 0.4).unwrap()); assert_eq!(run_paints(&s, id).0, brand); assert_eq!(run_paints(&s, id).1, tint40); assert_eq!(def(&s)["fill"], serde_json::to_value(&brand).unwrap()); assert_eq!(def(&s)["stroke"], serde_json::to_value(&tint40).unwrap()); let later = s.execute("text.create", &json!({"x": 10, "y": 90, "text": "Later"})).unwrap()["id"].as_u64().unwrap(); s.execute("charStyle.apply", &json!({"name": "Ink", "id": later})).unwrap(); assert_eq!(run_paints(&s, later).0, brand); } #[test] fn character_style_color_and_features_are_checked() { let (mut s, _) = session_with_text("Text"); s.execute("swatch.new", &json!({"name": "Brand", "color": "#2050a0", "global": true})).unwrap(); s.execute("charStyle.new", &json!({"name": "Body", "attrs": {}})).unwrap(); let mut over = serde_json::to_value(s.doc().unwrap().doc.tint_paint("Brand", 1.0).unwrap()).unwrap(); over["tint"] = json!(1.5); let mut missing = over.clone(); missing["tint"] = json!(0.5); missing["swatch"] = json!("Gone"); s.execute("swatch.new", &json!({"name": "Local", "color": "#102030"})).unwrap(); let mut local = missing.clone(); local["swatch"] = json!("Local"); let many: Vec = (0..=vectorcraft_text::MAX_STYLE_FEATURES).map(|_| "dlig".to_string()).collect(); let before = s.doc().unwrap().doc.clone(); let undo = s.doc().unwrap().history.undo.len(); for (attrs, message) in [ (json!({"fill": over}), "`fill.tint` must be from 0 to 1"), (json!({"stroke": missing}), "no swatch `Gone`"), (json!({"stroke": local}), "`Local` is a process colour"), (json!({"stroke_width": -1.0}), "`stroke_width` must be from 0 to 4000000"), (json!({"stroke_width": 5.0e6}), "`stroke_width` must be from 0 to 4000000"), (json!({"features": ["dlig", "zzzz"]}), "unknown OpenType feature `zzzz`"), (json!({"features": ["--liga"]}), "unknown OpenType feature `--liga`"), (json!({"features": many}), "features lists at most 256 tags"), (json!({"fill": {"type": "solid"}}), "missing field"), ] { for cmd in ["charStyle.edit", "charStyle.setAttrs", "charStyle.new"] { let p = match cmd { "charStyle.edit" => json!({"name": "Body", "to": "Body", "attrs": attrs}), "charStyle.new" => json!({"name": "Other", "attrs": attrs}), _ => json!({"name": "Body", "attrs": attrs}), }; let error = s.execute(cmd, &p).unwrap_err().to_string(); assert!(error.contains(message), "{cmd} {attrs}: {error}"); } } assert_eq!(s.doc().unwrap().doc, before); assert_eq!(s.doc().unwrap().history.undo.len(), undo); } #[test] fn style_paints_count_as_swatch_uses_and_gradient_stops_are_checked() { let (mut s, _) = session_with_text("Text"); s.execute("swatch.new", &json!({"name": "Brand", "color": "#2050a0", "global": true})).unwrap(); s.execute("swatch.new", &json!({"name": "Spare", "color": "#a02050", "global": true})).unwrap(); let unused = |s: &mut Session| s.execute("swatch.unused", &json!({})).unwrap()["names"].clone(); assert!(unused(&mut s).as_array().unwrap().contains(&json!("Brand"))); let brand = s.doc().unwrap().doc.tint_paint("Brand", 0.5).unwrap(); s.execute("charStyle.new", &json!({"name": "Ink", "attrs": {"stroke": brand}})).unwrap(); let names = unused(&mut s); assert!(!names.as_array().unwrap().contains(&json!("Brand")), "{names}"); assert!(names.as_array().unwrap().contains(&json!("Spare")), "{names}"); let mut g = vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient::default()); g.gradient.stops[0].swatch = Some("Brand".into()); g.gradient.stops[0].tint = 0.25; let fill = serde_json::to_value(vectorcraft_color::Paint::Gradient(Box::new(g.clone()))).unwrap(); s.execute("charStyle.new", &json!({"name": "Fade", "attrs": {"fill": fill}})).unwrap(); let def = s.doc().unwrap().doc.char_styles.iter().find(|d| d.name == "Fade").unwrap().attrs.clone(); let want = s.doc().unwrap().doc.global_color("Brand").unwrap().tinted(0.25); assert_eq!(def["fill"]["gradient"]["stops"][0]["color"], serde_json::to_value(want).unwrap()); g.gradient.stops[0].swatch = Some("Gone".into()); let gone = serde_json::to_value(vectorcraft_color::Paint::Gradient(Box::new(g))).unwrap(); let error = s.execute("charStyle.new", &json!({"name": "Bad", "attrs": {"fill": gone}})).unwrap_err().to_string(); assert!(error.contains("no swatch `Gone`"), "{error}"); } #[test] fn styles_from_files_keep_their_unknown_feature_tags_and_swatch_links() { let (mut s, _) = session_with_text("Text"); s.execute("charStyle.new", &json!({"name": "Body", "attrs": {}})).unwrap(); let linked = json!({"type": "solid", "color": {"model": "rgb", "r": 1.0, "g": 0.0, "b": 0.0}, "swatch": "Gone"}); if let Some(def) = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).char_styles.iter_mut().find(|d| d.name == "Body") { def.attrs.insert("features".into(), json!(["zzzz", "dlig"])); def.attrs.insert("fill".into(), linked.clone()); } let fill = s.doc().unwrap().doc.char_styles[0].attrs["fill"].clone(); s.execute("charStyle.edit", &json!({"name": "Body", "to": "Body", "attrs": {"features": ["zzzz", "-dlig"], "fill": fill}})).unwrap(); let def = s.doc().unwrap().doc.char_styles.iter().find(|d| d.name == "Body").unwrap().attrs.clone(); assert_eq!(def["features"], json!(["zzzz", "-dlig"])); let mut replaced = def.clone(); replaced.insert("size".into(), json!(14.0)); s.execute("charStyle.setAttrs", &json!({"name": "Body", "attrs": replaced})).unwrap(); s.execute("charStyle.setAttrs", &json!({"name": "Body", "attrs": def})).unwrap(); s.execute("charStyle.duplicate", &json!({"name": "Body"})).unwrap(); assert_eq!(s.doc().unwrap().doc.char_styles.iter().find(|d| d.name == "Body copy").unwrap().attrs, def); // A list longer than a style made here can have stays editable, but does not grow. let long: Vec = (0..300).map(|i| if i % 2 == 0 { "dlig".to_string() } else { "-dlig".to_string() }).collect(); if let Some(def) = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).char_styles.iter_mut().find(|d| d.name == "Body") { def.attrs.insert("features".into(), json!(long)); } let mut shorter = long.clone(); shorter.pop(); s.execute("charStyle.edit", &json!({"name": "Body", "to": "Body", "attrs": {"features": shorter}})).unwrap(); let mut longer = shorter.clone(); longer.extend(["swsh".to_string(), "salt".to_string()]); let error = s.execute("charStyle.edit", &json!({"name": "Body", "to": "Body", "attrs": {"features": longer}})).unwrap_err().to_string(); assert!(error.contains("features lists at most 256 tags"), "{error}"); } #[test] fn editing_character_style_clears_removed_size_without_losing_local_override() { let (mut s, id) = session_with_text("One two"); s.execute("charStyle.setAttrs", &json!({"name": NORMAL_CHAR, "attrs": {"size": 15.0}})).unwrap(); s.execute("charStyle.new", &json!({"name": "Body", "attrs": {"size": 24.0}})).unwrap(); s.execute("charStyle.apply", &json!({"name": "Body", "id": id})).unwrap(); s.execute("text.setRangeStyle", &json!({"id": id, "start": 4, "end": 7, "size": 31.0})).unwrap(); s.execute("charStyle.edit", &json!({"name": "Body", "to": "Body", "attrs": {}})).unwrap(); let r = runs(&s, id); assert_eq!((r[0].style.size, r[1].style.size), (15.0, 31.0)); } #[test] fn replacing_character_style_attrs_clears_removed_size() { let (mut s, id) = session_with_text("Text"); s.execute("charStyle.new", &json!({"name": "Body", "attrs": {"size": 24.0}})).unwrap(); s.execute("charStyle.apply", &json!({"name": "Body", "id": id})).unwrap(); s.execute("charStyle.setAttrs", &json!({"name": "Body", "attrs": {}})).unwrap(); assert_eq!(runs(&s, id)[0].style.size, 12.0); } #[test] fn character_style_options_edit_validates_names_and_allows_case_changes() { let (mut s, _) = session_with_text("Text"); s.execute("charStyle.new", &json!({"name":"Body","attrs":{"size":24.0}})).unwrap(); s.execute("charStyle.new", &json!({"name":"Other","attrs":{}})).unwrap(); for (name, to, message) in [(super::NORMAL_CHAR, "New", "can't be edited this way"), ("Body", " ", "give the style a name"), ("Body", "Other", "already exists")] { let error = s.execute("charStyle.edit", &json!({"name":name,"to":to,"attrs":{}})).unwrap_err().to_string(); assert!(error.contains(message), "{error}"); } s.execute("charStyle.edit", &json!({"name":"Body","to":"body","attrs":{"size":18.0}})).unwrap(); s.execute("charStyle.edit", &json!({"name":"body","to":" body ","attrs":{"size":20.0}})).unwrap(); assert_eq!(s.doc().unwrap().doc.char_styles.iter().find(|style| style.name == "body").unwrap().attrs["size"], json!(20.0)); } #[test] fn char_style_new_apply_range_redefine_keeps_overrides() { let (mut s, id) = session_with_text("Hello world"); s.execute("charStyle.new", &json!({"name": "Emphasis", "attrs": {"size": 24.0, "underline": true}})).unwrap(); // Style "world" only (a Type tool range). s.execute("charStyle.apply", &json!({"name": "Emphasis", "id": id, "start": 6, "end": 11})).unwrap(); let r = runs(&s, id); assert_eq!(r.len(), 2); assert_eq!((r[1].text.as_str(), r[1].style.size, r[1].style.underline), ("world", 24.0, true)); assert_eq!(r[1].style.style_name.as_deref(), Some("Emphasis")); assert_eq!(r[0].style.style_name, None); // A local override on the styled run survives a redefinition; other attributes update. s.execute("text.setRangeStyle", &json!({"id": id, "start": 6, "end": 11, "size": 30.0})).unwrap(); s.execute("charStyle.setAttrs", &json!({"name": "Emphasis", "attrs": {"size": 18.0, "underline": false, "tracking": 50.0}})).unwrap(); let r = runs(&s, id); assert_eq!((r[1].style.size, r[1].style.underline, r[1].style.tracking), (30.0, false, 50.0)); // Clear Overrides: back to exactly the style's attributes. s.execute("charStyle.apply", &json!({"name": "Emphasis", "id": id, "start": 6, "end": 11, "clearOverrides": true})).unwrap(); assert_eq!(runs(&s, id)[1].style.size, 18.0); let list = s.execute("charStyle.list", &json!({})).unwrap(); assert_eq!(list["styles"][0]["name"], NORMAL_CHAR); assert_eq!(list["styles"][1]["uses"], 1); } /// Applying and redefining a style record a step named from a template with the style's name /// in it (`HistoryEntry::named`), and undo and redo keep it. The Normal styles' names are /// marked as names VectorCraft gives. A new style's step has a name of its own. #[test] fn style_steps_are_named_from_a_template() { let (mut s, id) = session_with_text("Hello"); let last = |s: &Session| { let e = s.doc().unwrap().history.undo.last().unwrap(); (e.label.clone(), e.named().map(|n| (n.template, n.name.clone(), n.built_in))) }; let named = |template, name: &str, built_in| Some((template, name.to_string(), built_in)); s.execute("charStyle.new", &json!({"name": "Body"})).unwrap(); assert_eq!(last(&s), ("New Character Style".into(), None)); s.execute("charStyle.apply", &json!({"name": "Body", "id": id})).unwrap(); assert_eq!(last(&s), ("Apply Body".into(), named("Apply {name}", "Body", false))); s.execute("edit.undo", &json!({})).unwrap(); let redo = s.doc().unwrap().history.redo.last().unwrap().named().cloned(); assert_eq!(redo.map(|n| n.label()).as_deref(), Some("Apply Body")); s.execute("edit.redo", &json!({})).unwrap(); assert_eq!(last(&s), ("Apply Body".into(), named("Apply {name}", "Body", false))); s.execute("charStyle.apply", &json!({"name": NORMAL_CHAR, "id": id})).unwrap(); assert_eq!(last(&s), (format!("Apply {NORMAL_CHAR}"), named("Apply {name}", NORMAL_CHAR, true))); s.execute("paraStyle.new", &json!({"name": "Lead"})).unwrap(); assert_eq!(last(&s), ("New Paragraph Style".into(), None)); s.execute("paraStyle.setAttrs", &json!({"name": "Lead", "attrs": {"direction": "rightToLeft"}})).unwrap(); assert_eq!(last(&s), ("Redefine Lead".into(), named("Redefine {name}", "Lead", false))); // A step renamed since it was recorded is no longer named from the template. s.doc_mut().unwrap().history.undo.last_mut().unwrap().label = "Typing".into(); assert_eq!(last(&s), ("Typing".into(), None)); } #[test] fn new_from_selection_rename_delete_and_undo() { let (mut s, id) = session_with_text("Title"); s.execute("text.setStyle", &json!({"size": 40})).unwrap(); let name = s.execute("charStyle.new", &json!({})).unwrap()["name"].as_str().unwrap().to_string(); assert_eq!(name, "Character Style 1"); assert_eq!(s.doc().unwrap().doc.char_styles[0].attrs["size"], 40.0); s.execute("charStyle.apply", &json!({"name": name})).unwrap(); s.execute("charStyle.rename", &json!({"name": name, "to": "Heading"})).unwrap(); assert_eq!(runs(&s, id)[0].style.style_name.as_deref(), Some("Heading")); assert!(s.execute("charStyle.rename", &json!({"name": NORMAL_CHAR, "to": "x"})).is_err()); s.execute("charStyle.duplicate", &json!({"name": "Heading"})).unwrap(); s.execute("charStyle.delete", &json!({"name": "Heading"})).unwrap(); assert_eq!(runs(&s, id)[0].style.style_name, None); assert_eq!(runs(&s, id)[0].style.size, 40.0, "deleting keeps the look"); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(runs(&s, id)[0].style.style_name.as_deref(), Some("Heading")); assert!(s.execute("charStyle.new", &json!({"name": "Bad", "attrs": {"nope": 1}})).is_err()); // Attributes are stored canonically (a hand-written colour matches the text's afterwards). s.execute( "charStyle.new", &json!({"name": "Pink", "attrs": {"fill": {"type": "solid", "color": {"model": "rgb", "r": 0.9, "g": 0.2, "b": 0.4}}}}), ) .unwrap(); s.execute("charStyle.apply", &json!({"name": "Pink"})).unwrap(); let fill = serde_json::to_value(&runs(&s, id)[0].style.fill).unwrap(); assert_eq!(s.doc().unwrap().doc.char_styles.iter().find(|d| d.name == "Pink").unwrap().attrs["fill"], fill); } /// New Character Style and New Paragraph Style trim the name they are given, as a rename /// trims `to`; the trimmed name is the one compared with the styles there are. #[test] fn new_styles_trim_their_names_as_a_rename_does() { let (mut s, _) = session_with_text("Body text"); for p in ["charStyle", "paraStyle"] { assert_eq!(s.execute(&format!("{p}.new"), &json!({"name": " Body \t"})).unwrap()["name"], "Body", "{p}"); let list = s.execute(&format!("{p}.list"), &json!({})).unwrap(); let names: Vec<&str> = list["styles"].as_array().unwrap().iter().filter_map(|x| x["name"].as_str()).collect(); assert_eq!(names[1..], ["Body"], "{p}"); let e = s.execute(&format!("{p}.new"), &json!({"name": " Body"})).unwrap_err().to_string(); assert!(e.contains("a style named `Body` already exists"), "{p}: {e}"); let e = s.execute(&format!("{p}.new"), &json!({"name": " \t "})).unwrap_err().to_string(); assert!(e.contains("give the style a name"), "{p}: {e}"); assert_eq!(s.execute(&format!("{p}.duplicate"), &json!({"name": "Body"})).unwrap()["name"], "Body copy", "{p}"); } // A style whose name has spaces around it, as a saved file can hold, is copied under the // trimmed name, numbered past the copies there are. std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).char_styles.push(TextStyleDef { name: " Old ".into(), attrs: Map::new() }); s.execute("charStyle.new", &json!({"name": "Old copy"})).unwrap(); assert_eq!(s.execute("charStyle.duplicate", &json!({"name": " Old "})).unwrap()["name"], "Old copy 2"); } fn unused(s: &mut Session, p: &str) -> Vec { serde_json::from_value(s.execute(&format!("{p}.unused"), &json!({})).unwrap()["names"].clone()).unwrap() } fn listed(s: &mut Session, p: &str) -> Vec { let list = s.execute(&format!("{p}.list"), &json!({})).unwrap(); list["styles"].as_array().unwrap().iter().filter_map(|x| x["name"].as_str()).map(str::to_string).collect() } /// Select All Unused: `charStyle.unused` and `paraStyle.unused` list, in panel order and without an /// undo step, the styles no text uses, never the Normal style. Type in a symbol, an opacity mask, a /// graph design, a pattern tile or a brush's art counts, as type in the layers does. #[test] fn unused_styles_are_those_no_text_uses_wherever_the_text_is() { let (mut s, id) = session_with_text("Used"); let names = ["Spare", "Used", "Symbol", "Mask", "Design", "Pattern", "Brush", "Also spare"]; for p in ["charStyle", "paraStyle"] { for n in names { s.execute(&format!("{p}.new"), &json!({"name": n, "attrs": {}})).unwrap(); } s.execute(&format!("{p}.apply"), &json!({"name": "Used", "id": id})).unwrap(); } // Type in style `name`, selected. let styled = |s: &mut Session, name: &str| { let t = s.execute("text.create", &json!({"x": 10, "y": 150, "text": name})).unwrap()["id"].as_u64().unwrap(); for p in ["charStyle", "paraStyle"] { s.execute(&format!("{p}.apply"), &json!({"name": name, "id": t})).unwrap(); } s.execute("select.set", &json!({"ids": [t]})).unwrap(); NodeId(t) }; let clear = |s: &mut Session, id: NodeId| { s.execute("edit.clear", &json!({"ids": [id.0]})).unwrap(); }; styled(&mut s, "Symbol"); let sym = s.execute("symbol.new", &json!({"name": "Mark"})).unwrap(); clear(&mut s, NodeId(sym["id"].as_u64().unwrap())); let masked = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 60, "height": 60})).unwrap()["id"].as_u64().unwrap(); let mask = styled(&mut s, "Mask"); s.execute("select.set", &json!({"ids": [masked, mask.0]})).unwrap(); s.execute("transparency.makeOpacityMask", &json!({})).unwrap(); let design = styled(&mut s, "Design"); s.execute("graph.design", &json!({"new": "Bar"})).unwrap(); clear(&mut s, design); let tile = styled(&mut s, "Pattern"); s.execute("object.pattern.make", &json!({"edit": false})).unwrap(); if s.doc().unwrap().doc.node(tile).is_some() { clear(&mut s, tile); } let art = styled(&mut s, "Brush"); s.execute("brush.new", &json!({"type": "art", "name": "Lettering"})).unwrap(); clear(&mut s, art); let undo = s.doc().unwrap().history.undo.len(); for p in ["charStyle", "paraStyle"] { assert_eq!(unused(&mut s, p), ["Spare", "Also spare"], "{p}"); } assert_eq!(s.doc().unwrap().history.undo.len(), undo); let list = s.execute("charStyle.list", &json!({})).unwrap(); let uses = |name: &str| list["styles"].as_array().unwrap().iter().find(|x| x["name"] == name).unwrap()["uses"].as_u64(); for name in ["Symbol", "Mask", "Design", "Pattern", "Brush"] { assert_eq!(uses(name), Some(1), "{name}"); } } /// `charStyle.delete` and `paraStyle.delete` take `names` and delete them in one undo step; text /// that used them keeps its look. A name that is not a style, the Normal style, or `names` that is /// not an array of strings deletes none. #[test] fn several_styles_are_deleted_in_one_step() { let (mut s, id) = session_with_text("Title"); s.execute("text.setStyle", &json!({"size": 40})).unwrap(); for p in ["charStyle", "paraStyle"] { for n in ["A", "B", "C"] { s.execute(&format!("{p}.new"), &json!({"name": n})).unwrap(); } s.execute(&format!("{p}.apply"), &json!({"name": "B", "id": id})).unwrap(); let before = listed(&mut s, p); let undo = s.doc().unwrap().history.undo.len(); let normal = before[0].clone(); for bad in [json!(["A", "Nope"]), json!(["A", normal]), json!([]), json!(["A", 7]), json!("A")] { assert!(s.execute(&format!("{p}.delete"), &json!({"names": bad})).is_err(), "{p} {bad}"); } assert_eq!((listed(&mut s, p), s.doc().unwrap().history.undo.len()), (before.clone(), undo), "{p}"); s.execute(&format!("{p}.delete"), &json!({"names": ["A", "B"]})).unwrap(); assert_eq!(listed(&mut s, p), [normal.as_str(), "C"], "{p}"); assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "{p}"); assert_eq!(s.execute("edit.undo", &json!({})).unwrap()["undone"], "Delete Styles", "{p}"); assert_eq!(listed(&mut s, p), before, "{p}"); } s.execute("charStyle.delete", &json!({"names": ["B"]})).unwrap(); assert_eq!((runs(&s, id)[0].style.style_name.as_deref(), runs(&s, id)[0].style.size), (None, 40.0)); assert_eq!(s.execute("edit.undo", &json!({})).unwrap()["undone"], "Delete Style"); } /// #1001: attributes their JSON leaves out at the defaults (Auto leading and kerning, no /// mojikumi) are still attributes a style can set, and a style captured with one applies to /// text at the default, is duplicated and comes back out of a library. #[test] fn styles_take_attributes_left_out_at_their_defaults() { let (mut s, id) = session_with_text("A"); s.execute("charStyle.new", &json!({"name": "Kerned", "attrs": {"kerning": 40, "leading": 30}})).unwrap(); s.execute("charStyle.setAttrs", &json!({"name": "Kerned", "attrs": {"kerning": 20, "small_caps": 70}})).unwrap(); s.execute("paraStyle.new", &json!({"name": "Punctuation", "attrs": {"mojikumi": "lineEndHalf"}})).unwrap(); s.execute("paraStyle.setAttrs", &json!({"name": "Punctuation", "attrs": {"direction": "rightToLeft"}})).unwrap(); for bad in [json!({"nope": 1}), json!({"justify_auto": true})] { assert!(s.execute("paraStyle.new", &json!({"name": "Bad", "attrs": bad})).is_err(), "{bad}"); } // Captured from text with explicit leading, applied to fresh Auto-leading text. s.execute("text.setStyle", &json!({"leading": 30})).unwrap(); s.execute("charStyle.new", &json!({"name": "Spaced"})).unwrap(); let fresh = s.execute("text.create", &json!({"x": 10, "y": 90, "text": "B"})).unwrap()["id"].as_u64().unwrap(); assert_eq!(runs(&s, fresh)[0].style.leading, None); for clear in [false, true] { s.execute("charStyle.apply", &json!({"name": "Spaced", "id": fresh, "clearOverrides": clear})).unwrap(); assert_eq!(runs(&s, fresh)[0].style.leading, Some(30.0)); } let copy = s.execute("charStyle.duplicate", &json!({"name": "Spaced"})).unwrap()["name"].as_str().unwrap().to_string(); assert_eq!(s.doc().unwrap().doc.char_styles.iter().find(|d| d.name == copy).unwrap().attrs["leading"], 30.0); s.execute("charStyle.apply", &json!({"name": "Kerned", "id": fresh})).unwrap(); let st = runs(&s, fresh)[0].style.clone(); assert_eq!((st.kerning, st.small_caps), (Some(20.0), Some(70.0))); s.execute("paraStyle.apply", &json!({"name": "Punctuation", "id": id})).unwrap(); let NodeKind::Text(t) = &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind else { panic!() }; assert_eq!((t.para.mojikumi, t.para.direction), (vectorcraft_doc::Mojikumi::LineEndHalf, Some(vectorcraft_doc::ParaDirection::RightToLeft))); // A library keeps the captured attributes and makes the style again where it's used. s.execute("select.set", &json!({"ids": [id]})).unwrap(); let lib = s.execute("library.add", &json!({"kind": "charStyle", "name": "Lib Spaced"})).unwrap(); let other = s.execute("text.create", &json!({"x": 10, "y": 150, "text": "C"})).unwrap()["id"].as_u64().unwrap(); s.execute("library.use", &json!({"library": lib["library"], "kind": "charStyle", "item": lib["name"]})).unwrap(); assert_eq!(runs(&s, other)[0].style.leading, Some(30.0)); } #[test] fn auto_leading_redefine_keeps_overrides_and_history() { for redefine in [true, false] { let (mut s, source) = session_with_text("Source"); let [linked, local] = ["Linked", "Local"].map(|text| s.execute("text.create", &json!({"x": 10, "y": 90, "text": text})).unwrap()["id"].as_u64().unwrap()); let ids = [source, linked, local]; s.execute("charStyle.new", &json!({"name": "Body", "attrs": {"leading": 30}})).unwrap(); s.execute("charStyle.apply", &json!({"name": "Body", "ids": ids})).unwrap(); s.execute("text.setStyle", &json!({"ids": [local], "leading": 42})).unwrap(); s.execute("text.setStyle", &json!({"ids": [source], "leading": "auto"})).unwrap(); let before = s.doc().unwrap().doc.clone(); let undo = s.doc().unwrap().history.undo.len(); if redefine { s.execute("charStyle.redefine", &json!({"name": "Body", "id": source})).unwrap(); } else { s.execute("charStyle.setAttrs", &json!({"name": "Body", "attrs": {"leading": null}})).unwrap(); } assert_eq!(ids.map(|id| runs(&s, id)[0].style.leading), [None, None, Some(42.0)]); assert!(ids.iter().all(|&id| runs(&s, id)[0].style.style_name.as_deref() == Some("Body"))); let after = s.doc().unwrap().doc.clone(); assert_eq!(after.char_styles[0].attrs.get("leading"), Some(&Value::Null), "Auto is explicit, not an unset attribute"); assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(s.doc().unwrap().doc, before); s.execute("edit.redo", &json!({})).unwrap(); assert_eq!(s.doc().unwrap().doc, after); let back = vectorcraft_format::load(&vectorcraft_format::save(&after, false)).unwrap(); assert_eq!(back.char_styles, after.char_styles); for id in ids { let NodeKind::Text(t) = &back.node(NodeId(id)).unwrap().kind else { panic!("not text") }; assert_eq!(t.runs, runs(&s, id)); } // Auto users follow the next numeric definition; real numeric and Auto overrides stay. s.execute("charStyle.setAttrs", &json!({"name": "Body", "attrs": {"leading": 26}})).unwrap(); assert_eq!(ids.map(|id| runs(&s, id)[0].style.leading), [Some(26.0), Some(26.0), Some(42.0)]); s.execute("text.setStyle", &json!({"ids": [linked], "leading": "auto"})).unwrap(); s.execute("charStyle.setAttrs", &json!({"name": "Body", "attrs": {"leading": 28}})).unwrap(); assert_eq!(ids.map(|id| runs(&s, id)[0].style.leading), [Some(28.0), None, Some(42.0)]); // Omitting leading returns linked text to Auto and keeps local overrides. s.execute("charStyle.setAttrs", &json!({"name": "Body", "attrs": {"tracking": 20}})).unwrap(); assert!(!s.doc().unwrap().doc.char_styles[0].attrs.contains_key("leading")); assert_eq!(ids.map(|id| runs(&s, id)[0].style.leading), [None, None, Some(42.0)]); } } /// The attribute names a style of type `T` can set, without the style name. fn settable() -> std::collections::BTreeSet { attr_names::().into_iter().filter(|&n| n != "style_name").map(String::from).collect() } /// New and Redefine capture every paragraph attribute. A paragraph style redefined from a /// paragraph at the defaults gives its paragraphs the defaults back where they had the old /// definition's values, and `setAttrs` with a default value does too; local overrides, and /// attributes `setAttrs` leaves out return to the base value. #[test] fn para_style_redefined_to_the_defaults_updates_its_paragraphs() { use vectorcraft_doc::{Composer, HyphenationOptions}; let (mut s, source) = session_with_text("Source"); let [linked, local] = ["Linked", "Local"].map(|text| s.execute("text.create", &json!({"x": 10, "y": 90, "text": text})).unwrap()["id"].as_u64().unwrap()); let ids = [source, linked, local]; let para = |s: &Session, id: u64| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { NodeKind::Text(t) => t.para_at(0).clone(), _ => panic!("not text"), }; let limits = |s: &Session| ids.map(|id| para(s, id).hyphenation.hyphen_limit); let composers = |s: &Session| ids.map(|id| para(s, id).composer); let defined = |s: &Session, i: usize| s.doc().unwrap().doc.para_styles[i].attrs.keys().cloned().collect::>(); s.execute("text.setFormat", &json!({"ids": ids, "hyphenLimit": 2, "composer": "singleLine"})).unwrap(); s.execute("paraStyle.new", &json!({"name": "Body", "id": source})).unwrap(); s.execute("paraStyle.apply", &json!({"name": "Body", "ids": ids})).unwrap(); s.execute("text.setFormat", &json!({"ids": [local], "hyphenLimit": 4})).unwrap(); s.execute("text.setFormat", &json!({"ids": [source], "hyphenLimit": 0, "composer": "everyLine"})).unwrap(); s.execute("paraStyle.redefine", &json!({"name": "Body", "id": source})).unwrap(); assert_eq!((limits(&s), composers(&s)), ([0, 0, 4], [Composer::EveryLine; 3])); assert_eq!(para(&s, linked).hyphenation, HyphenationOptions::default()); assert!(ids.iter().all(|&id| para(&s, id).style_name.as_deref() == Some("Body"))); assert_eq!(defined(&s, 0), settable::(), "Redefine captures every attribute"); s.execute("paraStyle.new", &json!({"name": "Copy", "id": local})).unwrap(); assert_eq!(defined(&s, 1), settable::(), "New captures every attribute"); // Style options: a default value reaches users, and an attribute left out returns to base. s.execute("paraStyle.setAttrs", &json!({"name": "Body", "attrs": {"hyphenation": {"hyphen_limit": 3}, "composer": "singleLine"}})).unwrap(); assert_eq!((limits(&s), composers(&s)), ([3, 3, 4], [Composer::SingleLine; 3])); s.execute("paraStyle.setAttrs", &json!({"name": "Body", "attrs": {"composer": "everyLine"}})).unwrap(); assert_eq!((limits(&s), composers(&s)), ([0, 0, 4], [Composer::EveryLine; 3])); assert_eq!(Value::Object(s.doc().unwrap().doc.para_styles[0].attrs.clone()), json!({"composer": "everyLine"})); } /// The same for character styles: Auto kerning and the Roman baseline alignment, which the /// run's JSON leaves out, are captured and reach the style's users. #[test] fn char_style_redefined_to_the_defaults_updates_its_text() { use vectorcraft_doc::CharAlign; let (mut s, source) = session_with_text("Source"); let [linked, local] = ["Linked", "Local"].map(|text| s.execute("text.create", &json!({"x": 10, "y": 90, "text": text})).unwrap()["id"].as_u64().unwrap()); let ids = [source, linked, local]; let kerning = |s: &Session| ids.map(|id| runs(s, id)[0].style.kerning); let aligns = |s: &Session| ids.map(|id| runs(s, id)[0].style.char_align); let defined = |s: &Session, i: usize| s.doc().unwrap().doc.char_styles[i].attrs.keys().cloned().collect::>(); s.execute("text.setFormat", &json!({"ids": ids, "kerning": 40, "charAlign": "emBoxCenter"})).unwrap(); s.execute("charStyle.new", &json!({"name": "Body", "id": source})).unwrap(); s.execute("charStyle.apply", &json!({"name": "Body", "ids": ids})).unwrap(); s.execute("text.setFormat", &json!({"ids": [local], "kerning": 60})).unwrap(); s.execute("text.setFormat", &json!({"ids": [source], "kerning": "auto", "charAlign": "romanBaseline"})).unwrap(); s.execute("charStyle.redefine", &json!({"name": "Body", "id": source})).unwrap(); assert_eq!((kerning(&s), aligns(&s)), ([None, None, Some(60.0)], [CharAlign::RomanBaseline; 3])); assert_eq!(defined(&s, 0), settable::(), "Redefine captures every attribute"); s.execute("charStyle.new", &json!({"name": "Copy", "id": local})).unwrap(); assert_eq!(defined(&s, 1), settable::(), "New captures every attribute"); s.execute("charStyle.setAttrs", &json!({"name": "Body", "attrs": {"kerning": 10, "charAlign": "emBoxTop"}})).unwrap(); assert_eq!((kerning(&s), aligns(&s)), ([Some(10.0), Some(10.0), Some(60.0)], [CharAlign::EmBoxTop; 3])); s.execute("charStyle.setAttrs", &json!({"name": "Body", "attrs": {"charAlign": "romanBaseline"}})).unwrap(); assert_eq!((kerning(&s), aligns(&s)), ([None, None, Some(60.0)], [CharAlign::RomanBaseline; 3])); assert_eq!(Value::Object(s.doc().unwrap().doc.char_styles[0].attrs.clone()), json!({"charAlign": "romanBaseline"})); } /// A style captured from type without Variable Font settings sets none explicitly (`{}`): /// applying it clears type's settings, and redefining it from type whose settings were reset /// resets the type that uses it. #[test] fn variable_font_settings_are_captured_explicitly() { let _fonts = crate::adding_fonts(); use vectorcraft_text::test_fonts::{TWO_AXIS_FAMILY, two_axis_font}; vectorcraft_text::FontDb::global().add_font(two_axis_font().unwrap()); let (mut s, source) = session_with_text("Source"); let linked = s.execute("text.create", &json!({"x": 10, "y": 90, "text": "Linked"})).unwrap()["id"].as_u64().unwrap(); let ids = [source, linked]; s.execute("text.setStyle", &json!({"ids": ids, "font": TWO_AXIS_FAMILY})).unwrap(); s.execute("charStyle.new", &json!({"name": "Body", "id": source})).unwrap(); assert_eq!(s.doc().unwrap().doc.char_styles[0].attrs.get("fontAxes"), Some(&json!({}))); s.execute("text.setFormat", &json!({"ids": [linked], "fontAxes": {"wght": 650}})).unwrap(); s.execute("charStyle.apply", &json!({"name": "Body", "ids": ids})).unwrap(); let wght = |s: &Session| ids.map(|id| runs(s, id)[0].style.font_axes.get("wght").copied()); assert_eq!(wght(&s), [None, None], "applying the style clears the settings"); for (set, want) in [(json!(650), Some(650.0)), (json!(null), None)] { s.execute("text.setFormat", &json!({"ids": [source], "fontAxes": {"wght": set}})).unwrap(); s.execute("charStyle.redefine", &json!({"name": "Body", "id": source})).unwrap(); assert_eq!(wght(&s), [want, want], "redefined from {set}"); } } /// A style that changes the font, applied or redefined, leaves none of the old font's /// version and Variable Font settings, unless the style sets them. #[test] fn another_font_from_a_style_clears_the_old_fonts_version_and_settings() { let _fonts = crate::adding_fonts(); use vectorcraft_text::test_fonts::{TWO_AXIS_FAMILY, two_axis_font}; vectorcraft_text::FontDb::global().add_font(two_axis_font().unwrap()); let (mut s, id) = session_with_text("Source"); let set_variable = |s: &mut Session| { s.execute("text.setStyle", &json!({"ids": [id], "font": TWO_AXIS_FAMILY})).unwrap(); s.execute("text.setFormat", &json!({"ids": [id], "fontAxes": {"wght": 650}})).unwrap(); s.edit("version", |d, _| { if let Some(t) = text_mut(d, NodeId(id)) { t.runs.iter_mut().for_each(|r| r.style.font_version = Some("Version 2.000".into())); } Ok(()) }) .unwrap(); }; let font = |s: &Session| { let st = runs(s, id)[0].style.clone(); (st.font_family, st.font_version, st.font_axes.get("wght").copied()) }; set_variable(&mut s); s.execute("charStyle.new", &json!({"name": "Plain", "attrs": {"font_family": "Inter"}})).unwrap(); s.execute("charStyle.new", &json!({"name": "Same", "attrs": {"size": 20}})).unwrap(); s.execute("charStyle.apply", &json!({"name": "Same", "id": id})).unwrap(); assert_eq!(font(&s), (TWO_AXIS_FAMILY.to_string(), Some("Version 2.000".to_string()), Some(650.0)), "the same font keeps them"); s.execute("charStyle.apply", &json!({"name": "Plain", "id": id})).unwrap(); assert_eq!(font(&s), ("Inter".to_string(), None, None)); // A style that sets the settings keeps its own. s.execute("charStyle.new", &json!({"name": "Heavy", "attrs": {"font_family": TWO_AXIS_FAMILY, "fontAxes": {"wght": 600}}})).unwrap(); s.execute("charStyle.apply", &json!({"name": "Heavy", "id": id})).unwrap(); assert_eq!(font(&s), (TWO_AXIS_FAMILY.to_string(), None, Some(600.0))); // Redefined to another font: its users' settings and versions go too. s.execute("charStyle.new", &json!({"name": "Sans", "attrs": {"font_family": TWO_AXIS_FAMILY}})).unwrap(); s.execute("charStyle.apply", &json!({"name": "Sans", "id": id})).unwrap(); set_variable(&mut s); assert_eq!(runs(&s, id)[0].style.style_name.as_deref(), Some("Sans")); s.execute("charStyle.setAttrs", &json!({"name": "Sans", "attrs": {"font_family": "Inter"}})).unwrap(); assert_eq!(font(&s), ("Inter".to_string(), None, None)); } #[test] fn auto_leading_new_distinguishes_unset_attributes() { let (mut s, id) = session_with_text("Title"); s.execute("text.setStyle", &json!({"leading": 30})).unwrap(); s.execute("charStyle.new", &json!({"name": "Sparse", "attrs": {"tracking": 20}})).unwrap(); assert!(!s.doc().unwrap().doc.char_styles[0].attrs.contains_key("leading")); s.execute("charStyle.apply", &json!({"name": "Sparse", "id": id})).unwrap(); assert_eq!(runs(&s, id)[0].style.leading, Some(30.0)); s.execute("charStyle.new", &json!({"name": "Automatic", "attrs": {"leading": null}})).unwrap(); s.execute("charStyle.apply", &json!({"name": "Automatic", "id": id})).unwrap(); assert_eq!(runs(&s, id)[0].style.leading, None); assert_eq!(s.doc().unwrap().doc.char_styles[1].attrs.get("leading"), Some(&Value::Null)); // New from Auto text captures that value too, rather than leaving leading unspecified. s.execute("charStyle.new", &json!({"name": "Captured", "id": id})).unwrap(); s.execute("text.setStyle", &json!({"ids": [id], "leading": 36})).unwrap(); s.execute("charStyle.apply", &json!({"name": "Captured", "id": id})).unwrap(); assert_eq!(runs(&s, id)[0].style.leading, None); assert_eq!(s.doc().unwrap().doc.char_styles[2].attrs.get("leading"), Some(&Value::Null)); } #[test] fn optical_character_style_captures_and_redefines_automatic_mode() { let (mut s, id) = session_with_text("III"); s.execute("text.setRangeStyle", &json!({"id": id, "kerning": "optical"})).unwrap(); s.execute("charStyle.new", &json!({"name": "Profile", "id": id})).unwrap(); assert_eq!(s.doc().unwrap().doc.char_styles[0].attrs.get("opticalKerning"), Some(&json!(true))); s.execute("text.setRangeStyle", &json!({"id": id, "kerning": "auto"})).unwrap(); s.execute("charStyle.apply", &json!({"name": "Profile", "id": id})).unwrap(); assert!(runs(&s, id)[0].style.optical_kerning); s.execute("text.setRangeStyle", &json!({"id": id, "kerning": "auto"})).unwrap(); s.execute("charStyle.redefine", &json!({"name": "Profile", "id": id})).unwrap(); assert_eq!(s.doc().unwrap().doc.char_styles[0].attrs.get("opticalKerning"), Some(&json!(false))); } #[test] fn paragraph_styles_and_survive_save() { let (mut s, id) = session_with_text("Body"); s.execute("paraStyle.new", &json!({"name": "Centered", "attrs": {"justify": "Center", "space_after": 6.0}})).unwrap(); s.execute("paraStyle.apply", &json!({"name": "Centered"})).unwrap(); let para = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { NodeKind::Text(t) => t.para.clone(), _ => panic!(), }; assert_eq!((para(&s).justify, para(&s).space_after), (vectorcraft_doc::Justify::Center, 6.0)); // The Normal style can be redefined too (stored once redefined). s.execute("paraStyle.redefine", &json!({"name": NORMAL_PARA})).unwrap(); assert_eq!(s.execute("paraStyle.list", &json!({})).unwrap()["styles"][0]["attrs"]["justify"], "Center"); let d = s.doc().unwrap().doc.clone(); let back = vectorcraft_format::load(&vectorcraft_format::save(&d, false)).unwrap(); assert_eq!(back.para_styles, d.para_styles); } fn capture_boundary_command(redefine: bool, text: &str, start: usize, end: Option, expected_size: f64) { let (mut s, id) = session_with_text(text); s.execute("text.setStyle", &json!({"id": id, "size": 18})).unwrap(); s.execute("text.setRangeStyle", &json!({"id": id, "start": 2, "end": text.len(), "size": 34})).unwrap(); if redefine { s.execute("charStyle.new", &json!({"name": "Captured", "attrs": {"size": 24}})).unwrap(); } s.doc_mut().unwrap().mark_saved(); let before = s.doc().unwrap().doc.clone(); let selection = s.doc().unwrap().selection.clone(); let undo = s.doc().unwrap().history.undo.len(); let mut params = json!({"name": "Captured", "id": id, "start": start}); if let Some(end) = end { params["end"] = json!(end); } let command = if redefine { "charStyle.redefine" } else { "charStyle.new" }; s.execute(command, ¶ms).unwrap(); let st = s.doc().unwrap(); let def = st.doc.char_styles.iter().find(|d| d.name == "Captured").unwrap(); assert_eq!(def.attrs.get("size").and_then(Value::as_f64), Some(expected_size), "{command} {text:?} {start}..{end:?}"); assert!(!def.attrs.contains_key("style_name")); assert_eq!(st.doc.node(NodeId(id)), before.node(NodeId(id)), "capture does not apply the definition"); assert_eq!(st.selection, selection); assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo + 1, 0)); assert!(st.is_dirty()); let after = st.doc.clone(); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(s.doc().unwrap().doc, before); assert_eq!(s.doc().unwrap().selection, selection); assert!(!s.doc().unwrap().is_dirty()); s.execute("edit.redo", &json!({})).unwrap(); assert_eq!(s.doc().unwrap().doc, after); assert_eq!(s.doc().unwrap().selection, selection); } fn capture_boundary_ranges(redefine: bool) { for (text, start, end, size) in [ ("ABcdef", 2, 5, 34.0), ("é漢字", 2, 5, 34.0), ("ABcdef", 5, 2, 34.0), // command ranges are ordered before selected-character sampling ("ABcdef", 1, 5, 18.0), // a mixed range samples its first selected character ("ABcdef", 2, 2, 18.0), // collapsed range retains insertion affinity ("ABcdef", 3, 3, 34.0), ("ABcdef", 99, 100, 34.0), // beyond the text retains the final run ] { capture_boundary_command(redefine, text, start, Some(end), size); } } #[test] fn char_style_new_range_capture_boundary() { capture_boundary_ranges(false); } #[test] fn char_style_redefine_range_capture_boundary() { capture_boundary_ranges(true); } #[test] fn char_style_start_only_capture_affinity_controls() { for redefine in [false, true] { for text in ["ABcdef", "é漢字"] { for (start, size) in [(0, 18.0), (2, 18.0), (3, 34.0), (99, 34.0)] { capture_boundary_command(redefine, text, start, None, size); } } } } #[test] fn para_style_range_capture_samples_start_paragraph() { for redefine in [false, true] { for (start, end, justify) in [(0, 7, "Left"), (4, 7, "Right"), (4, 0, "Right")] { let (mut s, id) = session_with_text("One\nTwo"); s.execute("text.setStyle", &json!({"id": id, "justify": "left"})).unwrap(); s.execute("text.setStyle", &json!({"id": id, "start": 4, "end": 7, "justify": "right"})).unwrap(); if redefine { s.execute("paraStyle.new", &json!({"name": "Captured", "attrs": {"justify": "Center"}})).unwrap(); } let before = s.doc().unwrap().doc.clone(); let selection = s.doc().unwrap().selection.clone(); let undo = s.doc().unwrap().history.undo.len(); let command = if redefine { "paraStyle.redefine" } else { "paraStyle.new" }; s.execute(command, &json!({"name": "Captured", "id": id, "start": start, "end": end})).unwrap(); let st = s.doc().unwrap(); let def = st.doc.para_styles.iter().find(|d| d.name == "Captured").unwrap(); assert_eq!(def.attrs["justify"], json!(justify), "paragraph capture still samples start, not the end or a normalized range"); assert_eq!(st.doc.node(NodeId(id)), before.node(NodeId(id))); assert_eq!(st.selection, selection); assert_eq!(st.history.undo.len(), undo + 1); let after = st.doc.clone(); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(s.doc().unwrap().doc, before); s.execute("edit.redo", &json!({})).unwrap(); assert_eq!(s.doc().unwrap().doc, after); } } }