Character Styles: Style Options sets character color and OpenType features

Character Style Options and New Character Style get a Character Color
section and an OpenType Features section.

Character Color sets the fill and stroke (None or a document swatch;
a global swatch or a tint of one stays linked, with a Tint from 0% to
100%), the stroke Weight, and Overprint Fill and Overprint Stroke. A
gradient or pattern paint the style already has stays until another
paint is chosen.

OpenType Features sets Figure, Position, Standard and Discretionary
Ligatures, Contextual, Stylistic and Titling Alternates, Swash,
Ordinals, Fractions and Stylistic Sets 1 to 20. A changed control
replaces only its own tags in the style's feature list, so other tags
stay, and Default Figure and Default Position are stored as their
features turned off, distinct from unset.

Empty controls stay unset, and each section's Reset Panel clears only
its own attributes.

charStyle.new, charStyle.edit and charStyle.setAttrs check a changed
fill, stroke, stroke weight or feature list: tints from 0 to 1, links
to global swatches of the document (the color follows the swatch),
weights from 0 to 4000000, and known feature tags, at most 256. Values
the style or the text it is made from already has are kept.
charStyle.duplicate keeps the copied style's values in the same way, so
a style with a language tag that is not in the list can be duplicated.

Editing a global swatch now also updates the fill and stroke stored in
character styles, so their text no longer shows as overriding the style
and text styled later takes the new color. swatch.unused counts a swatch
a character style links to as used.
This commit is contained in:
dotCooCoo
2026-11-03 16:03:39 +07:00
committed by bflatastic
parent a587043373
commit 84e344c475
31 changed files with 1015 additions and 24 deletions
+138
View File
@@ -257,6 +257,144 @@ fn new_character_style_keeps_a_language_tag_from_the_text() {
assert!(error.contains("unknown language"), "{error}");
}
/// The fill of the first run of `id` and its overprint flags, its stroke and stroke weight.
fn run_paints(s: &Session, id: u64) -> (vectorcraft_color::Paint, vectorcraft_color::Paint, f64, bool, bool) {
let st = runs(s, id)[0].style.clone();
(st.fill, st.stroke, st.stroke_width, st.overprint_fill, st.overprint_stroke)
}
#[test]
fn character_style_color_and_features_reach_text_and_follow_swatch_edits() {
let (mut s, id) = session_with_text("Text 1/2");
s.execute("swatch.new", &json!({"name": "Brand", "color": "#2050a0", "global": true})).unwrap();
let brand = s.doc().unwrap().doc.tint_paint("Brand", 1.0).unwrap();
let mut tinted = serde_json::to_value(s.doc().unwrap().doc.tint_paint("Brand", 1.0).unwrap()).unwrap();
// The command takes the color of the link at the tint, whatever color is sent.
tinted["tint"] = json!(0.4);
let attrs = json!({"fill": brand, "stroke": tinted, "stroke_width": 2.0, "overprint_fill": true, "overprint_stroke": false,
"features": ["dlig", "-liga", "tnum", "lnum", "-onum", "-pnum", "ss02", "-ss01"]});
s.execute("charStyle.new", &json!({"name": "Ink", "attrs": attrs})).unwrap();
s.execute("charStyle.apply", &json!({"name": "Ink", "id": id})).unwrap();
let want_stroke = s.doc().unwrap().doc.tint_paint("Brand", 0.4).unwrap();
assert_eq!(run_paints(&s, id), (brand, want_stroke.clone(), 2.0, true, false));
assert_eq!(runs(&s, id)[0].style.features, ["dlig", "-liga", "tnum", "lnum", "-onum", "-pnum", "ss02", "-ss01"]);
let def = |s: &Session| s.doc().unwrap().doc.char_styles.iter().find(|d| d.name == "Ink").unwrap().attrs.clone();
assert_eq!(def(&s)["stroke"], serde_json::to_value(&want_stroke).unwrap());
let saved = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
assert_eq!(saved.char_styles.iter().find(|d| d.name == "Ink").unwrap().attrs, def(&s));
s.execute("swatch.edit", &json!({"name": "Brand", "color": "#a02050"})).unwrap();
let (brand, tint40) = (s.doc().unwrap().doc.tint_paint("Brand", 1.0).unwrap(), s.doc().unwrap().doc.tint_paint("Brand", 0.4).unwrap());
assert_eq!(run_paints(&s, id).0, brand);
assert_eq!(run_paints(&s, id).1, tint40);
assert_eq!(def(&s)["fill"], serde_json::to_value(&brand).unwrap());
assert_eq!(def(&s)["stroke"], serde_json::to_value(&tint40).unwrap());
let later = s.execute("text.create", &json!({"x": 10, "y": 90, "text": "Later"})).unwrap()["id"].as_u64().unwrap();
s.execute("charStyle.apply", &json!({"name": "Ink", "id": later})).unwrap();
assert_eq!(run_paints(&s, later).0, brand);
}
#[test]
fn character_style_color_and_features_are_checked() {
let (mut s, _) = session_with_text("Text");
s.execute("swatch.new", &json!({"name": "Brand", "color": "#2050a0", "global": true})).unwrap();
s.execute("charStyle.new", &json!({"name": "Body", "attrs": {}})).unwrap();
let mut over = serde_json::to_value(s.doc().unwrap().doc.tint_paint("Brand", 1.0).unwrap()).unwrap();
over["tint"] = json!(1.5);
let mut missing = over.clone();
missing["tint"] = json!(0.5);
missing["swatch"] = json!("Gone");
s.execute("swatch.new", &json!({"name": "Local", "color": "#102030"})).unwrap();
let mut local = missing.clone();
local["swatch"] = json!("Local");
let many: Vec<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": many}), "features lists at most 256 tags"),
(json!({"fill": {"type": "solid"}}), "missing field"),
] {
for cmd in ["charStyle.edit", "charStyle.setAttrs", "charStyle.new"] {
let p = match cmd {
"charStyle.edit" => json!({"name": "Body", "to": "Body", "attrs": attrs}),
"charStyle.new" => json!({"name": "Other", "attrs": attrs}),
_ => json!({"name": "Body", "attrs": attrs}),
};
let error = s.execute(cmd, &p).unwrap_err().to_string();
assert!(error.contains(message), "{cmd} {attrs}: {error}");
}
}
assert_eq!(s.doc().unwrap().doc, before);
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
}
#[test]
fn style_paints_count_as_swatch_uses_and_gradient_stops_are_checked() {
let (mut s, _) = session_with_text("Text");
s.execute("swatch.new", &json!({"name": "Brand", "color": "#2050a0", "global": true})).unwrap();
s.execute("swatch.new", &json!({"name": "Spare", "color": "#a02050", "global": true})).unwrap();
let unused = |s: &mut Session| s.execute("swatch.unused", &json!({})).unwrap()["names"].clone();
assert!(unused(&mut s).as_array().unwrap().contains(&json!("Brand")));
let brand = s.doc().unwrap().doc.tint_paint("Brand", 0.5).unwrap();
s.execute("charStyle.new", &json!({"name": "Ink", "attrs": {"stroke": brand}})).unwrap();
let names = unused(&mut s);
assert!(!names.as_array().unwrap().contains(&json!("Brand")), "{names}");
assert!(names.as_array().unwrap().contains(&json!("Spare")), "{names}");
let mut g = vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient::default());
g.gradient.stops[0].swatch = Some("Brand".into());
g.gradient.stops[0].tint = 0.25;
let fill = serde_json::to_value(vectorcraft_color::Paint::Gradient(Box::new(g.clone()))).unwrap();
s.execute("charStyle.new", &json!({"name": "Fade", "attrs": {"fill": fill}})).unwrap();
let def = s.doc().unwrap().doc.char_styles.iter().find(|d| d.name == "Fade").unwrap().attrs.clone();
let want = s.doc().unwrap().doc.global_color("Brand").unwrap().tinted(0.25);
assert_eq!(def["fill"]["gradient"]["stops"][0]["color"], serde_json::to_value(want).unwrap());
g.gradient.stops[0].swatch = Some("Gone".into());
let gone = serde_json::to_value(vectorcraft_color::Paint::Gradient(Box::new(g))).unwrap();
let error = s.execute("charStyle.new", &json!({"name": "Bad", "attrs": {"fill": gone}})).unwrap_err().to_string();
assert!(error.contains("no swatch `Gone`"), "{error}");
}
#[test]
fn styles_from_files_keep_their_unknown_feature_tags_and_swatch_links() {
let (mut s, _) = session_with_text("Text");
s.execute("charStyle.new", &json!({"name": "Body", "attrs": {}})).unwrap();
let linked = json!({"type": "solid", "color": {"model": "rgb", "r": 1.0, "g": 0.0, "b": 0.0}, "swatch": "Gone"});
if let Some(def) = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).char_styles.iter_mut().find(|d| d.name == "Body") {
def.attrs.insert("features".into(), json!(["zzzz", "dlig"]));
def.attrs.insert("fill".into(), linked.clone());
}
let fill = s.doc().unwrap().doc.char_styles[0].attrs["fill"].clone();
s.execute("charStyle.edit", &json!({"name": "Body", "to": "Body", "attrs": {"features": ["zzzz", "-dlig"], "fill": fill}})).unwrap();
let def = s.doc().unwrap().doc.char_styles.iter().find(|d| d.name == "Body").unwrap().attrs.clone();
assert_eq!(def["features"], json!(["zzzz", "-dlig"]));
let mut replaced = def.clone();
replaced.insert("size".into(), json!(14.0));
s.execute("charStyle.setAttrs", &json!({"name": "Body", "attrs": replaced})).unwrap();
s.execute("charStyle.setAttrs", &json!({"name": "Body", "attrs": def})).unwrap();
s.execute("charStyle.duplicate", &json!({"name": "Body"})).unwrap();
assert_eq!(s.doc().unwrap().doc.char_styles.iter().find(|d| d.name == "Body copy").unwrap().attrs, def);
// A list longer than a style made here can have stays editable, but does not grow.
let long: Vec<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");