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
+11 -2
View File
@@ -818,7 +818,7 @@ fn swatch_unused(s: &mut Session, _: &Value) -> Result<Value> {
Some(l) if d.swatch(l).is_some() => links.push((l.clone(), tint)),
_ => colors.push(*c),
};
d.visit_paints(&mut |p| match p {
let mut paint = |p: &Paint| match p {
Paint::Solid { color: c, swatch, tint } => color(c, swatch.as_ref(), *tint),
Paint::Gradient(g) => {
g.gradient.stops.iter().for_each(|s| color(&s.color, s.swatch.as_ref(), s.tint));
@@ -827,7 +827,16 @@ fn swatch_unused(s: &mut Session, _: &Value) -> Result<Value> {
}
Paint::Pattern { pattern, .. } => patterns.push(pattern.clone()),
Paint::None => {}
});
};
d.visit_paints(&mut paint);
// A character style's fill and stroke use their swatches even while no text uses the style.
for attrs in d.char_styles.iter().map(|def| &def.attrs) {
for key in ["fill", "stroke"] {
if let Some(p) = attrs.get(key).and_then(|v| serde_json::from_value::<Paint>(v.clone()).ok()) {
paint(&p);
}
}
}
let used = |w: &Swatch| {
gradient_links.contains(&w.name)
|| links.iter().any(|(l, t)| match w.tint_of() {
+76 -8
View File
@@ -477,10 +477,12 @@ fn name_param<'a>(p: &'a Value, kind: Kind, verb: &str) -> Result<&'a str> {
str_param(p, "name").ok_or_else(|| bad(&kind.cmd(verb), "missing `name`"))
}
/// The `attrs` of `p`, validated. `kept` holds the style's current attributes (`charStyle.edit`) or
/// the target text's attributes (`charStyle.new`): a language tag that is not in the list, which a
/// file can give a style or a run, stays when `attrs` keeps it.
fn attrs_param(p: &Value, kind: Kind, verb: &str, kept: Option<&Map<String, Value>>) -> Result<Option<Map<String, Value>>> {
/// The `attrs` of `p`, validated. `kept` holds the style's current attributes (`charStyle.edit`,
/// `charStyle.setAttrs`), the target text's attributes (`charStyle.new`) or the attributes of the
/// style being copied (`charStyle.duplicate`): a language tag that is not in the list, a swatch link the document does
/// not have, or an OpenType feature tag the shaper does not know, which a file can give a style or
/// a run, stays when `attrs` keeps it.
fn attrs_param(d: &Document, p: &Value, kind: Kind, verb: &str, kept: Option<&Map<String, Value>>) -> Result<Option<Map<String, Value>>> {
match p.get("attrs") {
None | Some(Value::Null) => Ok(None),
Some(Value::Object(m)) => {
@@ -490,12 +492,25 @@ fn attrs_param(p: &Value, kind: Kind, verb: &str, kept: Option<&Map<String, Valu
let full = match kind {
Kind::Char => {
let mut st = with_attrs(&CharStyle::default(), m, &c)?;
let changed = |key: &str| m.contains_key(key) && kept.and_then(|k| k.get(key)) != m.get(key);
// A language of the list, stored as its tag (as text.setFormat takes it).
if let Some(l) = &st.language
&& kept.and_then(|k| k.get("language")) != m.get("language")
{
st.language = vectorcraft_doc::setup::char_language(l).map_err(|e| bad(&c, e))?.map(str::to_string);
}
for (key, paint) in [("fill", &mut st.fill), ("stroke", &mut st.stroke)] {
if changed(key) {
style_paint(d, paint, key, &c)?;
}
}
let (key, lo, hi) = ("stroke_width", 0.0, vectorcraft_doc::MAX_COORD);
if changed(key) && !(lo..=hi).contains(&st.stroke_width) {
return Err(bad(&c, format!("`{key}` must be from {lo} to {hi}")));
}
if changed("features") {
style_features(&st.features, kept, &c)?;
}
char_attrs(&st)
}
Kind::Para => para_attrs(&with_attrs(&ParaStyle::default(), m, &c)?),
@@ -506,6 +521,51 @@ fn attrs_param(p: &Value, kind: Kind, verb: &str, kept: Option<&Map<String, Valu
}
}
/// Check a character style's `fill` or `stroke` (`key`): each solid color and gradient stop has a
/// tint from 0 to 1, and a link to a global swatch of `d`, whose color at that tint it takes.
fn style_paint(d: &Document, paint: &mut vectorcraft_color::Paint, key: &str, c: &str) -> Result<()> {
match paint {
vectorcraft_color::Paint::Solid { color, swatch, tint } => style_color(d, color, swatch.as_deref(), *tint, key, c),
vectorcraft_color::Paint::Gradient(g) => {
g.gradient.stops.iter_mut().try_for_each(|stop| style_color(d, &mut stop.color, stop.swatch.as_deref(), stop.tint, key, c))
}
_ => Ok(()),
}
}
/// Check one color of a character style's `key` paint for [`style_paint`].
fn style_color(d: &Document, color: &mut vectorcraft_color::Color, swatch: Option<&str>, tint: f32, key: &str, c: &str) -> Result<()> {
if !(0.0..=1.0).contains(&tint) {
let (key, lo, hi) = (format!("{key}.tint"), 0, 1);
return Err(bad(c, format!("`{key}` must be from {lo} to {hi}")));
}
if let Some(name) = swatch {
if d.swatch(name).is_none() {
return Err(bad(c, format!("no swatch `{name}`")));
}
let base = d.global_color(name).ok_or_else(|| bad(c, format!("`{name}` is a process colour: tints apply to global and spot colours")))?;
*color = base.tinted(tint);
}
Ok(())
}
/// Check a character style's OpenType `features`: at most [`vectorcraft_text::MAX_STYLE_FEATURES`]
/// tags, or no more than the `kept` attributes already list, each one the shaper knows or one the
/// `kept` attributes already list.
fn style_features(features: &[String], kept: Option<&Map<String, Value>>, c: &str) -> Result<()> {
let kept: Vec<&str> = kept.and_then(|k| k.get("features")).and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).collect();
let max = vectorcraft_text::MAX_STYLE_FEATURES;
if features.len() > max.max(kept.len()) {
return Err(bad(c, format!("features lists at most {max} tags")));
}
// One `-` or `+` at most, as the shaper reads a tag.
let known = |t: &str| vectorcraft_text::OtFeatures::known_tag(t) && !t.strip_prefix(['-', '+']).unwrap_or(t).starts_with(['-', '+']);
match features.iter().find(|t| !known(t) && !kept.contains(&t.as_str())) {
Some(t) => Err(bad(c, format!("unknown OpenType feature `{t}`"))),
None => Ok(()),
}
}
/// The attributes of a new style made from nothing (no `attrs`, no text selected): while the
/// interface is in Japanese, new type's em box top-to-top leading (paragraph styles) and em box
/// centre alignment (character styles); else none (the defaults).
@@ -538,9 +598,15 @@ fn list(s: &mut Session, kind: Kind) -> Result<Value> {
}
fn new(s: &mut Session, p: &Value, kind: Kind) -> Result<Value> {
new_from(s, p, kind, None)
}
/// `charStyle.new`, `paraStyle.new`: `copied` holds the attributes of the style a copy is made of
/// (`charStyle.duplicate`), which `attrs` keeps as [`attrs_param`] keeps the target text's.
fn new_from(s: &mut Session, p: &Value, kind: Kind, copied: Option<&Map<String, Value>>) -> Result<Value> {
let c = kind.cmd("new");
let target = selection_attrs(s, p, kind)?;
let attrs = match attrs_param(p, kind, "new", target.as_ref())? {
let attrs = match attrs_param(&s.doc()?.doc, p, kind, "new", copied.or(target.as_ref()))? {
Some(a) => a,
None => match target {
Some(a) => a,
@@ -668,7 +734,9 @@ fn redefine(s: &mut Session, p: &Value, kind: Kind) -> Result<Value> {
fn set_attrs(s: &mut Session, p: &Value, kind: Kind) -> Result<Value> {
let name = name_param(p, kind, "setAttrs")?.to_string();
let attrs = attrs_param(p, kind, "setAttrs", None)?.ok_or_else(|| bad(&kind.cmd("setAttrs"), "missing `attrs`"))?;
let d = &s.doc()?.doc;
let old = kind.attrs(d, &name);
let attrs = attrs_param(d, p, kind, "setAttrs", old.as_ref())?.ok_or_else(|| bad(&kind.cmd("setAttrs"), "missing `attrs`"))?;
redefine_to(s, kind, &name, attrs, "setAttrs")
}
@@ -678,7 +746,7 @@ fn edit_style(s: &mut Session, p: &Value, kind: Kind) -> Result<Value> {
let to = str_param(p, "to").ok_or_else(|| bad(&c, "missing `to`"))?.trim().to_string();
let d = &s.doc()?.doc;
let old = kind.attrs(d, &name).ok_or_else(|| bad(&c, format!("no style `{name}`")))?;
let attrs = attrs_param(p, kind, "edit", Some(&old))?.ok_or_else(|| bad(&c, "missing `attrs`"))?;
let attrs = attrs_param(d, p, kind, "edit", Some(&old))?.ok_or_else(|| bad(&c, "missing `attrs`"))?;
if name == kind.normal() {
return Err(bad(&c, format!("`{name}` can't be edited this way")));
}
@@ -726,7 +794,7 @@ fn duplicate(s: &mut Session, p: &Value, kind: Kind) -> Result<Value> {
.map(|i| if i == 1 { format!("{stem} copy") } else { format!("{stem} copy {i}") })
.find(|n| kind.attrs(d, n).is_none())
.unwrap_or_else(|| format!("{stem} copy"));
new(s, &json!({ "name": copy, "attrs": attrs }), kind)
new_from(s, &json!({ "name": copy, "attrs": attrs }), kind, Some(&attrs))
}
fn rename(s: &mut Session, p: &Value, kind: Kind) -> Result<Value> {
+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");