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
+25 -2
View File
@@ -110,8 +110,9 @@ impl Document {
/// Visit every color that can link to a swatch (solid paints and gradient stops of fills,
/// strokes and text runs) in the art (opacity masks included), symbol definitions, graph
/// designs, pattern tiles and the tile that pattern editing's Cancel puts back, graphic styles
/// and the swatches themselves (tint swatches, gradient swatches' stops).
/// designs, pattern tiles and the tile that pattern editing's Cancel puts back, graphic styles,
/// character styles' fill and stroke and the swatches themselves (tint swatches, gradient
/// swatches' stops).
/// `f(colour, link, tint)` may change them and returns true when it did
/// ([`Paint::map_links`]). Only nodes holding a changed paint (and the paths to them) are
/// copied. Returns the number of changed paints.
@@ -134,6 +135,11 @@ impl Document {
for w in self.swatches_iter_mut() {
n += usize::from(w.paint.map_links(f));
}
for def in &mut self.char_styles {
for key in ["fill", "stroke"] {
n += usize::from(map_style_paint(&mut def.attrs, key, f));
}
}
n
}
@@ -198,6 +204,23 @@ pub fn color_name(c: Color) -> String {
/// true when it changed them.
pub type LinkFn<'a> = dyn FnMut(&mut Color, &mut Option<String>, &mut f32) -> bool + 'a;
/// Apply `f` to the paint a character style's attribute `key` holds, as [`Paint::map_links`]
/// does, and store the result in the attribute. True when it changed.
fn map_style_paint(attrs: &mut serde_json::Map<String, serde_json::Value>, key: &str, f: &mut LinkFn) -> bool {
let Some(value) = attrs.get_mut(key) else { return false };
let Ok(mut paint) = serde_json::from_value::<Paint>(value.clone()) else { return false };
if !paint.map_links(f) {
return false;
}
match serde_json::to_value(&paint) {
Ok(changed) => {
*value = changed;
true
}
Err(_) => false,
}
}
/// Every colour in the subtree of `n` with the swatch it links to: solid fills, strokes, text runs
/// and gradient stops (with their link), then mesh points (unlinked), in paint order.
pub fn node_colors(n: &Node, f: &mut dyn FnMut(&Color, Option<&str>)) {
+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");
@@ -29,6 +29,31 @@ const SWITCHES: &[&str] = &["underline", "strikethrough", "standardVerticalRoman
const ADVANCED: &[&str] = &["h_scale", "v_scale", "baseline_shift", "rotation", "language"];
/// The number fields of Advanced Character Formats.
const ADVANCED_NUMBERS: &[&str] = &["h_scale", "v_scale", "baseline_shift", "rotation"];
/// The attributes of Character Color.
const COLOR: &[&str] = &["fill", "stroke", "stroke_width", "overprint_fill", "overprint_stroke"];
/// The fields of Character Color: the fill and stroke paints, their tints in percent, the stroke
/// weight in points and the two overprint checkboxes.
const COLOR_FIELDS: &[&str] = &["fill", "fill_tint", "stroke", "stroke_tint", "stroke_width", "overprint_fill", "overprint_stroke"];
/// The on, off or unset checkboxes of OpenType Features and the feature tag each one sets.
const OT_SWITCHES: &[(&str, &str)] = &[
("ot_liga", "liga"),
("ot_calt", "calt"),
("ot_dlig", "dlig"),
("ot_swsh", "swsh"),
("ot_salt", "salt"),
("ot_titl", "titl"),
("ot_ordn", "ordn"),
("ot_frac", "frac"),
];
/// The feature tags the Figure menu sets.
const FIGURE_TAGS: [&str; 4] = ["tnum", "onum", "pnum", "lnum"];
/// The values of the Figure field, in the order of the OpenType panel's Figure menu.
const FIGURE_VALUES: [&str; 5] = ["default", "tabularLining", "proportionalLining", "proportionalOldstyle", "tabularOldstyle"];
/// The values of the Position field, in the order of the OpenType panel's Position menu.
const POSITION_VALUES: [&str; 5] = ["default", "superscript", "subscript", "numerator", "denominator"];
/// The fields of OpenType Features.
const OT_FIELDS: &[&str] =
&["ot_figure", "ot_position", "ot_liga", "ot_calt", "ot_dlig", "ot_swsh", "ot_salt", "ot_titl", "ot_ordn", "ot_frac", "ot_sets"];
pub(super) const SPEC: DialogSpec = DialogSpec {
heading: |_| tl!("Character Style Options").into(),
@@ -55,6 +80,24 @@ pub(super) const SPEC: DialogSpec = DialogSpec {
"baseline_shift",
"rotation",
"language",
"fill",
"fill_tint",
"stroke",
"stroke_tint",
"stroke_width",
"overprint_fill",
"overprint_stroke",
"ot_figure",
"ot_position",
"ot_liga",
"ot_calt",
"ot_dlig",
"ot_swsh",
"ot_salt",
"ot_titl",
"ot_ordn",
"ot_frac",
"ot_sets",
"section",
]
},
@@ -73,6 +116,7 @@ pub fn open(app: &mut VectorcraftApp, name: &str) -> Result<Value, String> {
fields.insert("__smallCapsSize".into(), json!(doc.setup.small_caps_size));
fields.insert("__superscript".into(), json!(doc.setup.superscript));
fields.insert("__subscript".into(), json!(doc.setup.subscript));
fields.insert("__swatches".into(), swatch_choices(doc));
fields.insert("section".into(), json!("general"));
fields.insert("preview".into(), json!(true));
app.ui.dialog = Some(Dialog { kind: KIND.into(), fields });
@@ -100,6 +144,71 @@ pub(crate) fn fields_from_attrs(attrs: &Map<String, Value>) -> Map<String, Value
fields.insert((*key).into(), attrs.get(*key).filter(|v| !v.is_null()).cloned().unwrap_or_else(|| json!("")));
}
fields.insert("language".into(), language_field(attrs.get("language")));
for key in ["fill", "stroke"] {
let paint = attrs.get(key).cloned().unwrap_or(Value::Null);
fields.insert(format!("{key}_tint"), tint_field(&paint));
fields.insert(key.into(), paint);
}
fields.insert("stroke_width".into(), attrs.get("stroke_width").filter(|v| !v.is_null()).cloned().unwrap_or_else(|| json!("")));
for key in ["overprint_fill", "overprint_stroke"] {
fields.insert(key.into(), attrs.get(key).cloned().unwrap_or(Value::Null));
}
fields.extend(opentype_fields(attrs.get("features")));
fields
}
/// The Tint field for a paint: its tint in percent when it links to a swatch, else empty.
fn tint_field(paint: &Value) -> Value {
if paint.get("swatch").is_some_and(Value::is_string) {
json!(vectorcraft_color::tint_percent(paint.get("tint").and_then(Value::as_f64).unwrap_or(1.0) as f32))
} else {
json!("")
}
}
/// The swatches Character Color offers, as `[name, paint]` pairs in the Swatches panel's order, the
/// built-in Registration swatch first: a global color or a tint of one as a paint linked to it, any
/// other solid color as that color.
pub(crate) fn swatch_choices(doc: &vectorcraft_doc::Document) -> Value {
let registration = vectorcraft_color::swatch::registration();
let builtin = (!doc.swatches_iter().any(|w| w.name == registration.name)).then_some(registration);
let choices = builtin.into_iter().chain(doc.swatches_iter()).filter_map(|w| {
let paint = match (doc.swatch_link(&w.name), &w.paint) {
(Some((base, tint)), _) => doc.tint_paint(&base, tint)?,
(None, vectorcraft_color::Paint::Solid { .. }) => w.paint.clone(),
_ => return None,
};
Some(json!([w.name, paint]))
});
Value::Array(choices.collect())
}
/// A feature tag without its `-` or `+`.
fn bare(tag: &str) -> &str {
tag.trim_start_matches(['-', '+'])
}
/// The OpenType Features fields for a style's `features`: each control is unset unless the list
/// names one of its tags, on or off.
fn opentype_fields(features: Option<&Value>) -> Map<String, Value> {
let tags: Vec<&str> = features.and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).collect();
let names = |set: &[&str]| tags.iter().any(|t| set.contains(&bare(t)));
let f = vectorcraft_text::OtFeatures::from_tags(tags.iter().copied());
let mut fields = Map::new();
for (field, tag) in OT_SWITCHES {
let last = tags.iter().rev().find(|t| bare(t) == *tag);
fields.insert((*field).into(), last.map_or(Value::Null, |t| json!(!t.starts_with('-'))));
}
let figure = names(&FIGURE_TAGS).then(|| FIGURE_VALUES.get(crate::panels::opentype::figure(&f))).flatten();
fields.insert("ot_figure".into(), figure.map_or(Value::Null, |v| json!(v)));
let position =
names(&crate::panels::opentype::POSITION_TAGS).then(|| crate::panels::opentype::position(&f).and_then(|i| POSITION_VALUES.get(i))).flatten();
fields.insert("ot_position".into(), position.map_or(Value::Null, |v| json!(v)));
let sets: Vec<String> = vectorcraft_text::EXTRA_FEATURES[..20]
.iter()
.filter_map(|set| tags.iter().rev().find(|t| bare(t) == *set).map(|t| if t.starts_with('-') { format!("-{set}") } else { set.to_string() }))
.collect();
fields.insert("ot_sets".into(), if sets.is_empty() { Value::Null } else { json!(sets) });
fields
}
@@ -136,6 +245,8 @@ fn optional_number(d: &Dialog, key: &str) -> Result<Option<Value>, String> {
"v_scale" => tl!("Vertical Scale"),
"baseline_shift" => tl!("Baseline Shift"),
"rotation" => tl!("Character Rotation"),
"fill_tint" | "stroke_tint" => tl!("Tint"),
"stroke_width" => tl!("Weight"),
_ => return Err("unknown character style number field".into()),
};
match d.fields.get(key) {
@@ -160,6 +271,15 @@ fn optional_number(d: &Dialog, key: &str) -> Result<Option<Value>, String> {
if key == "baseline_shift" && !(min..=max).contains(&n) {
return Err(format!("{label} must be a number from {min} to {max}"));
}
// Weight from 0 to the document limit, a tint from 0% to 100%.
let (min, max) = match key {
"stroke_width" => (0.0, max),
"fill_tint" | "stroke_tint" => (0.0, 100.0),
_ => (min, max),
};
if ["stroke_width", "fill_tint", "stroke_tint"].contains(&key) && !(min..=max).contains(&n) {
return Err(format!("{label} must be a number from {min} to {max}"));
}
// An angle from -180 to 180 degrees, as text.setFormat stores it.
let n = if key == "rotation" { (n + 180.0).rem_euclid(360.0) - 180.0 } else { n };
Ok(Some(json!(n)))
@@ -249,9 +369,121 @@ pub(crate) fn edited_attrs(d: &Dialog) -> Result<Map<String, Value>, String> {
}
}
advanced_attrs(d, &original, &original_fields, &mut attrs)?;
color_attrs(d, &original, &original_fields, &mut attrs)?;
opentype_attrs(d, &original, &original_fields, &mut attrs);
Ok(attrs)
}
/// Write the Character Color fields of `d` into `attrs`: an unset paint, weight or checkbox
/// leaves its attribute unset, and a field still as it opened keeps the original value, a
/// gradient or pattern paint included.
fn color_attrs(
d: &Dialog,
original: &Map<String, Value>,
original_fields: &Map<String, Value>,
attrs: &mut Map<String, Value>,
) -> Result<(), String> {
let reset = d.bool("__resetColor");
for key in COLOR {
attrs.remove(*key);
}
let unchanged = |key: &str| !reset && d.fields.get(key) == original_fields.get(key);
for key in ["fill", "stroke"] {
let tint_key = format!("{key}_tint");
if unchanged(key) && unchanged(&tint_key) {
if let Some(value) = original.get(key) {
attrs.insert(key.into(), value.clone());
}
continue;
}
let Some(mut paint) = d.fields.get(key).filter(|v| v.is_object()).cloned() else { continue };
if paint.get("swatch").is_some_and(Value::is_string)
&& let Some(Value::Number(tint)) = optional_number(d, &tint_key)?
&& let Some(tint) = tint.as_f64()
{
paint["tint"] = json!(tint / 100.0);
}
attrs.insert(key.into(), paint);
}
if unchanged("stroke_width") {
if let Some(value) = original.get("stroke_width") {
attrs.insert("stroke_width".into(), value.clone());
}
} else if let Some(v) = optional_number(d, "stroke_width")? {
attrs.insert("stroke_width".into(), v);
}
for key in ["overprint_fill", "overprint_stroke"] {
let value = if unchanged(key) { original.get(key).cloned() } else { d.fields.get(key).filter(|v| v.is_boolean()).cloned() };
if let Some(value) = value {
attrs.insert(key.into(), value);
}
}
Ok(())
}
/// The tags a Figure field value sets: its figure features on and the others off.
fn figure_tags(value: &str) -> &'static [&'static str] {
match value {
"tabularLining" => &["tnum", "lnum", "-onum", "-pnum"],
"proportionalLining" => &["pnum", "lnum", "-tnum", "-onum"],
"proportionalOldstyle" => &["onum", "pnum", "-tnum", "-lnum"],
"tabularOldstyle" => &["onum", "tnum", "-pnum", "-lnum"],
"default" => &["-tnum", "-onum", "-pnum", "-lnum"],
_ => &[],
}
}
/// Write the OpenType Features fields of `d` into `attrs`. When every field is still as it
/// opened, the original `features` list stays as it is. Otherwise each changed control replaces
/// the tags it sets in the original list, which keeps every other tag, and an unset control
/// removes them; a list left empty leaves `features` unset.
fn opentype_attrs(d: &Dialog, original: &Map<String, Value>, original_fields: &Map<String, Value>, attrs: &mut Map<String, Value>) {
let reset = d.bool("__resetOpenType");
attrs.remove("features");
let unchanged = |key: &str| !reset && d.fields.get(key) == original_fields.get(key);
if OT_FIELDS.iter().all(|key| unchanged(key)) {
if let Some(value) = original.get("features") {
attrs.insert("features".into(), value.clone());
}
return;
}
let mut tags: Vec<String> = if reset {
vec![]
} else {
original.get("features").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).map(str::to_string).collect()
};
for (field, tag) in OT_SWITCHES {
if !unchanged(field) {
tags.retain(|t| bare(t) != *tag);
if let Some(on) = d.fields.get(*field).and_then(Value::as_bool) {
tags.push(if on { (*tag).to_string() } else { format!("-{tag}") });
}
}
}
if !unchanged("ot_figure") {
tags.retain(|t| !FIGURE_TAGS.contains(&bare(t)));
tags.extend(figure_tags(&d.str("ot_figure")).iter().map(|t| t.to_string()));
}
if !unchanged("ot_position") {
let position_tags = crate::panels::opentype::POSITION_TAGS;
tags.retain(|t| !position_tags.contains(&bare(t)));
if let Some(i) = POSITION_VALUES.iter().position(|v| *v == d.str("ot_position")) {
for (k, tag) in position_tags.iter().enumerate() {
tags.push(if k + 1 == i { (*tag).to_string() } else { format!("-{tag}") });
}
}
}
if !unchanged("ot_sets") {
let sets = &vectorcraft_text::EXTRA_FEATURES[..20];
tags.retain(|t| !sets.contains(&bare(t)));
let listed = d.fields.get("ot_sets").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str);
tags.extend(listed.filter(|t| sets.contains(&bare(t))).map(str::to_string));
}
if !tags.is_empty() {
attrs.insert("features".into(), json!(tags));
}
}
/// Write the Advanced Character Formats fields of `d` into `attrs`: a filled field sets its
/// attribute, an empty one leaves it unset, and a field still as it opened keeps the original
/// value, an explicit default or a language tag not in the list included.
@@ -328,9 +560,27 @@ pub(crate) fn reset_advanced(d: &mut Dialog) {
d.fields.insert("language".into(), Value::Null);
}
/// Whether the section `d` shows has a Reset Panel: Basic or Advanced Character Formats.
/// Reset Panel in Character Color: its paints, tints, weight and overprint checkboxes become unset.
pub(crate) fn reset_color(d: &mut Dialog) {
d.fields.insert("__resetColor".into(), json!(true));
for key in COLOR_FIELDS {
let unset = if ["fill_tint", "stroke_tint", "stroke_width"].contains(key) { json!("") } else { Value::Null };
d.fields.insert((*key).into(), unset);
}
}
/// Reset Panel in OpenType Features: every control becomes unset, and the style's `features`
/// with it.
pub(crate) fn reset_opentype(d: &mut Dialog) {
d.fields.insert("__resetOpenType".into(), json!(true));
for key in OT_FIELDS {
d.fields.insert((*key).into(), Value::Null);
}
}
/// Whether the section `d` shows has a Reset Panel: every section but General.
pub(crate) fn can_reset(d: &Dialog) -> bool {
["basic", "advanced"].contains(&d.str("section").as_str())
["basic", "advanced", "color", "opentype"].contains(&d.str("section").as_str())
}
/// Reset Panel: clear the settings of the section `d` shows.
@@ -338,6 +588,8 @@ pub(crate) fn reset_section(d: &mut Dialog) {
match d.str("section").as_str() {
"basic" => reset_basic(d),
"advanced" => reset_advanced(d),
"color" => reset_color(d),
"opentype" => reset_opentype(d),
_ => {}
}
}
@@ -355,7 +607,13 @@ pub fn reset_panel(app: &mut VectorcraftApp) -> Result<Value, String> {
/// The section list of Character Style Options and New Character Style.
pub(crate) fn section_list(ui: &mut egui::Ui, d: &mut Dialog) {
ui.vertical(|ui| {
for (id, label) in [("general", tl!("General")), ("basic", tl!("Basic Character Formats")), ("advanced", tl!("Advanced Character Formats"))] {
for (id, label) in [
("general", tl!("General")),
("basic", tl!("Basic Character Formats")),
("advanced", tl!("Advanced Character Formats")),
("color", tl!("Character Color")),
("opentype", tl!("OpenType Features")),
] {
if ui.selectable_label(d.str("section") == id, label).clicked() {
d.fields.insert("section".into(), json!(id));
}
@@ -363,7 +621,7 @@ pub(crate) fn section_list(ui: &mut egui::Ui, d: &mut Dialog) {
});
}
/// The Reset Panel button, enabled on Basic and Advanced Character Formats.
/// The Reset Panel button, enabled on every section but General.
pub(crate) fn reset_button(ui: &mut egui::Ui, d: &mut Dialog) {
ui.add_enabled_ui(can_reset(d), |ui| {
if ui.button(tl!("Reset Panel")).clicked() {
@@ -471,6 +729,151 @@ pub(crate) fn advanced_body(ui: &mut egui::Ui, d: &mut Dialog) {
});
}
/// An on, off or unset checkbox in its own grid row: a click on unset turns it on, on turns it
/// off, and off makes it unset.
fn switch(ui: &mut egui::Ui, d: &mut Dialog, key: &str, caption: &str, enabled: bool) {
ui.label("");
let value = d.fields.get(key).and_then(Value::as_bool);
if crate::widgets::check3(ui, caption, value, enabled) {
let next = match value {
None => json!(true),
Some(true) => json!(false),
Some(false) => Value::Null,
};
d.fields.insert(key.into(), next);
}
ui.end_row();
}
/// The Fill or Stroke menu (`key`): unset, None, or a swatch of the document. A paint no swatch
/// gives (a gradient, a pattern, a color from a file) shows as its description and stays as it
/// is until another entry is chosen.
fn paint_choice(ui: &mut egui::Ui, d: &mut Dialog, key: &str) {
let swatches: Vec<(String, Value)> = d
.fields
.get("__swatches")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|pair| Some((pair.get(0)?.as_str()?.to_string(), pair.get(1)?.clone())))
.collect();
let current = d.fields.get(key).cloned().unwrap_or(Value::Null);
let none = tl!("None");
let shown = if current.is_null() {
String::new()
} else if current.get("type").and_then(Value::as_str) == Some("none") {
none.to_string()
} else if let Some((name, _)) = swatches.iter().find(|(_, paint)| *paint == current) {
name.clone()
} else if let Some((name, _)) = swatches.iter().find(|(_, paint)| paint.get("swatch").is_some_and(|s| Some(s) == current.get("swatch"))) {
name.clone()
} else {
serde_json::from_value::<vectorcraft_color::Paint>(current).map(|p| p.label()).unwrap_or_default()
};
let options: Vec<&str> = [" ", none].into_iter().chain(swatches.iter().map(|(name, _)| name.as_str())).collect();
if let Some(i) = crate::widgets::dropdown_names(ui, (KIND, key), &shown, &options, 190.0) {
let paint = match i {
0 => Value::Null,
1 => json!({"type": "none"}),
_ => swatches.get(i - 2).map_or(Value::Null, |(_, paint)| paint.clone()),
};
d.fields.insert(format!("{key}_tint"), tint_field(&paint));
d.fields.insert(key.into(), paint);
}
}
/// Character Color: the Fill and Stroke paints, the Tint of a paint linked to a global swatch,
/// the stroke Weight and Overprint Fill and Overprint Stroke, each unset or set.
pub(crate) fn color_body(ui: &mut egui::Ui, d: &mut Dialog) {
egui::Grid::new("character-style-color").num_columns(2).spacing([18.0, 8.0]).show(ui, |ui| {
for (key, caption) in [("fill", tl!("Fill")), ("stroke", tl!("Stroke"))] {
ui.label(caption);
paint_choice(ui, d, key);
ui.end_row();
let tint_key = format!("{key}_tint");
ui.label(tl!("Tint"));
if d.fields.get(&tint_key).is_none_or(Value::is_null) {
d.fields.insert(tint_key.clone(), json!(""));
}
let linked = d.fields.get(key).and_then(|p| p.get("swatch")).is_some_and(Value::is_string);
ui.add_enabled_ui(linked, |ui| {
ui.horizontal(|ui| {
form::text(ui, d, &tint_key, 160.0);
ui.label("%");
});
});
ui.end_row();
}
ui.label(tl!("Weight"));
if d.fields.get("stroke_width").is_none_or(Value::is_null) {
d.fields.insert("stroke_width".into(), json!(""));
}
let stroked = d.fields.get("stroke").and_then(|p| p.get("type")).and_then(Value::as_str) != Some("none");
ui.add_enabled_ui(stroked, |ui| {
ui.horizontal(|ui| {
form::text(ui, d, "stroke_width", 160.0);
ui.label("pt");
});
});
ui.end_row();
switch(ui, d, "overprint_fill", tl!("Overprint Fill"), true);
switch(ui, d, "overprint_stroke", tl!("Overprint Stroke"), true);
});
}
/// OpenType Features: the Figure and Position menus, the feature checkboxes and the stylistic
/// sets, each unset or set.
pub(crate) fn opentype_body(ui: &mut egui::Ui, d: &mut Dialog) {
let figures: Vec<(&str, &str)> = FIGURE_VALUES.into_iter().zip(crate::panels::opentype::FIGURES).collect();
let positions: Vec<(&str, &str)> = POSITION_VALUES.into_iter().zip(crate::panels::opentype::POSITIONS).collect();
egui::Grid::new("character-style-opentype").num_columns(2).spacing([18.0, 8.0]).show(ui, |ui| {
ui.label(tl!("Figure:"));
choice(ui, d, "ot_figure", &figures);
ui.end_row();
ui.label(tl!("Position:"));
choice(ui, d, "ot_position", &positions);
ui.end_row();
for (key, caption) in [
("ot_liga", tl!("Standard Ligatures")),
("ot_calt", tl!("Contextual Alternates")),
("ot_dlig", tl!("Discretionary Ligatures")),
("ot_swsh", tl!("Swash")),
("ot_salt", tl!("Stylistic Alternates")),
("ot_titl", tl!("Titling Alternates")),
("ot_ordn", tl!("Ordinals")),
("ot_frac", tl!("Fractions")),
] {
switch(ui, d, key, caption, true);
}
});
ui.add_space(6.0);
ui.label(tl!("Stylistic Sets"));
// Each set is on (`ssNN`), off (`-ssNN`) or unset (not listed), as the other checkboxes are.
let mut listed: Vec<String> =
d.fields.get("ot_sets").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).map(str::to_string).collect();
let mut clicked = None;
egui::Grid::new("character-style-sets").num_columns(5).spacing([12.0, 4.0]).show(ui, |ui| {
for (i, set) in vectorcraft_text::EXTRA_FEATURES[..20].iter().enumerate() {
let value = listed.iter().find(|t| bare(t) == *set).map(|t| !t.starts_with('-'));
if crate::widgets::check3(ui, set, value, true) {
clicked = Some((*set, value));
}
if i % 5 == 4 {
ui.end_row();
}
}
});
if let Some((set, value)) = clicked {
listed.retain(|t| bare(t) != set);
match value {
None => listed.push(set.to_string()),
Some(true) => listed.push(format!("-{set}")),
Some(false) => {}
}
d.fields.insert("ot_sets".into(), if listed.is_empty() { Value::Null } else { json!(listed) });
}
}
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
ui.horizontal(|ui| {
section_list(ui, d);
@@ -480,6 +883,10 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
basic_body(ui, d);
} else if d.str("section") == "advanced" {
advanced_body(ui, d);
} else if d.str("section") == "color" {
color_body(ui, d);
} else if d.str("section") == "opentype" {
opentype_body(ui, d);
} else {
ui.horizontal(|ui| {
ui.label(tl!("Style Name:"));
@@ -841,6 +1248,221 @@ mod tests {
assert!(!def.attrs.contains_key("v_scale"));
}
/// The negated tags of the twenty stylistic sets: an explicit empty Stylistic Sets choice.
fn no_sets() -> Vec<String> {
vectorcraft_text::EXTRA_FEATURES[..20].iter().map(|s| format!("-{s}")).collect()
}
#[test]
fn color_and_opentype_fields_keep_unset_explicit_and_untouched_values() {
let d = dialog_for(json!({}));
for key in ["fill", "stroke", "overprint_fill", "overprint_stroke", "ot_figure", "ot_position", "ot_liga", "ot_frac", "ot_sets"] {
assert!(d.fields[key].is_null(), "{key}");
}
assert_eq!((d.str("stroke_width"), d.str("fill_tint")), (String::new(), String::new()));
assert_eq!(edited_attrs(&d).unwrap(), Map::new());
let mut explicit =
vec!["-tnum", "-onum", "-pnum", "-lnum", "-sups", "-subs", "-numr", "-dnom", "-liga"].into_iter().map(String::from).collect::<Vec<_>>();
explicit.extend(no_sets());
for original in [
json!({"fill": {"type": "none"}, "stroke_width": 0.0, "overprint_fill": false, "features": explicit}),
json!({"stroke": {"type": "pattern", "pattern": "Dots"}, "fill": {"type": "solid", "color": {"model": "gray", "k": 0.5}}}),
json!({"features": ["zzzz", "dlig", "ss03"], "size": 9.0}),
] {
let d = dialog_for(original.clone());
assert_eq!(edited_attrs(&d).unwrap(), attrs(original.clone()), "{original}");
}
let d = dialog_for(json!({"fill": {"type": "none"}, "overprint_fill": false, "features": explicit}));
assert_eq!(d.fields["fill"], json!({"type": "none"}));
assert_eq!(d.fields["overprint_fill"], json!(false));
assert_eq!((d.str("ot_figure"), d.str("ot_position")), ("default".to_string(), "default".to_string()));
assert_eq!(
(d.fields["ot_liga"].clone(), d.fields["ot_dlig"].clone(), d.fields["ot_sets"].clone()),
(json!(false), Value::Null, json!(no_sets()))
);
// One changed control keeps every other tag, unknown ones included.
let mut d = dialog_for(json!({"features": ["zzzz", "dlig", "ss03"], "stroke": {"type": "pattern", "pattern": "Dots"}}));
assert_eq!((d.fields["ot_dlig"].clone(), d.fields["ot_sets"].clone()), (json!(true), json!(["ss03"])));
d.fields.insert("ot_liga".into(), json!(false));
d.fields.insert("overprint_stroke".into(), json!(true));
assert_eq!(
edited_attrs(&d).unwrap(),
attrs(json!({"features": ["zzzz", "dlig", "ss03", "-liga"], "stroke": {"type": "pattern", "pattern": "Dots"}, "overprint_stroke": true}))
);
d.fields.insert("ot_sets".into(), json!(["ss05", "ss03"]));
d.fields.insert("ot_figure".into(), json!("proportionalOldstyle"));
d.fields.insert("ot_position".into(), json!("numerator"));
d.fields.insert("ot_dlig".into(), Value::Null);
let features = edited_attrs(&d).unwrap()["features"].clone();
// Sets the list leaves out stay unset, so text that uses the style keeps its own.
assert_eq!(features, json!(["zzzz", "-liga", "onum", "pnum", "-tnum", "-lnum", "-sups", "-subs", "numr", "-dnom", "ss05", "ss03"]));
let reopened = dialog_for(json!({"features": features}));
assert_eq!(
["ot_figure", "ot_position", "ot_liga", "ot_dlig"].map(|k| reopened.fields[k].clone()),
[json!("proportionalOldstyle"), json!("numerator"), json!(false), Value::Null]
);
assert_eq!(reopened.fields["ot_sets"], json!(["ss03", "ss05"]));
// Every control unset leaves the attribute unset.
let mut d = dialog_for(json!({"features": ["dlig"]}));
d.fields.insert("ot_dlig".into(), Value::Null);
assert_eq!(edited_attrs(&d).unwrap(), Map::new());
}
#[test]
fn color_tint_and_weight_outside_their_limits_are_errors() {
let linked = json!({"type": "solid", "color": {"model": "gray", "k": 0.5}, "swatch": "Ink"});
for (key, value, message) in [
("fill_tint", "150", "Tint must be a number from 0 to 100"),
("stroke_tint", "-1", "Tint must be a number from 0 to 100"),
("stroke_width", "-1", "Weight must be a number from 0 to 4000000"),
("stroke_width", "thick", "Weight must be a number"),
] {
let mut d = dialog_for(json!({"fill": linked, "stroke": linked}));
d.fields.insert(key.into(), json!(value));
assert_eq!(edited_attrs(&d).unwrap_err(), message, "{key} {value}");
}
let mut d = dialog_for(json!({"fill": linked}));
assert_eq!(d.fields["fill_tint"], json!(100.0));
d.fields.insert("fill_tint".into(), json!("40"));
d.fields.insert("stroke_width".into(), json!("1.5"));
assert_eq!(
edited_attrs(&d).unwrap(),
attrs(json!({"fill": {"type": "solid", "color": {"model": "gray", "k": 0.5}, "swatch": "Ink", "tint": 0.4}, "stroke_width": 1.5}))
);
}
#[test]
fn reset_panel_in_color_and_opentype_clears_only_that_section() {
let original = json!({"size": 12.0, "h_scale": 90.0, "fill": {"type": "none"}, "stroke_width": 2.0, "overprint_stroke": true, "features": ["dlig", "zzzz"]});
let mut d = dialog_for(original.clone());
d.fields.insert("section".into(), json!("color"));
assert!(can_reset(&d));
reset_section(&mut d);
assert_eq!(edited_attrs(&d).unwrap(), attrs(json!({"size": 12.0, "h_scale": 90.0, "features": ["dlig", "zzzz"]})));
let mut d = dialog_for(original);
d.fields.insert("section".into(), json!("opentype"));
reset_section(&mut d);
assert!(d.fields["ot_dlig"].is_null());
assert_eq!(
edited_attrs(&d).unwrap(),
attrs(json!({"size": 12.0, "h_scale": 90.0, "fill": {"type": "none"}, "stroke_width": 2.0, "overprint_stroke": true}))
);
d.fields.insert("ot_frac".into(), json!(true));
assert_eq!(edited_attrs(&d).unwrap()["features"], json!(["frac"]));
}
#[test]
fn color_and_opentype_sections_show_their_controls() {
let mut d = dialog_for(json!({}));
let mut app = app();
let shown = crate::tests_labels::painted_text(&mut app, |_, ui| section_list(ui, &mut d));
for label in ["Character Color", "OpenType Features"] {
assert!(shown.contains(label), "{label}: {shown}");
}
let shown = crate::tests_labels::painted_text(&mut app, |_, ui| color_body(ui, &mut d));
for label in ["Fill", "Stroke", "Tint", "Weight", "Overprint Fill", "Overprint Stroke"] {
assert!(shown.contains(label), "{label}: {shown}");
}
let shown = crate::tests_labels::painted_text(&mut app, |_, ui| opentype_body(ui, &mut d));
for label in [
"Figure:",
"Position:",
"Standard Ligatures",
"Contextual Alternates",
"Discretionary Ligatures",
"Swash",
"Stylistic Alternates",
"Titling Alternates",
"Ordinals",
"Fractions",
"Stylistic Sets",
"ss01",
"ss20",
] {
assert!(shown.contains(label), "{label}: {shown}");
}
assert!(!shown.contains("Small Caps"), "{shown}");
}
#[test]
fn color_and_features_preview_cancel_commit_and_new_style() {
let mut app = app();
app.run("swatch.new", json!({"name": "Brand", "color": "#2050a0", "global": true})).unwrap();
app.run("charStyle.new", json!({"name": "Body", "attrs": {"size": 24.0}})).unwrap();
let id = app.run("text.create", json!({"x": 10, "y": 40, "text": "Text"})).unwrap()["id"].as_u64().unwrap();
app.run("charStyle.apply", json!({"name": "Body", "id": id})).unwrap();
let before = app.session.doc().unwrap().doc.clone();
let undo = app.session.doc().unwrap().history.undo.len();
let brand = json!(before.tint_paint("Brand", 1.0).unwrap());
open(&mut app, "Body").unwrap();
let choices = app.ui.dialog.as_ref().unwrap().fields["__swatches"].as_array().unwrap().clone();
assert!(choices.contains(&json!(["Brand", brand])));
let registration = vectorcraft_color::swatch::REGISTRATION;
assert_eq!(choices.first(), Some(&json!([registration, before.tint_paint(registration, 1.0).unwrap()])));
let set = |app: &mut VectorcraftApp| {
let d = app.ui.dialog.as_mut().unwrap();
d.fields.insert("section".into(), json!("color"));
d.fields.insert("fill".into(), brand.clone());
d.fields.insert("stroke".into(), brand.clone());
d.fields.insert("stroke_tint".into(), json!("40"));
d.fields.insert("stroke_width".into(), json!("2"));
d.fields.insert("overprint_fill".into(), json!(true));
d.fields.insert("ot_dlig".into(), json!(true));
d.fields.insert("ot_figure".into(), json!("tabularLining"));
};
set(&mut app);
assert!(crate::menus::enabled(&app, "ui.charStyleOptions.resetPanel"));
frame(&mut app);
assert_eq!(app.session.doc().unwrap().doc.char_styles[0].attrs["fill"], brand);
super::super::cancel(&mut app);
assert_eq!(app.session.doc().unwrap().doc, before);
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo);
open(&mut app, "Body").unwrap();
set(&mut app);
super::super::confirm(&mut app).unwrap();
let state = app.session.doc().unwrap();
assert_eq!(state.history.undo.len(), undo + 1);
let tint40 = state.doc.tint_paint("Brand", 0.4).unwrap();
let def = state.doc.char_styles[0].attrs.clone();
assert_eq!(def["stroke"], json!(tint40));
assert_eq!(def["features"], json!(["dlig", "tnum", "lnum", "-onum", "-pnum"]));
let vectorcraft_doc::NodeKind::Text(t) = &state.doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind else { panic!("text") };
for run in &t.runs {
assert_eq!(
(run.style.fill.clone(), run.style.stroke.clone(), run.style.stroke_width),
(state.doc.tint_paint("Brand", 1.0).unwrap(), tint40.clone(), 2.0)
);
assert_eq!(run.style.features, ["dlig", "tnum", "lnum", "-onum", "-pnum"]);
}
open(&mut app, "Body").unwrap();
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!((d.fields["stroke_tint"].clone(), d.str("ot_figure")), (json!(40.0), "tabularLining".to_string()));
super::super::cancel(&mut app);
app.run("edit.undo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc, before);
app.run("select.set", json!({"ids": []})).unwrap();
app.run("ui.newCharStyle", json!({})).unwrap();
{
let d = app.ui.dialog.as_mut().unwrap();
assert!(d.fields["fill"].is_null() && d.fields["ot_sets"].is_null());
d.fields.insert("name".into(), json!("Ink"));
d.fields.insert("section".into(), json!("opentype"));
d.fields.insert("ot_sets".into(), json!(["-ss02", "ss04"]));
d.fields.insert("ot_frac".into(), json!(false));
}
assert!(crate::menus::enabled(&app, "ui.charStyleOptions.resetPanel"));
super::super::confirm(&mut app).unwrap();
let doc = &app.session.doc().unwrap().doc;
let def = doc.char_styles.iter().find(|def| def.name == "Ink").unwrap();
assert_eq!(def.attrs["features"], json!(["-frac", "-ss02", "ss04"]));
let vectorcraft_doc::NodeKind::Text(t) = &doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind else { panic!("text") };
assert!(t.runs.iter().all(|r| r.style.style_name.as_deref() == Some("Body")));
}
#[test]
fn invalid_numbers_name_the_visible_field() {
let mut d = Dialog::new(KIND, json!({}));
@@ -42,6 +42,7 @@ pub fn open(app: &mut VectorcraftApp, para: bool, target: Value) -> Result<Value
fields.insert("__smallCapsSize".into(), json!(d.setup.small_caps_size));
fields.insert("__superscript".into(), json!(d.setup.superscript));
fields.insert("__subscript".into(), json!(d.setup.subscript));
fields.insert("__swatches".into(), super::char_style_options::swatch_choices(d));
let attrs = target.get("attrs").and_then(Value::as_object).cloned().unwrap_or_default();
fields.extend(super::char_style_options::fields_from_attrs(&attrs));
fields.insert("__attrs".into(), Value::Object(attrs));
@@ -59,6 +60,10 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
super::char_style_options::basic_body(ui, d);
} else if d.str("section") == "advanced" {
super::char_style_options::advanced_body(ui, d);
} else if d.str("section") == "color" {
super::char_style_options::color_body(ui, d);
} else if d.str("section") == "opentype" {
super::char_style_options::opentype_body(ui, d);
} else {
ui.vertical(|ui| {
grid(ui, |ui| {
+5
View File
@@ -5986,6 +5986,11 @@ anchor Points: Punts:
Horizontal Scale Escala horitzontal
Vertical Scale Escala vertical
@msg {label} must be a number from {min} to {max} {label} ha de ser un nombre de {min} a {max}
Character Color Color del caràcter
OpenType Features Funcions OpenType
Stylistic Sets Jocs estilístics
@msg features lists at most {max} tags features admet com a màxim {max} etiquetes
@msg unknown OpenType feature `{t}` funció OpenType desconeguda `{t}`
@msg {label} must be a number above 0 and at most {max} {label} ha de ser un nombre superior a 0 i com a màxim {max}
@msg no character style `{name}` No s'ha trobat l'estil de caràcter `{name}`
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -6002,6 +6002,11 @@ anchor Points: Body:
Horizontal Scale Vodorovná změna velikosti
Vertical Scale Svislá změna velikosti
@msg {label} must be a number from {min} to {max} {label} musí být číslo od {min} do {max}
Character Color Barva znaků
OpenType Features Funkce OpenType
Stylistic Sets Stylistické sady
@msg features lists at most {max} tags features obsahuje nejvýše {max} značek
@msg unknown OpenType feature `{t}` neznámá funkce OpenType `{t}`
@msg {label} must be a number above 0 and at most {max} {label} musí být číslo větší než 0 a nejvýše {max}
@msg no character style `{name}` styl znaků `{name}` neexistuje
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -5587,6 +5587,11 @@ anchor Points: Punkte:
Horizontal Scale Horizontale Skalierung
Vertical Scale Vertikale Skalierung
@msg {label} must be a number from {min} to {max} {label} muss eine Zahl von {min} bis {max} sein
Character Color Zeichenfarbe
OpenType Features OpenType-Funktionen
Stylistic Sets Stilsätze
@msg features lists at most {max} tags features enthält höchstens {max} Tags
@msg unknown OpenType feature `{t}` unbekannte OpenType-Funktion `{t}`
@msg {label} must be a number above 0 and at most {max} {label} muss eine Zahl über 0 und höchstens {max} sein
@msg no character style `{name}` Zeichenformat `{name}` nicht gefunden
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -5953,6 +5953,11 @@ anchor Points: Σημεία:
Horizontal Scale Οριζόντια κλίμακα
Vertical Scale Κατακόρυφη κλίμακα
@msg {label} must be a number from {min} to {max} Το {label} πρέπει να είναι αριθμός από {min} έως {max}
Character Color Χρώμα χαρακτήρων
OpenType Features Χαρακτηριστικά OpenType
Stylistic Sets Στιλιστικά σύνολα
@msg features lists at most {max} tags Το features περιέχει έως {max} ετικέτες
@msg unknown OpenType feature `{t}` άγνωστο χαρακτηριστικό OpenType `{t}`
@msg {label} must be a number above 0 and at most {max} Το {label} πρέπει να είναι αριθμός μεγαλύτερος από 0 και έως {max}
@msg no character style `{name}` Δεν βρέθηκε το στυλ χαρακτήρων `{name}`
Can't render this file because it contains an unexpected character in line 163 and column 5.
+5
View File
@@ -5944,6 +5944,11 @@ anchor Points: Puntos:
Horizontal Scale Escala horizontal
Vertical Scale Escala vertical
@msg {label} must be a number from {min} to {max} {label} debe ser un número de {min} a {max}
Character Color Color de los caracteres
OpenType Features Funciones OpenType
Stylistic Sets Conjuntos estilísticos
@msg features lists at most {max} tags features admite como máximo {max} etiquetas
@msg unknown OpenType feature `{t}` función OpenType desconocida `{t}`
@msg {label} must be a number above 0 and at most {max} {label} debe ser un número mayor que 0 y como máximo {max}
@msg no character style `{name}` No existe el estilo de carácter `{name}`
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -6032,6 +6032,11 @@ anchor Points: Pisteet:
Horizontal Scale Vaakaskaalaus
Vertical Scale Pystyskaalaus
@msg {label} must be a number from {min} to {max} {label} on oltava luku {min} ja {max} väliltä
Character Color Merkin väri
OpenType Features OpenType-ominaisuudet
Stylistic Sets Tyylisarjat
@msg features lists at most {max} tags features sisältää enintään {max} tunnistetta
@msg unknown OpenType feature `{t}` tuntematon OpenType-ominaisuus `{t}`
@msg {label} must be a number above 0 and at most {max} {label} on oltava luku, joka on yli 0 ja enintään {max}
@msg no character style `{name}` Merkkityyliä `{name}` ei löytynyt
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -5997,6 +5997,11 @@ anchor Points: Points :
Horizontal Scale Mise à l’échelle horizontale
Vertical Scale Échelle verticale
@msg {label} must be a number from {min} to {max} {label} doit être un nombre compris entre {min} et {max}
Character Color Couleur des caractères
OpenType Features Fonctions OpenType
Stylistic Sets Jeux stylistiques
@msg features lists at most {max} tags features contient au plus {max} balises
@msg unknown OpenType feature `{t}` fonction OpenType inconnue `{t}`
@msg {label} must be a number above 0 and at most {max} {label} doit être un nombre supérieur à 0 et au plus égal à {max}
@msg no character style `{name}` Style de caractère `{name}` introuvable
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -5986,6 +5986,11 @@ anchor Points: Pontok:
Horizontal Scale Vízszintes méretezés
Vertical Scale Függőleges méretezés
@msg {label} must be a number from {min} to {max} {label} {min} és {max} közötti szám kell legyen
Character Color Karakterszín
OpenType Features OpenType-funkciók
Stylistic Sets Stíluskészletek
@msg features lists at most {max} tags a features legfeljebb {max} címkét tartalmazhat
@msg unknown OpenType feature `{t}` ismeretlen OpenType-funkció: `{t}`
@msg {label} must be a number above 0 and at most {max} {label} 0-nál nagyobb és legfeljebb {max} értékű szám kell legyen
@msg no character style `{name}` A(z) `{name}` karakterstílus nem található
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -5621,6 +5621,11 @@ anchor Points: Punti:
Horizontal Scale Scala orizzontale
Vertical Scale Scala verticale
@msg {label} must be a number from {min} to {max} {label} deve essere un numero da {min} a {max}
Character Color Colore carattere
OpenType Features Funzioni OpenType
Stylistic Sets Set stilistici
@msg features lists at most {max} tags features contiene al massimo {max} tag
@msg unknown OpenType feature `{t}` funzione OpenType sconosciuta `{t}`
@msg {label} must be a number above 0 and at most {max} {label} deve essere un numero maggiore di 0 e al massimo {max}
@msg no character style `{name}` Stile di carattere `{name}` non trovato
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -5686,6 +5686,11 @@ anchor Points: ポイント:
Horizontal Scale 水平比率
Vertical Scale 垂直比率
@msg {label} must be a number from {min} to {max} {label} には {min} から {max} までの数値を指定してください
Character Color 文字カラー
OpenType Features OpenType 機能
Stylistic Sets スタイルセット
@msg features lists at most {max} tags features に指定できるタグは {max} 個までです
@msg unknown OpenType feature `{t}` 不明な OpenType 機能 `{t}`
@msg {label} must be a number above 0 and at most {max} {label} には 0 より大きく {max} 以下の数値を指定してください
@msg no character style `{name}` 文字スタイル `{name}` が見つかりません
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -5990,6 +5990,11 @@ anchor Points: წერტილები:
Horizontal Scale ჰორიზონტალური მასშტაბი
Vertical Scale ვერტიკალური მასშტაბი
@msg {label} must be a number from {min} to {max} {label} უნდა იყოს რიცხვი {min}-დან {max}-მდე
Character Color სიმბოლოს ფერი
OpenType Features OpenType შესაძლებლობები
Stylistic Sets სტილისტური ნაკრებები
@msg features lists at most {max} tags features შეიძლება შეიცავდეს არაუმეტეს {max} ტეგს
@msg unknown OpenType feature `{t}` უცნობი OpenType შესაძლებლობა `{t}`
@msg {label} must be a number above 0 and at most {max} {label} უნდა იყოს 0-ზე მეტი და არაუმეტეს {max}-ისა რიცხვი
@msg no character style `{name}` სიმბოლოს სტილი `{name}` ვერ მოიძებნა
Can't render this file because it is too large.
+5
View File
@@ -5582,6 +5582,11 @@ anchor Points: 포인트:
Horizontal Scale 가로 비율
Vertical Scale 세로 비율
@msg {label} must be a number from {min} to {max} {label}은(는) {min}에서 {max} 사이의 숫자여야 합니다
Character Color 문자 색상
OpenType Features OpenType 기능
Stylistic Sets 스타일 세트
@msg features lists at most {max} tags features에는 태그를 최대 {max}개까지 지정할 수 있습니다
@msg unknown OpenType feature `{t}` 알 수 없는 OpenType 기능 `{t}`
@msg {label} must be a number above 0 and at most {max} {label}은(는) 0 초과 {max} 이하의 숫자여야 합니다
@msg no character style `{name}` 문자 스타일 `{name}`을 찾을 수 없습니다
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -5856,6 +5856,11 @@ anchor Points: Punkty:
Horizontal Scale Skala pozioma
Vertical Scale Skala pionowa
@msg {label} must be a number from {min} to {max} {label} musi być liczbą od {min} do {max}
Character Color Kolor znaków
OpenType Features Funkcje OpenType
Stylistic Sets Zestawy stylistyczne
@msg features lists at most {max} tags features może zawierać najwyżej {max} znaczników
@msg unknown OpenType feature `{t}` nieznana funkcja OpenType `{t}`
@msg {label} must be a number above 0 and at most {max} {label} musi być liczbą większą od 0 i nie większą niż {max}
@msg no character style `{name}` Nie znaleziono stylu znakowego `{name}`
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -5918,6 +5918,11 @@ anchor Points: Pontos:
Horizontal Scale Escala Horizontal
Vertical Scale Escala Vertical
@msg {label} must be a number from {min} to {max} {label} deve ser um número de {min} a {max}
Character Color Cor do Caractere
OpenType Features Recursos OpenType
Stylistic Sets Conjuntos Estilísticos
@msg features lists at most {max} tags features aceita no máximo {max} marcas
@msg unknown OpenType feature `{t}` recurso OpenType desconhecido `{t}`
@msg {label} must be a number above 0 and at most {max} {label} deve ser um número maior que 0 e no máximo {max}
@msg no character style `{name}` Estilo de caractere `{name}` não encontrado
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5
View File
@@ -5979,6 +5979,11 @@ anchor Points: Точки:
Horizontal Scale Масштаб по горизонтали
Vertical Scale Масштаб по вертикали
@msg {label} must be a number from {min} to {max} {label} должно быть числом от {min} до {max}
Character Color Цвет символов
OpenType Features Функции OpenType
Stylistic Sets Стилистические наборы
@msg features lists at most {max} tags features содержит не более {max} тегов
@msg unknown OpenType feature `{t}` неизвестная функция OpenType `{t}`
@msg {label} must be a number above 0 and at most {max} {label} должно быть числом больше 0 и не более {max}
@msg no character style `{name}` Стиль символов `{name}` не найден
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -6054,6 +6054,11 @@ shape Open Öppen
Horizontal Scale Horisontell skala
Vertical Scale Vertikal skala
@msg {label} must be a number from {min} to {max} {label} måste vara ett tal från {min} till {max}
Character Color Teckenfärg
OpenType Features OpenType-funktioner
Stylistic Sets Stilistiska uppsättningar
@msg features lists at most {max} tags features får innehålla högst {max} taggar
@msg unknown OpenType feature `{t}` okänd OpenType-funktion `{t}`
@msg {label} must be a number above 0 and at most {max} {label} måste vara ett tal över 0 och högst {max}
@msg no character style `{name}` Teckenformatet `{name}` hittades inte
Can't render this file because it contains an unexpected character in line 5 and column 25.
+5
View File
@@ -5959,6 +5959,11 @@ anchor Points: Noktalar:
Horizontal Scale Yatay Ölçek
Vertical Scale Dikey Ölçek
@msg {label} must be a number from {min} to {max} {label} {min} ile {max} arasında bir sayı olmalıdır
Character Color Karakter Rengi
OpenType Features OpenType Özellikleri
Stylistic Sets Stilistik Setler
@msg features lists at most {max} tags features en fazla {max} etiket içerebilir
@msg unknown OpenType feature `{t}` bilinmeyen OpenType özelliği `{t}`
@msg {label} must be a number above 0 and at most {max} {label} 0'ın üzerinde ve en fazla {max} olan bir sayı olmalıdır
@msg no character style `{name}` `{name}` karakter stili bulunamadı
Can't render this file because it contains an unexpected character in line 5 and column 25.
+5
View File
@@ -5601,6 +5601,11 @@ anchor Points: Точки:
Horizontal Scale Масштаб по горизонталі
Vertical Scale Масштаб за вертикаллю
@msg {label} must be a number from {min} to {max} {label} має бути числом від {min} до {max}
Character Color Колір символів
OpenType Features Функції OpenType
Stylistic Sets Стилістичні набори
@msg features lists at most {max} tags features містить не більше {max} тегів
@msg unknown OpenType feature `{t}` невідома функція OpenType `{t}`
@msg {label} must be a number above 0 and at most {max} {label} має бути числом більше 0 і не більше {max}
@msg no character style `{name}` Стиль символів `{name}` не знайдено
Can't render this file because it contains an unexpected character in line 177 and column 5.
+5
View File
@@ -5602,6 +5602,11 @@ anchor Points: Điểm:
Horizontal Scale Tỷ lệ ngang
Vertical Scale Tỷ lệ dọc
@msg {label} must be a number from {min} to {max} {label} phải là một số từ {min} đến {max}
Character Color Màu ký tự
OpenType Features Tính năng OpenType
Stylistic Sets Bộ phong cách
@msg features lists at most {max} tags features chứa tối đa {max} thẻ
@msg unknown OpenType feature `{t}` tính năng OpenType không xác định `{t}`
@msg {label} must be a number above 0 and at most {max} {label} phải là một số lớn hơn 0 và tối đa {max}
@msg no character style `{name}` Không tìm thấy kiểu ký tự `{name}`
Can't render this file because it contains an unexpected character in line 199 and column 5.
+5
View File
@@ -5843,6 +5843,11 @@ anchor Points: 点:
Horizontal Scale 水平缩放
Vertical Scale 垂直缩放
@msg {label} must be a number from {min} to {max} {label} 必须是 {min} 到 {max} 之间的数字
Character Color 字符颜色
OpenType Features OpenType 特性
Stylistic Sets 风格集
@msg features lists at most {max} tags features 最多包含 {max} 个标签
@msg unknown OpenType feature `{t}` 未知的 OpenType 特性 `{t}`
@msg {label} must be a number above 0 and at most {max} {label} 必须是大于 0 且不超过 {max} 的数字
@msg no character style `{name}` 找不到字符样式 `{name}`
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -5639,6 +5639,11 @@ anchor Points: 點:
Horizontal Scale 水平縮放
Vertical Scale 垂直縮放
@msg {label} must be a number from {min} to {max} {label} 必須是 {min} 到 {max} 之間的數字
Character Color 字元顏色
OpenType Features OpenType 功能
Stylistic Sets 風格集
@msg features lists at most {max} tags features 最多包含 {max} 個標籤
@msg unknown OpenType feature `{t}` 未知的 OpenType 功能 `{t}`
@msg {label} must be a number above 0 and at most {max} {label} 必須是大於 0 且不超過 {max} 的數字
@msg no character style `{name}` 找不到字元樣式 `{name}`
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1 -1
View File
@@ -706,7 +706,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"ui.charStyleOptions.resetPanel",
"Reset Panel",
"",
"{} clear the settings of the section shown, Basic Character Formats or Advanced Character Formats, in the open Character Style Options or New Character Style dialog; available only on those sections",
"{} clear the settings of the section shown, Basic Character Formats, Advanced Character Formats, Character Color or OpenType Features, in the open Character Style Options or New Character Style dialog; available only on those sections",
),
(
"ui.newCharStyle",
+5 -5
View File
@@ -20,12 +20,12 @@ enum Flag {
/// A checkbox row: label and the feature it toggles.
type Toggle = (&'static str, Flag);
const FIGURES: [&str; 5] = ["Default Figure", "Tabular Lining", "Proportional Lining", "Proportional Oldstyle", "Tabular Oldstyle"];
pub(crate) const FIGURES: [&str; 5] = ["Default Figure", "Tabular Lining", "Proportional Lining", "Proportional Oldstyle", "Tabular Oldstyle"];
const POSITIONS: [&str; 5] = ["Default Position", "Superscript", "Subscript", "Numerator", "Denominator"];
pub(crate) const POSITIONS: [&str; 5] = ["Default Position", "Superscript", "Subscript", "Numerator", "Denominator"];
/// The features the Position menu's entries after Default Position turn on, in menu order.
const POSITION_TAGS: [&str; 4] = ["sups", "subs", "numr", "dnom"];
pub(crate) const POSITION_TAGS: [&str; 4] = ["sups", "subs", "numr", "dnom"];
/// The bit of [`OtFeatures::extra`] that turns `tag` on (0 for a tag outside [`EXTRA_FEATURES`]).
fn extra_bit(tag: &str) -> u32 {
@@ -51,7 +51,7 @@ impl Flag {
/// The Figure menu entry `f` shows: Default Figure, Tabular Lining, Proportional Lining
/// (`pnum` and `lnum`), Proportional Oldstyle or Tabular Oldstyle.
fn figure(f: &OtFeatures) -> usize {
pub(crate) fn figure(f: &OtFeatures) -> usize {
let proportional_lining = extra_bit("pnum") | extra_bit("lnum");
match (f.tabular_figures, f.oldstyle_figures) {
(false, false) if f.extra & proportional_lining == proportional_lining => 2,
@@ -81,7 +81,7 @@ fn set_figure(f: &mut OtFeatures, i: usize) {
/// The Position menu entry `f` shows: Superscript (`sups`), Subscript (`subs`), Numerator
/// (`numr`) or Denominator (`dnom`) when `f` turns that one on, Default Position when it turns
/// none of them on, and `None` when it turns on more than one.
fn position(f: &OtFeatures) -> Option<usize> {
pub(crate) fn position(f: &OtFeatures) -> Option<usize> {
let mut on = POSITION_TAGS.iter().enumerate().filter(|(_, t)| f.extra & extra_bit(t) != 0).map(|(i, _)| i + 1);
match (on.next(), on.next()) {
(None, _) => Some(0),
+22 -2
View File
@@ -5473,19 +5473,39 @@ attribute map in one undo step. `to` is trimmed and must be unique. The command
while preserving local overrides; Undo/Redo restores the definition and its users. The `charStyleOptions`
dialog exposes `name`, `font_family`, `font_style`, `size`, `leading`, `kerning`, `tracking`, `case`,
`position`, `underline`, `strikethrough`, `charAlign`, `h_scale`, `v_scale`, `baseline_shift`, `rotation`,
`language`, `section` (`general`, `basic` or `advanced`), and `preview`. An empty field leaves its
`language`, `fill`, `fill_tint`, `stroke`, `stroke_tint`, `stroke_width`, `overprint_fill`, `overprint_stroke`,
`ot_figure`, `ot_position`, `ot_liga`, `ot_calt`, `ot_dlig`, `ot_swsh`, `ot_salt`, `ot_titl`, `ot_ordn`, `ot_frac`,
`ot_sets`, `section` (`general`, `basic`, `advanced`, `color` or `opentype`), and `preview`. An empty field leaves its
attribute unset. `leading` and `kerning` also accept `Auto` to store an explicit automatic value.
Advanced Character Formats holds `h_scale` and `v_scale` (percent, above 0 and at most 4000000),
`baseline_shift` (points, -4000000 to 4000000), `rotation` (degrees, stored from -180 to 180) and `language` (a name or tag from Document
Setup's languages; English: USA stores the explicit default `null`, and a tag not in the list that the style
already has is kept). Language sets the language the characters are shaped in; it does not change hyphenation
or spelling.
Character Color holds `fill` and `stroke` (a paint: `{"type": "none"}` or a solid color; the menu lists None and
the document's solid swatches, a global swatch or a tint of one as a paint linked to it), `fill_tint` and
`stroke_tint` (percent, 0 to 100, for a paint linked to a global swatch), `stroke_width` (points, 0 to 4000000)
and the `overprint_fill` and `overprint_stroke` checkboxes. A gradient or pattern paint the style already has is
kept until another paint is chosen. A paint linked to a global swatch takes the swatch's color at its tint, and
editing the swatch later updates the style and its text. `swatch.unused` counts a swatch a character style's fill or
stroke links to as used, whether or not text uses the style.
OpenType Features holds `ot_figure` (`default`, `tabularLining`, `proportionalLining`, `proportionalOldstyle` or
`tabularOldstyle`), `ot_position` (`default`, `superscript`, `subscript`, `numerator` or `denominator`), the on,
off or unset checkboxes `ot_liga`, `ot_calt`, `ot_dlig`, `ot_swsh`, `ot_salt`, `ot_titl`, `ot_ordn` and `ot_frac`,
and `ot_sets` (a list of stylistic sets `ss01` to `ss20`, each on, or off with a leading `-`, as in
`["ss02", "-ss05"]`; a set not listed is unset). They write the style's `features` list: a changed control
replaces only its own tags, so tags the dialog does not show stay, and Default Figure or Default Position stores
its features turned off, distinct from unset.
`charStyle.new`, `charStyle.setAttrs` and `charStyle.edit` reject a tint outside 0 to 1, a link to a swatch that is
not a global swatch of the document, a stroke weight outside 0 to 4000000, more than 256 feature tags and an
OpenType feature tag the shaper does not know, unless the style or the text it is made from already has that value.
The New Character Style dialog accepts the same character fields and starts with the selected text's
captured attributes. Without selected text, it starts with the character defaults for the interface language
(including `charAlign: "emBoxCenter"` in Japanese); changing a Basic field keeps other seeded attributes.
Without selected text, the New Paragraph Style dialog sends no `attrs`, and the engine gives the new style the
paragraph defaults for the interface language. Reset Panel in Basic Character
Formats removes those fields from the definition, and Reset Panel in Advanced Character Formats removes its five.
Formats removes those fields from the definition, Reset Panel in Advanced Character Formats removes its five, Reset
Panel in Character Color removes its five attributes and Reset Panel in OpenType Features removes `features`.
`ui.charStyleOptions.resetPanel {}` performs that reset for the section selected in either character-style
dialog; it is disabled on General.
+1
View File
@@ -505,6 +505,7 @@ since 2026-10-01.
- **Character-style Auto leading:** capture and explicit style attributes retain Auto as a reset, distinct from omitted leading. Redefine and later numeric updates preserve local leading overrides; the Character Styles marker recognizes Auto correctly. Regression assertions cover partial definitions, Undo/Redo and native save/load serialization. Public references: [Character and paragraph styles](https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html) and [Leading](https://helpx.adobe.com/illustrator/using/line-character-spacing.html).
- **Character Style Options, General and Basic Character Formats:** the dialog edits a style's name and stored character formats together. Empty fields remain unset, checkboxes show an indeterminate unset state, Basic's Reset Panel clears its explicit settings, and Preview can be canceled without changing history. OK saves one undo step; New Character Style offers the same fields from captured text or the interface language's defaults without a selection. Public reference: [Character and paragraph styles](https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html).
- **Character Style Options, Advanced Character Formats:** Character Style Options and New Character Style set a style's Horizontal Scale and Vertical Scale (above 0% and at most 4,000,000%), Baseline Shift (-4,000,000 pt to 4,000,000 pt), Character Rotation (stored from -180° to 180°) and Language; text that uses the style takes them and keeps its local overrides. Empty fields remain unset, an explicit 100%, 0° or English: USA stays distinct from unset, and the section's Reset Panel clears only these five. Language sets the language the characters are shaped in, not hyphenation or spelling. Public reference: [Character and paragraph styles](https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html) ("Adjust the Horizontal and Vertical Scale, Baseline Shift, Character Rotation, and Language options").
- **Character Style Options, Character Color and OpenType Features:** Character Style Options and New Character Style set a style's fill and stroke (None or a document swatch; a global swatch stays linked, with a Tint from 0% to 100%), stroke Weight, Overprint Fill and Overprint Stroke, and its Figure, Position, Standard and Discretionary Ligatures, Contextual, Stylistic and Titling Alternates, Swash, Ordinals, Fractions and Stylistic Sets 1 to 20; text that uses the style takes them and keeps its local overrides, and editing a global swatch later updates the style and its text. Empty controls remain unset, an explicit None, off, Default Figure or Default Position stays distinct from unset, each stylistic set is on, off or unset, a gradient or pattern paint stays until another paint is chosen, a changed OpenType control keeps the style's other feature tags, and each section's Reset Panel clears only its own attributes. Public references: [Character and paragraph styles](https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html) ("Change the Character Color, Tint, Weight, and Overprint Fill or Stroke options"; "Set the Figure, Position, Ligatures, Alternates, Ordinals, or Fractions options") and [Format text with OpenType features](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/format-text-with-opentype-features.html) ("In the Character Styles or Paragraph Styles panel, select OpenType Features").
- **Text styles redefined to the defaults:** New and Redefine Character Style and Paragraph Style capture every attribute of the selected text, including those at their defaults (Auto kerning, Roman baseline alignment, the default Hyphenation options, the Every-line Composer, no tab stops), and `charStyle.setAttrs` and `paraStyle.setAttrs` keep an attribute set to its default value. After a redefinition, text whose value still equals the old definition's takes the new value, a default included; local overrides stay, and text that still has the old value of an attribute the new definition leaves out goes back to the Normal style's value. The style panels show "+" only where the text differs from its style, including type with Auto alignment. Public reference: [Character and paragraph styles](https://web.archive.org/web/20260825113735/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html).
- **Text styles dragged onto the panel's buttons:** a style dragged from the Character Styles or Paragraph Styles panel onto Create New Style is copied (`charStyle.duplicate`, `paraStyle.duplicate`, which name the copy `<name> copy`) and the copy is highlighted; dragged onto Delete it is deleted (`charStyle.delete`, `paraStyle.delete`); the Normal style is not deleted. Each drop is one undo step. Public reference: [Character and paragraph styles](https://web.archive.org/web/20261015081534/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html).
- **Auto Leading outside the layout:** a paragraph's Justification… › Auto Leading (<https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/adjust-word-and-letterspacing-in-justified-text.html>) also sets the Leading the Character panel shows for Auto type (blank where the selected paragraphs differ), the leading the Alt+↑/↓ nudge (`type.step`) steps from, each paragraph's own, and the lines Fill with Placeholder Text counts in area type. Evidence: `ParaStyle::effective_leading` (`crates/doc/src/text.rs` test `paragraph_effective_leading_follows_auto_leading`), `tests_prefs::auto_leading_steps_and_fills_from_the_paragraph_auto_leading`, `panels::character` test `leading_field_shows_the_paragraph_auto_leading`.