mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-10 01:50:22 -06:00
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:
@@ -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() {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user