Files
vectorcraft/crates/engine/src/cmd/textstyles/tests.rs
T
dotCooCoo 1494e2c230 Type: Tsume narrows the space around each character's ink
Character panel > Tsume, shown with Show East Asian Options, takes away a
percentage of the blank space on each side of a character's ink, inside
its own width, in horizontal type. The value is from 0 to 100, 0 by
default. 50 halves that space and 100 sets the ink against the
characters before and after it; the glyph keeps its shape. A character
without ink, such as a space, keeps its width, and a ligature or a
letter with its marks takes the value of its first character. The
font's pair kerning, tracking, manual kerning and Insert Aki are added
after it, and justification keeps it. Vertical type keeps its spacing.

- text.setFormat and text.setRangeStyle take `tsume`; a value that is
  not a number or is outside 0 to 100 is refused, naming the key.
  Character styles carry it, and a file holding a value outside the
  range opens with the nearer end.
- The Character panel's Tsume field has 0, 25, 50, 75 and 100 in its
  menu, and Reset Panel sets it to 0. Character Style Options shows it
  in Advanced Character Formats.
- Live SVG text places each character Tsume moves, and type on a path
  with Tsume is written as outlines.

Docs: docs/mcp.md (Tsume, Character Style Options fields),
docs/target-app-parity.md.
2026-11-13 21:50:05 -08:00

1817 lines
109 KiB
Rust

use super::*;
#[test]
fn captured_character_style_excludes_a_one_character_glyph_choice() {
let style = CharStyle {
glyph_alternate: Some(vectorcraft_doc::GlyphChoice { ch: 'a', gid: 42, font: "Test-Regular".into(), version: "1".into() }),
..Default::default()
};
assert!(!char_attrs(&style).contains_key("glyphAlternate"));
assert!(style.glyph_alternate.is_some(), "the selected text keeps its direct glyph choice");
}
#[test]
fn character_style_edit_keeps_a_linked_runs_glyph_choice() {
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 choice = vectorcraft_doc::GlyphChoice { ch: 'S', gid: 42, font: "Test-Regular".into(), version: "1".into() };
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.glyph_alternate = Some(choice.clone());
}
s.execute("charStyle.edit", &json!({"name": "Body", "to": "Body", "attrs": {"size": 18}})).unwrap();
let run = &runs(&s, id)[0];
assert_eq!((run.style.style_name.as_deref(), run.style.size), (Some("Body"), 18.0));
assert_eq!(run.style.glyph_alternate, Some(choice), "the style edit keeps the run's own glyph choice");
}
/// A glyph choice given to a character style is an error, not a value the style drops.
#[test]
fn character_style_attributes_refuse_a_glyph_choice() {
let (mut s, _) = session_with_text("Styled");
let choice = json!({"ch": "S", "gid": 42, "font": "Test-Regular", "version": "1"});
let err = s.execute("charStyle.new", &json!({"name": "Body", "attrs": {"size": 24, "glyphAlternate": choice}})).unwrap_err();
assert!(err.to_string().contains("glyphAlternate"), "{err}");
s.execute("charStyle.new", &json!({"name": "Body", "attrs": {"size": 24}})).unwrap();
let err = s.execute("charStyle.edit", &json!({"name": "Body", "to": "Body", "attrs": {"glyphAlternate": choice}})).unwrap_err();
assert!(err.to_string().contains("glyphAlternate"), "{err}");
}
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<vectorcraft_doc::TextRun> {
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<String>, f64, Option<String>) {
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", &params).unwrap();
s.commit_interaction().unwrap();
} else {
s.execute("charStyle.edit", &params).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::<Vec<_>>(),
[(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::<Vec<_>>(),
[(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", "salt=2"]});
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", "salt=2"]);
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<String> = (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": ["salt=x"]}), "unknown OpenType feature `salt=x`"),
(json!({"features": ["-salt=2"]}), "unknown OpenType feature `-salt=2`"),
(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 character_style_dimensions_are_checked_before_a_definition_changes() {
let (mut s, _) = session_with_text("Text");
s.execute("charStyle.new", &json!({"name": "Body", "attrs": {"size": 24.0}})).unwrap();
let before = s.doc().unwrap().doc.clone();
let undo = s.doc().unwrap().history.undo.len();
for attrs in [
json!({"size": 0.0}),
json!({"size": 1296.1}),
json!({"leading": 0.0}),
json!({"leading": 5000.1}),
json!({"h_scale": 0.0}),
json!({"h_scale": 0.9}),
json!({"h_scale": 10000.1}),
json!({"v_scale": 0.0}),
json!({"v_scale": 0.9}),
json!({"v_scale": 10000.1}),
json!({"baseline_shift": -1296.1}),
json!({"baseline_shift": 1296.1}),
json!({"tcyUpDown": 1296.1}),
json!({"tcyLeftRight": -1296.1}),
json!({"akiLeft": -0.1}),
json!({"akiRight": 1000.1}),
json!({"tsume": -0.1}),
json!({"tsume": 100.1}),
] {
let key = attrs.as_object().unwrap().keys().next().unwrap();
for cmd in ["charStyle.edit", "charStyle.setAttrs", "charStyle.new"] {
let params = 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, &params).unwrap_err().to_string();
assert!(error.contains(&format!("`{key}`")), "{cmd} {attrs}: {error}");
}
}
assert_eq!(s.doc().unwrap().doc, before);
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
s.execute(
"charStyle.setAttrs",
&json!({"name": "Body", "attrs": {
"size": 1296.0, "leading": 5000.0,
"h_scale": 1.0, "v_scale": 10000.0,
"baseline_shift": -1296.0,
"tcyUpDown": 1296.0, "tcyLeftRight": -1296.0,
"akiLeft": 0.0, "akiRight": 1000.0,
"tsume": 100.0
}}),
)
.unwrap();
}
#[test]
fn text_style_rejects_doubly_signed_feature_tags() {
let (mut s, id) = session_with_text("Text");
let before = runs(&s, id);
let undo = s.doc().unwrap().history.undo.len();
for cmd in ["text.setStyle", "text.setRangeStyle"] {
for tag in ["--liga", "+-dnom", "-+ss01", "++liga"] {
let error = s.execute(cmd, &json!({"id": id, "start": 0, "end": 4, "features": [tag]})).unwrap_err().to_string();
assert!(error.contains(&format!("`{tag}`")), "{cmd}: {error}");
}
}
assert_eq!(runs(&s, id), 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<String> = (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<String> {
serde_json::from_value(s.execute(&format!("{p}.unused"), &json!({})).unwrap()["names"].clone()).unwrap()
}
fn listed(s: &mut Session, p: &str) -> Vec<String> {
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");
}
/// A saved document whose character and paragraph styles are `char` and `para` (name, attrs),
/// stored as they are, as `charStyle.load`'s `dataBase64`.
fn styles_file(char: &[(&str, Value)], para: &[(&str, Value)]) -> String {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
let mut d = (*s.doc().unwrap().doc).clone();
let defs = |v: &[(&str, Value)]| {
v.iter().map(|(n, a)| TextStyleDef { name: n.to_string(), attrs: a.as_object().cloned().unwrap_or_default() }).collect::<Vec<_>>()
};
d.char_styles = defs(char).into();
d.para_styles = defs(para).into();
vectorcraft_format::base64_encode(&vectorcraft_format::save(&d, false))
}
/// Load Character Styles and Load Paragraph Styles copy the other document's styles of their kind
/// after the document's own, in one undo step. A style with the same attributes as the style here
/// of its name, or of its name with a number, is not added again; any other gets the first free of
/// those names, and the style here and the type using it keep their attributes. The file's Normal
/// style is not loaded, and a style whose attributes do not read, or whose name is empty, is
/// skipped.
#[test]
fn styles_load_from_another_document_in_one_step() {
for p in ["charStyle", "paraStyle"] {
let (mut s, id) = session_with_text("Title");
let normal = if p == "charStyle" { NORMAL_CHAR } else { NORMAL_PARA };
let key = if p == "charStyle" { "size" } else { "space_after" };
let size = |v: f64| Value::Object(Map::from_iter([(key.to_string(), json!(v))]));
for (n, v) in [("Body", 12.0), ("Accent", 9.0), ("Accent 2", 1.0)] {
s.execute(&format!("{p}.new"), &json!({"name": n, "attrs": size(v)})).unwrap();
}
s.execute(&format!("{p}.apply"), &json!({"name": "Accent", "id": id})).unwrap();
let file =
[("Body", size(12.0)), ("Accent", size(20.0)), ("Fresh", size(5.0)), (normal, size(30.0)), ("Bad", json!({"nope": 1})), (" ", size(2.0))];
let data = styles_file(&file, &file);
let before = s.doc().unwrap().doc.clone();
let undo = s.doc().unwrap().history.undo.len();
let r = s.execute(&format!("{p}.load"), &json!({"dataBase64": data, "name": "Other.vectorcraft"})).unwrap();
assert_eq!(r, json!({"added": ["Accent 3", "Fresh"], "existing": ["Body"], "skipped": ["Bad", " "]}), "{p}");
assert_eq!(listed(&mut s, p), [normal, "Body", "Accent", "Accent 2", "Accent 3", "Fresh"], "{p}");
let d = s.doc().unwrap().doc.clone();
let attrs = |name: &str| super::style_attrs(&d, p == "paraStyle", name).map(Value::Object);
assert_eq!((attrs("Accent"), attrs("Accent 3"), attrs(normal)), (Some(size(9.0)), Some(size(20.0)), Some(json!({}))), "{p}");
// The other kind and the type are as they were.
let (other, mine) = if p == "charStyle" { (&d.para_styles, &d.char_styles) } else { (&d.char_styles, &d.para_styles) };
let (other_before, mine_before) =
if p == "charStyle" { (&before.para_styles, &before.char_styles) } else { (&before.char_styles, &before.para_styles) };
assert_eq!((other, &mine[..mine_before.len()]), (other_before, &mine_before[..]), "{p}");
assert_eq!(d.node(NodeId(id)), before.node(NodeId(id)), "{p}");
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "{p}");
// Loading the same file again adds nothing and makes no undo step.
let again = s.execute(&format!("{p}.load"), &json!({"dataBase64": data, "name": "Other.vectorcraft"})).unwrap();
assert_eq!(again["added"], json!([]), "{p}");
assert_eq!(again["existing"], json!(["Body", "Accent 3", "Fresh"]), "{p}");
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "{p}");
let undone = s.execute("edit.undo", &json!({})).unwrap()["undone"].clone();
assert_eq!(undone, if p == "charStyle" { "Load Character Styles" } else { "Load Paragraph Styles" });
assert_eq!(s.doc().unwrap().doc, before, "{p}");
}
}
/// Load All Styles loads both kinds in one undo step, from a path too; a file that does not open,
/// or no file, loads nothing.
#[test]
fn load_all_styles_loads_both_kinds_in_one_step() {
let (mut s, _) = session_with_text("Title");
let data = styles_file(&[("Lead", json!({"size": 30.0}))], &[("Centered", json!({"justify": "Center"}))]);
let dir = std::env::temp_dir().join(format!("vc-load-styles-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("Other.vectorcraft");
std::fs::write(&path, vectorcraft_format::base64_decode(&data).unwrap()).unwrap();
let before = s.doc().unwrap().doc.clone();
let undo = s.doc().unwrap().history.undo.len();
for bad in [json!({}), json!({"data": "not a document", "name": "x.vectorcraft"}), json!({"dataBase64": "!"})] {
assert!(s.execute("textStyle.loadAll", &bad).is_err(), "{bad}");
}
let r = s.execute("textStyle.loadAll", &json!({"path": path.to_string_lossy()})).unwrap();
std::fs::remove_dir_all(&dir).ok();
let one = |name: &str| json!({"added": [name], "existing": [], "skipped": []});
assert_eq!(r, json!({"charStyles": one("Lead"), "paraStyles": one("Centered")}));
assert_eq!(listed(&mut s, "charStyle"), [NORMAL_CHAR, "Lead"]);
assert_eq!(listed(&mut s, "paraStyle"), [NORMAL_PARA, "Centered"]);
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
assert_eq!(s.execute("edit.undo", &json!({})).unwrap()["undone"], "Load All Styles");
assert_eq!(s.doc().unwrap().doc, before);
}
/// A loaded character style whose size, scale, leading, shift, warichu Line Gap or stroke width is
/// out of the range `charStyle.new` takes is skipped.
#[test]
fn loaded_styles_out_of_range_are_skipped() {
let (mut s, _) = session_with_text("Title");
let file = [
("Huge", json!({"size": 2000.0})),
("Flat", json!({"h_scale": 0.5})),
("Shifted", json!({"baseline_shift": 5000.0})),
("Thick", json!({"stroke_width": -2.0})),
("Gapped", json!({"warichuGap": 500.0})),
("Fine", json!({"size": 12.0, "h_scale": 50.0})),
];
let data = styles_file(&file, &[]);
let r = s.execute("charStyle.load", &json!({"dataBase64": data, "name": "Other.vectorcraft"})).unwrap();
assert_eq!(r, json!({"added": ["Fine"], "existing": [], "skipped": ["Huge", "Flat", "Shifted", "Thick", "Gapped"]}));
assert_eq!(listed(&mut s, "charStyle"), [NORMAL_CHAR, "Fine"]);
}
/// A loaded style brings the pattern it paints with, renamed when the document has another pattern
/// of that name, as Paste brings it; a global color whose name the document gives another color
/// is the document's.
#[test]
fn loaded_styles_bring_the_patterns_and_swatches_they_paint_with() {
// A pattern "Dots" made from a `size` square, a global "Ink", and type painted with each.
let painted = |size: f64, ink: &str| {
let (mut s, id) = session_with_text("Dots");
let square = s.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": size, "height": size})).unwrap()["id"].clone();
s.execute("object.pattern.make", &json!({"name": "Dots", "edit": false, "ids": [square]})).unwrap();
s.execute("swatch.new", &json!({"name": "Ink", "color": ink, "global": true})).unwrap();
(s, id)
};
let (mut src, id) = painted(10.0, "#3366cc");
src.execute("select.set", &json!({"ids": [id]})).unwrap();
src.execute("paint.setFill", &json!({"swatch": "Dots", "ids": [id]})).unwrap();
src.execute("charStyle.new", &json!({"name": "Patterned", "id": id})).unwrap();
src.execute("paint.setFill", &json!({"swatch": "Ink", "ids": [id]})).unwrap();
src.execute("charStyle.new", &json!({"name": "Inked", "id": id})).unwrap();
let data = vectorcraft_format::base64_encode(&vectorcraft_format::save(&src.doc().unwrap().doc, false));
let (mut s, id) = painted(30.0, "#ff0000");
let r = s.execute("charStyle.load", &json!({"dataBase64": data, "name": "Other.vectorcraft"})).unwrap();
assert_eq!(r["added"], json!(["Patterned", "Inked"]));
let d = s.doc().unwrap().doc.clone();
let fill = |name: &str| super::style_attrs(&d, false, name).unwrap()["fill"].to_string();
assert!(d.pattern("Dots 2").is_some() && fill("Patterned").contains("\"Dots 2\""), "{}", fill("Patterned"));
assert!(fill("Inked").contains("\"Ink\"") && d.swatch("Ink 2").is_none(), "{}", fill("Inked"));
s.execute("charStyle.apply", &json!({"name": "Patterned", "id": id})).unwrap();
assert!(matches!(&runs(&s, id)[0].style.fill, vectorcraft_color::Paint::Pattern { pattern, .. } if pattern == "Dots 2"));
}
/// A pattern whose type is set in a loaded style that was renamed on the way in ("Accent 2") is
/// the same pattern when the file is loaded again, and so is the style that paints with it, also
/// when "Accent" itself paints with another pattern.
#[test]
fn reloaded_patterns_follow_the_renamed_styles_their_type_uses() {
for patterned in [false, true] {
let (mut src, id) = session_with_text("Tile");
if patterned {
let square = src.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 10, "height": 10})).unwrap()["id"].clone();
src.execute("object.pattern.make", &json!({"name": "Dots", "edit": false, "ids": [square]})).unwrap();
src.execute("paint.setFill", &json!({"swatch": "Dots", "ids": [id]})).unwrap();
}
src.execute("text.setRangeStyle", &json!({"id": id, "start": 0, "end": 4, "kerning": 40})).unwrap();
src.execute("charStyle.new", &json!({"name": "Accent", "id": id})).unwrap();
src.execute("charStyle.apply", &json!({"name": "Accent", "id": id})).unwrap();
if patterned {
// A solid fill over the style's, so the tile's type itself paints with no pattern.
src.execute("paint.setFill", &json!({"color": "#000000", "ids": [id]})).unwrap();
}
src.execute("object.pattern.make", &json!({"name": "Tiles", "edit": false, "ids": [id]})).unwrap();
let other = src.execute("text.create", &json!({"x": 10, "y": 90, "text": "B"})).unwrap()["id"].as_u64().unwrap();
src.execute("paint.setFill", &json!({"swatch": "Tiles", "ids": [other]})).unwrap();
src.execute("charStyle.new", &json!({"name": "Tiled", "id": other})).unwrap();
let data = vectorcraft_format::base64_encode(&vectorcraft_format::save(&src.doc().unwrap().doc, false));
let (mut s, _) = session_with_text("X");
s.execute("charStyle.new", &json!({"name": "Accent", "attrs": {"kerning": 10}})).unwrap();
let load = json!({"dataBase64": data, "name": "Other.vectorcraft"});
s.execute("charStyle.load", &load).unwrap();
let counts = |s: &Session| {
let d = &s.doc().unwrap().doc;
(d.patterns.len(), d.char_styles.iter().map(|c| c.name.clone()).collect::<Vec<_>>())
};
let first = counts(&s);
assert!(first.1.contains(&"Accent 2".to_string()) && first.1.contains(&"Tiled".to_string()), "{patterned}: {first:?}");
let r = s.execute("charStyle.load", &load).unwrap();
assert_eq!(r["added"], json!([]), "{patterned}: {r}");
assert_eq!(counts(&s), first, "{patterned}");
}
}
/// A loaded style that paints with a pattern whose type is set in that same style still paints
/// with the pattern when the pattern comes in under a free name ("Dots 2"). The commands refuse to
/// make that loop, so the source is a document edited directly, as a file may hold it.
#[test]
fn a_style_and_the_pattern_that_uses_it_load_together() {
let (mut src, id) = session_with_text("Tile");
src.execute("charStyle.new", &json!({"name": "Accent", "attrs": {"kerning": 40}})).unwrap();
src.execute("charStyle.apply", &json!({"name": "Accent", "id": id})).unwrap();
src.execute("object.pattern.make", &json!({"name": "Dots", "edit": false, "ids": [id]})).unwrap();
let other = src.execute("text.create", &json!({"x": 10, "y": 90, "text": "B"})).unwrap()["id"].as_u64().unwrap();
src.execute("charStyle.apply", &json!({"name": "Accent", "id": other})).unwrap();
src.execute("paint.setFill", &json!({"swatch": "Dots", "ids": [other]})).unwrap();
src.execute("charStyle.new", &json!({"name": "Painted", "id": other})).unwrap();
let mut source = (*src.doc().unwrap().doc).clone();
let painted = source.char_styles.iter().find(|s| s.name == "Painted").unwrap().attrs.clone();
source.char_styles.retain(|s| s.name != "Painted");
source.char_styles.iter_mut().find(|s| s.name == "Accent").unwrap().attrs = painted;
let data = vectorcraft_format::base64_encode(&vectorcraft_format::save(&source, false));
let fill = |s: &Session, name: &str| super::style_attrs(&s.doc().unwrap().doc, false, name).unwrap()["fill"].to_string();
let source_fill = super::style_attrs(&source, false, "Accent").unwrap()["fill"].to_string();
assert!(source_fill.contains("\"Dots\""), "{source_fill}");
let (mut s, _) = session_with_text("X");
let square = s.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 30, "height": 30})).unwrap()["id"].clone();
s.execute("object.pattern.make", &json!({"name": "Dots", "edit": false, "ids": [square]})).unwrap();
let load = json!({"dataBase64": data, "name": "Other.vectorcraft"});
s.execute("charStyle.load", &load).unwrap();
assert!(s.doc().unwrap().doc.pattern("Dots 2").is_some());
assert!(fill(&s, "Accent").contains("\"Dots 2\""), "{}", fill(&s, "Accent"));
// Loaded again, the two are found under those names, and nothing is added.
let (doc, undo) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len());
let r = s.execute("charStyle.load", &load).unwrap();
assert_eq!(r["added"], json!([]), "{r}");
assert_eq!((s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len()), (doc, undo));
}
/// A style and the pattern it paints with, whose type is set in that style, are both added when
/// the document has a style of that name and attributes but another pattern of that name: the
/// copy of the style paints with the copy of the pattern, and a second load adds nothing. A loop the
/// document has is used under the names it has together.
#[test]
fn a_style_in_a_loop_is_not_matched_to_a_style_that_paints_with_another_pattern() {
let (mut src, id) = session_with_text("Tile");
src.execute("charStyle.new", &json!({"name": "Accent", "attrs": {"kerning": 40}})).unwrap();
src.execute("charStyle.apply", &json!({"name": "Accent", "id": id})).unwrap();
src.execute("object.pattern.make", &json!({"name": "Dots", "edit": false, "ids": [id]})).unwrap();
let other = src.execute("text.create", &json!({"x": 10, "y": 90, "text": "B"})).unwrap()["id"].as_u64().unwrap();
src.execute("charStyle.apply", &json!({"name": "Accent", "id": other})).unwrap();
src.execute("paint.setFill", &json!({"swatch": "Dots", "ids": [other]})).unwrap();
src.execute("charStyle.new", &json!({"name": "Painted", "id": other})).unwrap();
let mut source = (*src.doc().unwrap().doc).clone();
let painted = source.char_styles.iter().find(|s| s.name == "Painted").unwrap().attrs.clone();
source.char_styles.retain(|s| s.name != "Painted");
source.char_styles.iter_mut().find(|s| s.name == "Accent").unwrap().attrs = painted.clone();
let data = vectorcraft_format::base64_encode(&vectorcraft_format::save(&source, false));
let (mut s, _) = session_with_text("X");
let square = s.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 30, "height": 30})).unwrap()["id"].clone();
s.execute("object.pattern.make", &json!({"name": "Dots", "edit": false, "ids": [square]})).unwrap();
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
doc.char_styles.push(TextStyleDef { name: "Accent".into(), attrs: painted });
let load = json!({"dataBase64": data, "name": "Other.vectorcraft"});
let r = s.execute("charStyle.load", &load).unwrap();
assert_eq!(r["added"], json!(["Accent 2"]), "{r}");
let d = s.doc().unwrap().doc.clone();
let fill = |name: &str| super::style_attrs(&d, false, name).unwrap()["fill"].to_string();
assert!(fill("Accent").contains("\"Dots\"") && fill("Accent 2").contains("\"Dots 2\""), "{} {}", fill("Accent"), fill("Accent 2"));
let NodeKind::Text(text) = &d.pattern("Dots 2").unwrap().art[0].kind else { panic!("pattern art should be type") };
assert_eq!(text.runs[0].style.style_name.as_deref(), Some("Accent 2"));
let r = s.execute("charStyle.load", &load).unwrap();
assert_eq!(r["added"], json!([]), "{r}");
assert_eq!(s.doc().unwrap().doc, d);
// With the loop's style renamed "Accent 3" and an "Accent 2" listed before it that also paints
// with "Dots 2", the loop is still found as "Accent 3" and "Dots 2", the names it has together.
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
let at = doc.char_styles.iter().position(|c| c.name == "Accent 2").unwrap();
doc.char_styles[at].name = "Accent 3".into();
let copy = TextStyleDef { name: "Accent 2".into(), attrs: doc.char_styles[at].attrs.clone() };
doc.char_styles.insert(at, copy);
let tile = doc.patterns.iter_mut().find(|p| p.name == "Dots 2").unwrap();
let NodeKind::Text(text) = &mut std::sync::Arc::make_mut(&mut tile.art[0]).kind else { panic!("pattern art should be type") };
text.runs[0].style.style_name = Some("Accent 3".into());
let d = s.doc().unwrap().doc.clone();
let r = s.execute("charStyle.load", &load).unwrap();
assert_eq!(r, json!({"added": [], "existing": ["Accent 3"], "skipped": []}));
assert_eq!(s.doc().unwrap().doc, d);
}
/// A brush in a loaded style's pattern whose art sets type in a style renamed on the way in
/// ("Accent 2") is the same brush when the file is loaded again.
#[test]
fn reloaded_brushes_follow_the_renamed_styles_their_art_uses() {
let (mut src, id) = session_with_text("Ink");
src.execute("charStyle.new", &json!({"name": "Accent", "attrs": {"kerning": 40}})).unwrap();
src.execute("charStyle.apply", &json!({"name": "Accent", "id": id})).unwrap();
src.execute("brush.new", &json!({"type": "art", "name": "Lettering", "ids": [id]})).unwrap();
let path = src.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 40, "height": 10})).unwrap()["id"].clone();
src.execute("brush.apply", &json!({"name": "Lettering", "ids": [path]})).unwrap();
src.execute("object.pattern.make", &json!({"name": "Tiles", "edit": false, "ids": [path]})).unwrap();
let other = src.execute("text.create", &json!({"x": 10, "y": 90, "text": "B"})).unwrap()["id"].as_u64().unwrap();
src.execute("paint.setFill", &json!({"swatch": "Tiles", "ids": [other]})).unwrap();
src.execute("charStyle.new", &json!({"name": "Tiled", "id": other})).unwrap();
let data = vectorcraft_format::base64_encode(&vectorcraft_format::save(&src.doc().unwrap().doc, false));
let (mut s, _) = session_with_text("X");
s.execute("charStyle.new", &json!({"name": "Accent", "attrs": {"kerning": 10}})).unwrap();
let load = json!({"dataBase64": data, "name": "Other.vectorcraft"});
let r = s.execute("charStyle.load", &load).unwrap();
assert!(r["added"].as_array().unwrap().contains(&json!("Accent 2")), "{r}");
let d = s.doc().unwrap().doc.clone();
let lib = vectorcraft_brush::library(&d);
let brush = lib.iter().find(|b| b.name == "Lettering").unwrap();
let mut styles = vec![];
for a in brush.art() {
a.walk(&mut |n| {
if let NodeKind::Text(t) = &n.kind {
styles.extend(t.runs.iter().filter_map(|r| r.style.style_name.clone()));
}
});
}
assert_eq!(styles, ["Accent 2"]);
let r = s.execute("charStyle.load", &load).unwrap();
assert_eq!(r["added"], json!([]), "{r}");
assert_eq!(s.doc().unwrap().doc, d);
}
/// A style name from a saved document stays the name used by type in imported pattern art.
#[test]
fn loaded_style_keeps_the_name_used_by_pattern_type() {
let (mut src, id) = session_with_text("Tile");
src.execute("charStyle.new", &json!({"name": "Accent", "attrs": {"kerning": 40}})).unwrap();
src.execute("charStyle.apply", &json!({"name": "Accent", "id": id})).unwrap();
src.execute("object.pattern.make", &json!({"name": "Tiles", "edit": false, "ids": [id]})).unwrap();
let other = src.execute("text.create", &json!({"x": 10, "y": 90, "text": "B"})).unwrap()["id"].as_u64().unwrap();
src.execute("paint.setFill", &json!({"swatch": "Tiles", "ids": [other]})).unwrap();
src.execute("charStyle.new", &json!({"name": "Tiled", "id": other})).unwrap();
let mut source = (*src.doc().unwrap().doc).clone();
source.char_styles.iter_mut().find(|s| s.name == "Accent").unwrap().name = " Accent ".into();
let tile = source.patterns.iter_mut().find(|p| p.name == "Tiles").unwrap();
let art = std::sync::Arc::make_mut(&mut tile.art[0]);
let NodeKind::Text(text) = &mut art.kind else { panic!("pattern art should be type") };
text.runs[0].style.style_name = Some(" Accent ".into());
let data = vectorcraft_format::base64_encode(&vectorcraft_format::save(&source, false));
let (mut dst, _) = session_with_text("X");
let load = json!({"dataBase64": data, "name": "Other.vectorcraft"});
dst.execute("charStyle.load", &load).unwrap();
let doc = &dst.doc().unwrap().doc;
assert!(doc.char_styles.iter().any(|s| s.name == " Accent "));
let NodeKind::Text(text) = &doc.pattern("Tiles").unwrap().art[0].kind else { panic!("pattern art should be type") };
assert_eq!(text.runs[0].style.style_name.as_deref(), Some(" Accent "));
}
/// Load Character Styles brings the paragraph style that type in a loaded style's pattern is set
/// in, renamed when the document has another style of that name, without listing it in the reply.
#[test]
fn loaded_pattern_type_keeps_its_paragraph_style() {
let (mut src, id) = session_with_text("Tile");
src.execute("paraStyle.new", &json!({"name": "Centered", "attrs": {"justify": "Center"}})).unwrap();
src.execute("paraStyle.apply", &json!({"name": "Centered", "id": id})).unwrap();
src.execute("object.pattern.make", &json!({"name": "Tiles", "edit": false, "ids": [id]})).unwrap();
let other = src.execute("text.create", &json!({"x": 10, "y": 90, "text": "B"})).unwrap()["id"].as_u64().unwrap();
src.execute("paint.setFill", &json!({"swatch": "Tiles", "ids": [other]})).unwrap();
src.execute("charStyle.new", &json!({"name": "Tiled", "id": other})).unwrap();
let data = vectorcraft_format::base64_encode(&vectorcraft_format::save(&src.doc().unwrap().doc, false));
let (mut s, _) = session_with_text("X");
s.execute("paraStyle.new", &json!({"name": "Centered", "attrs": {"justify": "Right"}})).unwrap();
let load = json!({"dataBase64": data, "name": "Other.vectorcraft"});
let r = s.execute("charStyle.load", &load).unwrap();
assert_eq!(r, json!({"added": ["Tiled"], "existing": [], "skipped": []}));
let d = s.doc().unwrap().doc.clone();
assert_eq!(listed(&mut s, "paraStyle"), [NORMAL_PARA, "Centered", "Centered 2"]);
assert_eq!(super::style_attrs(&d, true, "Centered 2").map(Value::Object), Some(json!({"justify": "Center"})));
let NodeKind::Text(text) = &d.pattern("Tiles").unwrap().art[0].kind else { panic!("pattern art should be type") };
assert_eq!(text.para_styles().filter_map(|p| p.style_name.as_deref()).collect::<Vec<_>>(), ["Centered 2"]);
// Loaded again, nothing is added.
let r = s.execute("charStyle.load", &load).unwrap();
assert_eq!(r["added"], json!([]), "{r}");
assert_eq!(s.doc().unwrap().doc, d);
}
/// A pattern whose tile paints with itself is found under its renamed copy on a second load.
#[test]
fn self_referencing_pattern_is_not_added_again() {
let (mut src, id) = session_with_text("Patterned");
let square = src.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 10, "height": 10})).unwrap()["id"].clone();
src.execute("object.pattern.make", &json!({"name": "Dots", "edit": false, "ids": [square]})).unwrap();
let tile = src.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 10, "height": 10})).unwrap()["id"].clone();
src.execute("paint.setFill", &json!({"swatch": "Dots", "ids": [tile]})).unwrap();
let art = src.doc().unwrap().doc.node(NodeId(tile.as_u64().unwrap())).unwrap().clone();
assert!(art.appearance.items.iter().any(|item| matches!(item.paint(), vectorcraft_color::Paint::Pattern { pattern, .. } if pattern == "Dots")));
let mut source = (*src.doc().unwrap().doc).clone();
source.patterns.iter_mut().find(|p| p.name == "Dots").unwrap().art = vec![std::sync::Arc::new(art)];
src.execute("paint.setFill", &json!({"swatch": "Dots", "ids": [id]})).unwrap();
src.execute("charStyle.new", &json!({"name": "Patterned", "id": id})).unwrap();
source.char_styles = src.doc().unwrap().doc.char_styles.clone();
assert!(super::style_attrs(&source, false, "Patterned").unwrap()["fill"].to_string().contains("\"Dots\""));
let (mut dst, _) = session_with_text("X");
let different = dst.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 30, "height": 30})).unwrap()["id"].clone();
dst.execute("object.pattern.make", &json!({"name": "Dots", "edit": false, "ids": [different]})).unwrap();
let target = std::sync::Arc::make_mut(&mut dst.doc_mut().unwrap().doc);
super::super::clipboard::resources::import_text_styles(&source, target, true, true).unwrap();
assert!(dst.doc().unwrap().doc.pattern("Dots 2").is_some());
assert!(
dst.doc().unwrap().doc.pattern("Dots 2").unwrap().art[0]
.appearance
.items
.iter()
.any(|item| matches!(item.paint(), vectorcraft_color::Paint::Pattern { pattern, .. } if pattern == "Dots 2"))
);
let before = dst.doc().unwrap().doc.clone();
let target = std::sync::Arc::make_mut(&mut dst.doc_mut().unwrap().doc);
let again = super::super::clipboard::resources::import_text_styles(&source, target, true, true).unwrap();
assert!(again.iter().all(|s| !s.added));
assert_eq!(dst.doc().unwrap().doc, before);
}
/// Patterns whose tiles each paint with themselves are matched one loop at a time: nine of them,
/// each in the document under its name with other art and as the copy an earlier load added,
/// are found under those copies, and a second load adds nothing.
#[test]
fn separate_self_referencing_patterns_are_not_added_again() {
let (mut src, id) = session_with_text("Patterned");
let mut tiles = vec![];
for k in 0..9 {
let name = format!("Dots{k}");
let square = src.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 10, "height": 10})).unwrap()["id"].clone();
src.execute("object.pattern.make", &json!({"name": name, "edit": false, "ids": [square]})).unwrap();
let tile = src.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 10, "height": 10})).unwrap()["id"].clone();
src.execute("paint.setFill", &json!({"swatch": name, "ids": [tile]})).unwrap();
tiles.push((name.clone(), src.doc().unwrap().doc.node(NodeId(tile.as_u64().unwrap())).unwrap().clone()));
src.execute("paint.setFill", &json!({"swatch": name, "ids": [id]})).unwrap();
src.execute("charStyle.new", &json!({"name": format!("Patterned{k}"), "id": id})).unwrap();
}
let mut source = (*src.doc().unwrap().doc).clone();
for (name, art) in tiles {
source.patterns.iter_mut().find(|p| p.name == name).unwrap().art = vec![std::sync::Arc::new(art)];
}
let (mut dst, _) = session_with_text("X");
for k in 0..9 {
let different = dst.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 30, "height": 30})).unwrap()["id"].clone();
dst.execute("object.pattern.make", &json!({"name": format!("Dots{k}"), "edit": false, "ids": [different]})).unwrap();
}
let target = std::sync::Arc::make_mut(&mut dst.doc_mut().unwrap().doc);
super::super::clipboard::resources::import_text_styles(&source, target, true, true).unwrap();
for k in 0..9 {
assert!(dst.doc().unwrap().doc.pattern(&format!("Dots{k} 2")).is_some(), "{k}");
}
let before = dst.doc().unwrap().doc.clone();
let target = std::sync::Arc::make_mut(&mut dst.doc_mut().unwrap().doc);
let again = super::super::clipboard::resources::import_text_styles(&source, target, true, true).unwrap();
assert!(again.iter().all(|s| !s.added));
assert_eq!(dst.doc().unwrap().doc.patterns.len(), before.patterns.len());
assert_eq!(dst.doc().unwrap().doc, before);
// With one loop's pattern gone from the document, the others are still found.
let target = std::sync::Arc::make_mut(&mut dst.doc_mut().unwrap().doc);
target.patterns.retain(|p| !p.name.starts_with("Dots8"));
let left = target.patterns.len();
let third = super::super::clipboard::resources::import_text_styles(&source, target, true, true).unwrap();
assert_eq!(dst.doc().unwrap().doc.patterns.len(), left + 1);
assert!(third.iter().filter(|s| s.name != "Patterned8" && !s.name.starts_with("Patterned8 ")).all(|s| !s.added));
}
/// Nine patterns whose tiles each paint with the next one, the last with the first, in the
/// document under their names with other art and as the copies an earlier load added, are found
/// under those copies, and a second load adds nothing.
#[test]
fn a_long_loop_of_patterns_is_not_added_again() {
let (mut src, id) = session_with_text("Patterned");
for k in 0..9 {
let square = src.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 10, "height": 10})).unwrap()["id"].clone();
src.execute("object.pattern.make", &json!({"name": format!("Dots{k}"), "edit": false, "ids": [square]})).unwrap();
}
let mut tiles = vec![];
for k in 0..9 {
let tile = src.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 10, "height": 10})).unwrap()["id"].clone();
src.execute("paint.setFill", &json!({"swatch": format!("Dots{}", (k + 1) % 9), "ids": [tile]})).unwrap();
tiles.push(src.doc().unwrap().doc.node(NodeId(tile.as_u64().unwrap())).unwrap().clone());
}
src.execute("paint.setFill", &json!({"swatch": "Dots0", "ids": [id]})).unwrap();
src.execute("charStyle.new", &json!({"name": "Patterned", "id": id})).unwrap();
let mut source = (*src.doc().unwrap().doc).clone();
for (k, art) in tiles.into_iter().enumerate() {
source.patterns.iter_mut().find(|p| p.name == format!("Dots{k}")).unwrap().art = vec![std::sync::Arc::new(art)];
}
let (mut dst, _) = session_with_text("X");
for k in 0..9 {
let different = dst.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 30, "height": 30})).unwrap()["id"].clone();
dst.execute("object.pattern.make", &json!({"name": format!("Dots{k}"), "edit": false, "ids": [different]})).unwrap();
}
let target = std::sync::Arc::make_mut(&mut dst.doc_mut().unwrap().doc);
super::super::clipboard::resources::import_text_styles(&source, target, true, true).unwrap();
for k in 0..9 {
assert!(dst.doc().unwrap().doc.pattern(&format!("Dots{k} 2")).is_some(), "{k}");
}
let before = dst.doc().unwrap().doc.clone();
let target = std::sync::Arc::make_mut(&mut dst.doc_mut().unwrap().doc);
let again = super::super::clipboard::resources::import_text_styles(&source, target, true, true).unwrap();
assert!(again.iter().all(|s| !s.added));
assert_eq!(dst.doc().unwrap().doc.patterns.len(), before.patterns.len());
assert_eq!(dst.doc().unwrap().doc, before);
}
/// #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<T: DeserializeOwned>() -> std::collections::BTreeSet<String> {
attr_names::<T>().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::<std::collections::BTreeSet<_>>();
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::<ParaStyle>(), "Redefine captures every attribute");
s.execute("paraStyle.new", &json!({"name": "Copy", "id": local})).unwrap();
assert_eq!(defined(&s, 1), settable::<ParaStyle>(), "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. A glyph choice belongs to
/// one character, and the space before a line's first character to where it stands (#5111), so
/// neither is a reusable style attribute.
#[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::<std::collections::BTreeSet<_>>();
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]));
let mut reusable = settable::<CharStyle>();
reusable.remove("glyphAlternate");
reusable.remove("kerningBefore");
assert_eq!(defined(&s, 0), reusable, "Redefine captures every reusable attribute");
s.execute("charStyle.new", &json!({"name": "Copy", "id": local})).unwrap();
assert_eq!(defined(&s, 1), reusable, "New captures every reusable 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<usize>, 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, &params).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);
}
}
}
fn paras(s: &Session, id: u64) -> Vec<ParaStyle> {
match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
NodeKind::Text(t) => t.paragraph_styles(),
_ => panic!("not text"),
}
}
/// `paraStyle.edit` renames a paragraph style and replaces its attributes in one undo step: every
/// paragraph using it takes the new values, a paragraph's local override stays, and the saved file
/// keeps the sparse definition.
#[test]
fn paragraph_style_options_edit_applies_to_users_and_is_one_saved_undo_step() {
use vectorcraft_doc::Composer;
let (mut s, id) = session_with_text("One\nTwo\nThree");
s.execute("paraStyle.new", &json!({"name": "Body", "attrs": {"left_indent": 6.0, "composer": "everyLine", "tabs": [{"position": 36.0}]}}))
.unwrap();
s.execute("paraStyle.apply", &json!({"name": "Body", "id": id, "start": 0, "end": 7})).unwrap();
s.execute("text.setFormat", &json!({"id": id, "start": 4, "end": 4, "leftIndent": 20.0})).unwrap();
let before = s.doc().unwrap().doc.clone();
let undo = s.doc().unwrap().history.undo.len();
let attrs = json!({"left_indent": 12.0, "composer": "singleLine", "tabs": [{"position": 36.0}],
"hyphenation": {"words_longer_than": 8}, "justification": {"word_spacing": [70.0, 90.0, 120.0]}});
s.execute("paraStyle.edit", &json!({"name": "Body", "to": "Text", "attrs": attrs})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
let p = paras(&s, id);
assert_eq!(
p.iter().map(|p| (p.style_name.as_deref(), p.left_indent, p.composer)).collect::<Vec<_>>(),
[(Some("Text"), 12.0, Composer::SingleLine), (Some("Text"), 20.0, Composer::SingleLine), (None, 0.0, Composer::EveryLine)]
);
assert_eq!(p[0].hyphenation.words_longer_than, 8);
assert_eq!(p[1].justification.word_spacing, [70.0, 90.0, 120.0]);
let def = s.doc().unwrap().doc.para_styles.iter().find(|d| d.name == "Text").unwrap().attrs.clone();
let mut keys: Vec<&str> = def.keys().map(String::as_str).collect();
keys.sort_unstable();
assert_eq!(keys, ["composer", "hyphenation", "justification", "left_indent", "tabs"]);
let saved = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
assert_eq!(saved.para_styles.iter().find(|d| d.name == "Text").unwrap().attrs, def);
let changed = s.doc().unwrap().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, changed);
// The Normal style, an empty or a taken name and an unknown style are errors.
s.execute("paraStyle.new", &json!({"name": "Other"})).unwrap();
for (params, why) in [
(json!({"name": "Text", "to": " ", "attrs": {}}), "give the style a name"),
(json!({"name": "Text", "to": "Other", "attrs": {}}), "already exists"),
(json!({"name": "Missing", "to": "Missing", "attrs": {}}), "no style `Missing`"),
(json!({"name": NORMAL_PARA, "to": NORMAL_PARA, "attrs": {}}), "can't be edited"),
] {
let error = s.execute("paraStyle.edit", &params).unwrap_err().to_string();
assert!(error.contains(why), "{params}: {error}");
}
}
/// A changed paragraph attribute outside the range `text.setFormat` keeps it to is an error for
/// `paraStyle.edit`, `paraStyle.setAttrs` and `paraStyle.new`, with no change to the document; the
/// ends of each range are accepted, and an out-of-range value the style already has can be kept.
#[test]
fn paragraph_style_attributes_are_checked_before_a_definition_changes() {
let (mut s, _) = session_with_text("Text");
s.execute("paraStyle.new", &json!({"name": "Body", "attrs": {"left_indent": 6.0}})).unwrap();
let before = s.doc().unwrap().doc.clone();
let undo = s.doc().unwrap().history.undo.len();
for (attrs, key) in [
(json!({"left_indent": -1296.1}), "left_indent"),
(json!({"right_indent": 1296.1}), "right_indent"),
(json!({"first_line_indent": 1e300}), "first_line_indent"),
(json!({"space_before": 1296.1}), "space_before"),
(json!({"space_after": -1296.1}), "space_after"),
(json!({"hyphenation": {"words_longer_than": 0}}), "hyphenation.words_longer_than"),
(json!({"hyphenation": {"words_longer_than": 4000000000_u64}}), "hyphenation.words_longer_than"),
(json!({"hyphenation": {"after_first": 16}}), "hyphenation.after_first"),
(json!({"hyphenation": {"before_last": 0}}), "hyphenation.before_last"),
(json!({"hyphenation": {"hyphen_limit": 26}}), "hyphenation.hyphen_limit"),
(json!({"hyphenation": {"zone": -1.0}}), "hyphenation.zone"),
(json!({"hyphenation": {"zone": 1296.1}}), "hyphenation.zone"),
(json!({"justification": {"word_spacing": [80.0, 100.0, 1000.1]}}), "justification.word_spacing"),
(json!({"justification": {"word_spacing": [120.0, 100.0, 133.0]}}), "justification.word_spacing"),
(json!({"justification": {"letter_spacing": [-100.1, 0.0, 0.0]}}), "justification.letter_spacing"),
(json!({"justification": {"letter_spacing": [0.0, 10.0, 5.0]}}), "justification.letter_spacing"),
(json!({"justification": {"glyph_scaling": [49.0, 100.0, 100.0]}}), "justification.glyph_scaling"),
(json!({"justification": {"auto_leading": 500.1}}), "justification.auto_leading"),
(json!({"tabs": [{"position": 100000.1}]}), "tabs.position"),
(json!({"tabs": [{"position": -1.0}]}), "tabs.position"),
] {
for cmd in ["paraStyle.edit", "paraStyle.setAttrs", "paraStyle.new"] {
let params = match cmd {
"paraStyle.edit" => json!({"name": "Body", "to": "Body", "attrs": attrs}),
"paraStyle.new" => json!({"name": "Other", "attrs": attrs}),
_ => json!({"name": "Body", "attrs": attrs}),
};
let error = s.execute(cmd, &params).unwrap_err().to_string();
assert!(error.contains(&format!("`{key}`")), "{cmd} {attrs}: {error}");
}
}
// Malformed values are errors too.
for attrs in [json!({"hyphenation": {"words_longer_than": -1}}), json!({"justification": {"word_spacing": [1.0, 2.0]}}), json!({"tabs": 3})] {
assert!(s.execute("paraStyle.edit", &json!({"name": "Body", "to": "Body", "attrs": attrs})).is_err(), "{attrs}");
}
assert_eq!(s.doc().unwrap().doc, before);
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
let ends = json!({
"left_indent": -1296.0, "right_indent": 1296.0, "first_line_indent": 0.0, "space_before": 1296.0, "space_after": -1296.0,
"hyphenation": {"words_longer_than": 25, "after_first": 1, "before_last": 15, "hyphen_limit": 0, "zone": 1296.0},
"justification": {"word_spacing": [0.0, 0.0, 1000.0], "letter_spacing": [-100.0, 0.0, 500.0], "glyph_scaling": [50.0, 100.0, 200.0], "auto_leading": 500.0},
"tabs": [{"position": 0.0}, {"position": 100000.0}]
});
s.execute("paraStyle.setAttrs", &json!({"name": "Body", "attrs": ends})).unwrap();
// A style that already holds a value out of range (one read from a file) can be renamed with it.
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
doc.para_styles.iter_mut().find(|d| d.name == "Body").unwrap().attrs.insert("left_indent".into(), json!(2000.0));
s.execute("paraStyle.edit", &json!({"name": "Body", "to": "Renamed", "attrs": {"left_indent": 2000.0}})).unwrap();
assert!(s.doc().unwrap().doc.para_styles.iter().any(|d| d.name == "Renamed"));
// Such a Hyphenation, Justification or tab value is kept while another option of its group changes,
// and a changed option is still checked.
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
let held = json!({
"hyphenation": {"words_longer_than": 40, "after_first": 2},
"justification": {"letter_spacing": [0.0, 0.0, 600.0], "auto_leading": 120.0},
"tabs": [{"position": 200000.0}]
});
let attrs = &mut doc.para_styles.iter_mut().find(|d| d.name == "Renamed").unwrap().attrs;
attrs.extend(held.as_object().unwrap().clone());
let edited = json!({
"hyphenation": {"words_longer_than": 40, "after_first": 3},
"justification": {"letter_spacing": [0.0, 0.0, 600.0], "auto_leading": 150.0},
"tabs": [{"position": 200000.0}, {"position": 72.0}]
});
s.execute("paraStyle.setAttrs", &json!({"name": "Renamed", "attrs": edited})).unwrap();
let def = |s: &Session| s.doc().unwrap().doc.para_styles.iter().find(|d| d.name == "Renamed").unwrap().attrs.clone();
assert_eq!(def(&s)["hyphenation"]["after_first"], json!(3));
assert_eq!(def(&s)["justification"]["auto_leading"], json!(150.0));
for (attrs, key) in [
(json!({"hyphenation": {"words_longer_than": 40, "after_first": 16}}), "hyphenation.after_first"),
(json!({"justification": {"auto_leading": 501.0}}), "justification.auto_leading"),
(json!({"tabs": [{"position": 200000.0}, {"position": 72.0}, {"position": 100001.0}]}), "tabs.position"),
] {
let error = s.execute("paraStyle.setAttrs", &json!({"name": "Renamed", "attrs": attrs})).unwrap_err().to_string();
assert!(error.contains(&format!("`{key}`")), "{attrs}: {error}");
}
}
/// A paragraph style's changed tab stops are checked stop by stop: a stop the style holds is kept
/// as it is, and a stop at a held position with another alignment is a changed stop. A changed
/// stop's leader has at most eight characters and its Align On one character, a changed list has
/// at most 256 stops (or as many as the style holds, here 300), and the stops are stored sorted by position,
/// keeping the first of stops at one position.
#[test]
fn paragraph_style_tab_stops_are_checked_by_stop_and_sorted() {
let (mut s, _) = session_with_text("Text");
s.execute("paraStyle.new", &json!({"name": "Body", "attrs": {}})).unwrap();
let def = |s: &Session| s.doc().unwrap().doc.para_styles.iter().find(|d| d.name == "Body").unwrap().attrs.clone();
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
let held: Vec<Value> =
(0..300).map(|i| json!({"position": 200000.0 + f64::from(i), "align": "left", "leader": "123456789", "alignOn": "."})).collect();
doc.para_styles.iter_mut().find(|d| d.name == "Body").unwrap().attrs.insert("tabs".into(), Value::from(held.clone()));
let before = s.doc().unwrap().doc.clone();
let mut moved = held.clone();
moved[0]["align"] = json!("right");
for (stops, why) in [
(Value::from(moved), "`tabs.position`"),
(json!([{"position": 36.0, "leader": "123456789"}]), "`tabs.leader` must be at most 8 characters"),
(Value::from((0..301).map(|i| json!({"position": f64::from(i)})).collect::<Vec<_>>()), "at most 256 tab stops"),
(json!([{"position": 36.0, "alignOn": ",."}]), "expected a character"),
] {
for cmd in ["paraStyle.edit", "paraStyle.setAttrs"] {
let params = match cmd {
"paraStyle.edit" => json!({"name": "Body", "to": "Body", "attrs": {"tabs": stops}}),
_ => json!({"name": "Body", "attrs": {"tabs": stops}}),
};
let error = s.execute(cmd, &params).unwrap_err().to_string();
assert!(error.contains(why), "{cmd}: {error}");
}
}
let many = Value::from((0..257).map(|i| json!({"position": f64::from(i)})).collect::<Vec<_>>());
let error = s.execute("paraStyle.new", &json!({"name": "Other", "attrs": {"tabs": many}})).unwrap_err().to_string();
assert!(error.contains("at most 256 tab stops"), "{error}");
assert_eq!(s.doc().unwrap().doc, before);
// The held stops stay while one is dropped and another attribute changes.
s.execute("paraStyle.setAttrs", &json!({"name": "Body", "attrs": {"tabs": &held[1..], "left_indent": 4.0}})).unwrap();
assert_eq!(def(&s)["tabs"].as_array().map(Vec::len), Some(299));
let stops = json!([{"position": 72.0, "align": "right"}, {"position": 36.0}, {"position": 36.0000001, "align": "center"}, {"position": 0.0, "leader": "12345678"}]);
s.execute("paraStyle.setAttrs", &json!({"name": "Body", "attrs": {"tabs": stops}})).unwrap();
assert_eq!(
def(&s)["tabs"],
json!([{"position": 0.0, "align": "left", "leader": "12345678", "alignOn": "."}, {"position": 36.0, "align": "left", "alignOn": "."},
{"position": 72.0, "align": "right", "alignOn": "."}])
);
s.execute("paraStyle.setAttrs", &json!({"name": "Body", "attrs": {"tabs": []}})).unwrap();
assert_eq!(def(&s)["tabs"], json!([]), "an empty list stays set");
}
/// Load Paragraph Styles and Load All Styles skip a paragraph style whose indents, spacing,
/// Hyphenation or Justification options or tab stops are out of the range `paraStyle.new` takes,
/// and load the others with their stops.
#[test]
fn loading_skips_paragraph_styles_out_of_range() {
let file = [
("Good", json!({"left_indent": 6.0, "tabs": [{"position": 36.0, "align": "decimal", "alignOn": ","}]})),
("Indent", json!({"left_indent": 2000.0})),
("Space", json!({"space_after": -1300.0})),
("Hyphens", json!({"hyphenation": {"words_longer_than": 40}})),
("Spacing", json!({"justification": {"word_spacing": [120.0, 100.0, 133.0]}})),
("Leader", json!({"tabs": [{"position": 10.0, "leader": "123456789"}]})),
];
let data = styles_file(&[], &file);
for cmd in ["paraStyle.load", "textStyle.loadAll"] {
let (mut s, _) = session_with_text("Title");
let r = s.execute(cmd, &json!({"dataBase64": data, "name": "Other.vectorcraft"})).unwrap();
let r = if cmd == "textStyle.loadAll" { r["paraStyles"].clone() } else { r };
assert_eq!(r, json!({"added": ["Good"], "existing": [], "skipped": ["Indent", "Space", "Hyphens", "Spacing", "Leader"]}), "{cmd}");
let good = super::style_attrs(&s.doc().unwrap().doc, true, "Good").unwrap();
assert_eq!(good["tabs"], json!([{"position": 36.0, "align": "decimal", "alignOn": ","}]), "{cmd}");
}
}