Merge remote-tracking branch 'origin/main' into fix/import-document-title

This commit is contained in:
Taha-Mahmoodi
2026-11-04 10:54:57 +04:30
18 changed files with 516 additions and 52 deletions
+32 -3
View File
@@ -89,29 +89,58 @@ fn text_tweaker(s: &mut Session, p: &Value) -> Result<Value> {
let mut rng = Rng::new(whole_or(p, "seed", 0, C)?);
let skip = switch_or(p, "skipSpaces", true, C)?;
let setup = s.doc()?.doc.setup.clone();
// A range given as anything but whole bytes is refused, not read as all of the text.
for k in ["start", "end"] {
if p.get(k).is_some_and(|v| !v.is_null() && v.as_u64().is_none()) {
return Err(bad(C, format!("`{k}` must be a whole number of bytes")));
}
}
// At a caret, all of the object's text.
let ranges = type_ranges(s, p, C, |text, _| Some((0, text.len())))?;
// Each object's changed characters: (byte, its change), worked out before the edit.
let mut plans: Vec<(NodeId, Vec<(usize, CharChange)>)> = vec![];
let mut count = 0;
let (mut seen, mut count) = (0, 0);
for (id, a, b, _) in ranges {
let t = text_ref(s, id)?;
let text = t.plain_text();
let chars: Vec<usize> =
text.char_indices().filter(|&(i, c)| (a..b).contains(&i) && !(skip && (c.is_whitespace() || c.is_control()))).map(|(i, _)| i).collect();
count += chars.len();
if count > MAX_CHARS {
seen += chars.len();
if seen > MAX_CHARS {
return Err(bad(C, format!("too many characters: at most {MAX_CHARS}")));
}
// Each run's end byte, to find the style a character has now.
let ends: Vec<usize> = t
.runs
.iter()
.scan(0, |off, r| {
*off += r.text.len();
Some(*off)
})
.collect();
let n = chars.len();
let mut changes = Vec::with_capacity(n);
let mut run = 0;
for (i, at) in chars.into_iter().enumerate() {
let random = if shape == "random" { unit(&mut rng) } else { 0.0 };
let value = from + (to - from) * amount(shape, i, n, random);
let mut change = CharChange::parse(&json!({ key: value }), C, &setup)?;
change.localize(&t, C)?;
while ends.get(run).is_some_and(|&e| e <= at) {
run += 1;
}
// A character that already has the value is left out, so a run that changes nothing
// makes no step.
if let Some(r) = t.runs.get(run) {
let mut style = r.style.clone();
change.apply(&mut style);
if style == r.style {
continue;
}
}
changes.push((at, change));
}
count += changes.len();
if !changes.is_empty() {
plans.push((id, changes));
}
@@ -62,7 +62,8 @@ fn a_straight_ramp_sizes_each_letter_and_skips_spaces() {
select(&mut s, &[id]);
let n = steps(&s);
let out = s.execute(C, &json!({"attribute": "size", "from": 10, "to": 40})).unwrap();
assert_eq!(out, json!({"count": 4, "ids": [id.0]}));
// `b` is 20 pt already: three characters change.
assert_eq!(out, json!({"count": 3, "ids": [id.0]}));
assert_eq!(steps(&s), n + 1, "one undo step");
let sizes: Vec<(char, f64)> = styles(&s, id).into_iter().map(|(c, st)| (c, st.size)).collect();
assert_eq!(sizes, [('a', 10.0), ('b', 20.0), (' ', 20.0), ('c', 30.0), ('d', 40.0)], "the space keeps its size");
@@ -108,7 +109,8 @@ fn each_object_gets_its_own_ramp() {
let (a, b) = (text(&mut s, "abc"), text(&mut s, "de"));
select(&mut s, &[a, b]);
let out = s.execute(C, &json!({"attribute": "rotation", "from": 0, "to": 30})).unwrap();
assert_eq!(out["count"], json!(5));
// Each first character is upright already.
assert_eq!(out["count"], json!(3));
let turns = |id| styles(&s, id).iter().map(|(_, st)| st.rotation).collect::<Vec<_>>();
assert_eq!((turns(a), turns(b)), (vec![0.0, 15.0, 30.0], vec![0.0, 30.0]));
}
@@ -161,6 +163,19 @@ fn empty_text_and_only_spaces_change_nothing() {
assert_eq!(steps(&s), n, "no step");
}
#[test]
fn characters_that_already_have_the_value_are_left_out() {
let mut s = session();
let id = text(&mut s, "abc");
select(&mut s, &[id]);
let p = json!({"attribute": "rotation", "from": 0, "to": 30});
// The first character already has 0°.
assert_eq!(s.execute(C, &p).unwrap(), json!({"count": 2, "ids": [id.0]}));
let (before, n) = (s.doc().unwrap().doc.clone(), steps(&s));
assert_eq!(s.execute(C, &p).unwrap(), json!({"count": 0, "ids": []}), "again: nothing to change");
assert_eq!((s.doc().unwrap().doc.clone(), steps(&s)), (before, n), "no step");
}
#[test]
fn bad_params_are_refused_without_a_change() {
let mut s = session();
@@ -179,6 +194,9 @@ fn bad_params_are_refused_without_a_change() {
json!({"seed": -1}),
json!({"skipSpaces": "yes"}),
json!({"ids": [999_999]}),
json!({"id": id.0, "start": -1}),
json!({"id": id.0, "start": 1, "end": 1.5}),
json!({"end": "2"}),
] {
assert!(s.execute(C, &p).is_err(), "{p}");
assert_eq!(s.doc().unwrap().doc, before, "{p}");
+1 -1
View File
@@ -201,7 +201,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Break Link to Symbol",
[],
None,
"{ids?|id?} expand selected instances into plain art (with their appearance changes to a dynamic symbol), placed through the symbol's 9-slice guides when it has them → {ids}",
"{ids?|id?} expand selected instances into plain art (with their appearance changes to a dynamic symbol), placed through the symbol's 9-slice guides when it has them, with the instance's opacity mask → {ids}",
has_selection,
symbol_break_link
),
+13 -3
View File
@@ -341,7 +341,8 @@ pub(super) fn symbol_place(s: &mut Session, p: &Value) -> Result<Value> {
/// The plain art of instance `inst` in document space (fresh ids), with the instance's own
/// appearance changes to a dynamic symbol's art when `own`, placed through its symbol's 9-slice
/// guides when it has them.
/// guides when it has them. The instance's opacity mask goes on the art, or on a group around it
/// when the art has a mask of its own.
fn expanded_instance(d: &mut Document, inst: &Node, own: bool) -> Option<Node> {
let NodeKind::SymbolInstance { symbol, xf } = &inst.kind else { return None };
let sym = d.symbols.iter().find(|s| &s.name == symbol)?;
@@ -361,13 +362,21 @@ fn expanded_instance(d: &mut Document, inst: &Node, own: bool) -> Option<Node> {
art.opacity *= inst.opacity;
art.blend = inst.blend;
}
if let Some(mask) = &inst.extras().mask {
if art.extras().mask.is_some() {
let id = d.alloc_id();
art = Node::group(id, vec![Arc::new(art)]);
}
art.extras_mut().mask = Some(mask.clone());
}
Some(art)
}
/// The most objects [`expanded_instance`] makes of instance `inst` in `d` (`own` as there): its
/// symbol's object count with the art of its opacity masks, or
/// [`vectorcraft_doc::NineSliceMap::most_placed`] when it is placed through 9-slice guides, plus
/// the group that opacity or a blending mode adds. 0 for any other object.
/// the group that opacity, a blending mode or the instance's opacity mask adds, plus the art of
/// the instance's opacity mask. 0 for any other object.
fn expanded_bound(d: &Document, inst: &Node, own: bool) -> u64 {
let NodeKind::SymbolInstance { symbol, xf } = &inst.kind else { return 0 };
let Some(sym) = d.symbols.iter().find(|s| &s.name == symbol) else { return 0 };
@@ -378,7 +387,8 @@ fn expanded_bound(d: &Document, inst: &Node, own: bool) -> u64 {
None if dynamic => vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, inst).count_with_masks() as u64,
None => sym.art.count_with_masks() as u64,
};
most.saturating_add(1)
let mask = inst.extras().mask.as_ref().map_or(0, |m| m.art.count_with_masks() as u64);
most.saturating_add(1).saturating_add(mask)
}
/// Replace instances `ids` with their art ([`expanded_instance`], `own` as there). Returns the
+12 -5
View File
@@ -328,8 +328,9 @@ fn same_text(s: &mut Session, cmd: &str, eq: fn(&CharStyle, &CharStyle) -> bool)
/// Call `f` with each type object among the selected objects and inside them, in selection order,
/// keeping only those [`walk_selectable`] visits: none that is hidden, locked, on a hidden, locked
/// or template layer, or outside the isolated group. A type object inside two selected objects is
/// given twice. The walk stops when `f` returns false.
/// or template layer, or outside the isolated group. A selected layer or sublayer gives the type
/// on it that passes the same test. A type object inside two selected objects is given twice. The
/// walk stops when `f` returns false.
fn each_selected_text(s: &Session, mut f: impl FnMut(NodeId) -> bool) {
fn inside(n: &Node, f: &mut impl FnMut(NodeId) -> bool) -> bool {
if !n.visible || n.locked {
@@ -340,12 +341,18 @@ fn each_selected_text(s: &Session, mut f: impl FnMut(NodeId) -> bool) {
}
n.children().into_iter().flatten().all(|c| inside(c, f))
}
fn from(n: &Node, selectable: &impl Fn(NodeId) -> bool, f: &mut impl FnMut(NodeId) -> bool) -> bool {
if n.is_layer() {
return n.children().into_iter().flatten().all(|c| from(c, selectable, f));
}
!selectable(n.id) || inside(n, f)
}
let Some(st) = s.active() else { return };
let locks = st.doc.locked_artboards();
let selectable = |id| is_selectable(&st.doc, st.isolation, &locks, id);
for id in &st.selection.objects {
if is_selectable(&st.doc, st.isolation, &locks, *id)
&& let Some(n) = st.doc.node(*id)
&& !inside(n, &mut f)
if let Some(n) = st.doc.node(*id)
&& !from(n, &selectable, &mut f)
{
return;
}
+13 -4
View File
@@ -107,7 +107,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Fill Color",
["Select", "Same"],
None,
"{} the objects filled with the first selected object's fill colour (any tint of its global or spot swatch, the same tint with prefs selectSameTintPercent); a member path of a compound path selected first compares as its compound path, except in isolation mode inside that compound path; from a Live Paint face, the faces of its Live Paint group only, and from any other object, no Live Paint face",
"{} the objects filled with the first selected object's fill colour (any tint of its global or spot swatch, the same tint with prefs selectSameTintPercent); a member path of a compound path selected first compares as its compound path, except in isolation mode inside that compound path; from a Live Paint face, the faces of its Live Paint group only, and from any other object, no Live Paint face; every select.same command that compares with the first selected object passes over a selected object that is hidden or locked, on a template layer or outside the isolated group",
has_selection,
|s, _| {
let tint = s.prefs.select_same_tint_percent;
@@ -686,12 +686,21 @@ fn paint_reported(d: &Document, n: &Node) -> bool {
}
/// The object Select > Same compares the others with, and whether isolation mode is inside a
/// compound path. The object is the first selected one, or its compound path when it is a member
/// of one; in isolation mode inside a compound path it is the member path itself.
/// compound path. The object is the first selected one that [`is_selectable`] accepts (none that is
/// hidden, locked, on a hidden, locked or template layer, or outside the isolated group), or its
/// compound path when it is a member of one; in isolation mode inside a compound path it is the
/// member path itself.
fn same_reference<'a>(st: &'a DocState, cmd: &str) -> Result<(&'a Node, bool)> {
let compound = |n: &Node| matches!(n.kind, NodeKind::Compound { .. });
let members = st.isolation.and_then(|i| st.doc.ancestry(i)).is_some_and(|a| a.iter().any(|i| st.doc.node(*i).is_some_and(compound)));
let first = st.selection.objects.first().and_then(|id| st.doc.node(*id)).ok_or_else(|| bad(cmd, "nothing selected"))?;
let locks = st.doc.locked_artboards();
let first = st
.selection
.objects
.iter()
.find(|id| is_selectable(&st.doc, st.isolation, &locks, **id))
.and_then(|id| st.doc.node(*id))
.ok_or_else(|| bad(cmd, "nothing selected"))?;
let refn = match st.doc.parent_of(first.id).and_then(|p| st.doc.node(p)) {
Some(p) if !members && compound(p) => p,
_ => first,
+8 -1
View File
@@ -351,6 +351,8 @@ pub(crate) struct CharChange {
stroke: Option<Paint>,
/// Weight (`strokeWidth`), cap, join, miter limit and dashes of the character stroke.
stroke_opts: StrokeChange,
/// Document points per text-space point for a stroke edit.
stroke_scale: Option<f64>,
underline: Option<bool>,
strikethrough: Option<bool>,
all_caps: Option<bool>,
@@ -641,6 +643,7 @@ impl CharChange {
fill: paint_param(p, "fill", cmd)?,
stroke: paint_param(p, "stroke", cmd)?,
stroke_opts,
stroke_scale: None,
underline: flag("underline"),
strikethrough: flag("strikethrough"),
all_caps: flag("allCaps"),
@@ -723,6 +726,7 @@ impl CharChange {
self.tcy_up_down = self.tcy_up_down.map(|v| local_type_value(t, v, (-1296.0, 1296.0), false, cmd)).transpose()?;
self.tcy_left_right = self.tcy_left_right.map(|v| local_type_value(t, v, (-1296.0, 1296.0), false, cmd)).transpose()?;
self.h_scale = self.h_scale.map(|v| local_type_value(t, v, (1.0, 10000.0), true, cmd)).transpose()?;
self.stroke_scale = Some(vectorcraft_doc::Scaling::factor(t.xf).filter(|k| k.is_finite() && *k > 1e-12).unwrap_or(1.0));
Ok(())
}
@@ -809,10 +813,13 @@ impl CharChange {
st.fill = v.clone();
}
if let Some(v) = &self.stroke {
if !v.is_none() {
super::gradient::run_stroke_weight(st);
}
st.stroke = v.clone();
}
if !self.stroke_opts.is_empty() {
self.stroke_opts.apply_char(st);
self.stroke_opts.apply_char_scaled(st, self.stroke_scale.unwrap_or(1.0));
}
if let Some(v) = self.underline {
st.underline = v;
+53
View File
@@ -1242,6 +1242,59 @@ fn break_link_then_update_redefines_all_instances() {
assert!((bounds(&s, other).center().x - 300.0).abs() < 1e-6);
}
/// A linked opacity mask of a 10 × 10 square at (100, 50).
fn square_mask(s: &mut Session) -> Box<vectorcraft_doc::OpacityMask> {
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
let art = Node::path(doc.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(100.0, 50.0, 110.0, 60.0)), Default::default());
Box::new(vectorcraft_doc::OpacityMask::new(art, true))
}
/// Break Link to Symbol keeps the instance's opacity mask on the art.
#[test]
fn break_link_keeps_the_instances_opacity_mask() {
let mut s = session();
let (inst, _, _) = make_symbol(&mut s);
let m = square_mask(&mut s);
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(inst).unwrap().extras_mut().mask = Some(m.clone());
let r = s.execute("symbol.breakLink", &json!({"ids": [inst.0]})).unwrap();
let art = node(&s, NodeId(r["ids"][0].as_u64().unwrap()));
assert!(matches!(art.kind, NodeKind::Path { .. }), "{}", art.kind_label());
assert_eq!(art.extras().mask, Some(m), "the art keeps the mask");
}
/// When the symbol's art has an opacity mask of its own, Break Link puts the instance's mask on a
/// group around the art, and the art keeps its own.
#[test]
fn break_link_puts_the_instances_mask_on_a_group_around_masked_art() {
let mut s = session();
let (inst, name, _) = make_symbol(&mut s);
let (own, m) = (square_mask(&mut s), square_mask(&mut s));
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
let sym = doc.symbols.iter_mut().find(|s| s.name == name).unwrap();
std::sync::Arc::make_mut(&mut sym.art).extras_mut().mask = Some(own);
doc.node_mut(inst).unwrap().extras_mut().mask = Some(m.clone());
let r = s.execute("symbol.breakLink", &json!({"ids": [inst.0]})).unwrap();
let art = node(&s, NodeId(r["ids"][0].as_u64().unwrap()));
let NodeKind::Group { children, .. } = &art.kind else { panic!("a group around the art: {}", art.kind_label()) };
assert_eq!(art.extras().mask, Some(m), "the group holds the instance's mask");
assert!(children.len() == 1 && children[0].extras().mask.is_some(), "the art keeps its own mask");
}
/// The art of the instance's opacity mask counts against Break Link's object limit: an instance
/// whose mask holds more than a million objects stays an instance.
#[test]
fn break_link_counts_the_instances_mask_art() {
let mut s = session();
let (inst, _, _) = make_symbol(&mut s);
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
let square =
std::sync::Arc::new(Node::path(doc.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(100.0, 50.0, 110.0, 60.0)), Default::default()));
let art = Node::group(doc.alloc_id(), vec![square; 1_000_000]);
doc.node_mut(inst).unwrap().extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(art, true)));
assert!(s.execute("symbol.breakLink", &json!({"ids": [inst.0]})).is_err(), "too many objects");
assert!(matches!(node(&s, inst).kind, NodeKind::SymbolInstance { .. }), "it stays an instance");
}
#[test]
fn delete_duplicate_and_replace_symbols() {
let mut s = session();
+15
View File
@@ -91,6 +91,21 @@ fn range_stroke_options_split_runs() {
assert!(s.execute("text.setRangeStyle", &json!({"id": id.0, "strokeOptions": 3})).is_err());
}
#[test]
fn range_stroke_gains_a_default_weight_and_scales_explicit_weight() {
let mut s = session();
let id = NodeId(run(&mut s, "text.create", json!({"x": 10, "y": 50, "text": "ABCD"}))["id"].as_u64().unwrap());
let weights = |s: &Session| text(s, id).runs.iter().map(|r| r.style.stroke_width).collect::<Vec<_>>();
run(&mut s, "text.setRangeStyle", json!({"id": id.0, "start": 0, "end": 2, "stroke": "#ff0000"}));
assert_eq!(weights(&s), [1.0, 0.0], "a newly painted range has a visible default stroke");
run(&mut s, "object.scale", json!({"ids": [id.0], "sx": 200, "sy": 200}));
run(&mut s, "text.setRangeStyle", json!({"id": id.0, "start": 0, "end": 2, "strokeWidth": 2.0}));
assert_eq!(weights(&s), [1.0, 0.0], "two document points become one text-space point at 200% scale");
run(&mut s, "text.setRangeStyle", json!({"id": id.0, "start": 2, "end": 4, "stroke": "#0000ff", "strokeWidth": 4.0}));
assert_eq!(weights(&s), [1.0, 2.0], "a different range receives its scaled weight");
}
#[test]
fn eyedropper_and_outlines_carry_the_character_stroke() {
let mut s = session();
+82
View File
@@ -1995,6 +1995,36 @@ fn select_same_font_skips_type_that_cannot_be_selected() {
assert_eq!(selected(&s), vec![g]);
}
/// A selected layer gives the type on it that can be selected as the reference of the font Same
/// commands: a hidden or locked layer gives none.
#[test]
fn select_same_font_takes_its_reference_from_the_type_on_a_selected_layer() {
let mut s = session();
let typed =
|s: &mut Session, y: f64, font: &str| id_of(&s.execute("text.create", &json!({"x": 0, "y": y, "text": "type", "font": font})).unwrap());
let arial = typed(&mut s, 20.0, "Arial");
let courier = typed(&mut s, 60.0, "Courier");
let l2 = id_of(&s.execute("layer.new", &json!({"top": true})).unwrap());
let on_layer = typed(&mut s, 100.0, "Courier");
sel(&mut s, &[l2]);
assert_eq!(s.execute("select.same.fontFamily", &json!({})).unwrap()["count"], 2);
let mut got = selected(&s);
got.sort();
let mut want = vec![courier, on_layer];
want.sort();
assert_eq!(got, want);
assert!(!selected(&s).contains(&arial));
for (off, on) in [
(json!({"id": l2.0, "visible": false}), json!({"id": l2.0, "visible": true})),
(json!({"id": l2.0, "locked": true}), json!({"id": l2.0, "locked": false})),
] {
s.execute("layer.setProps", &off).unwrap();
sel(&mut s, &[l2]);
assert!(s.execute("select.same.fontFamily", &json!({})).is_err(), "{off}");
s.execute("layer.setProps", &on).unwrap();
}
}
/// Select › Same › Text Fill & Stroke Color selects the type with characters whose fill and stroke
/// both match the first character of the selected type: the same fill with another stroke, or the
/// same stroke with another fill, is left out, and in type with several runs one run has to match
@@ -2185,6 +2215,58 @@ fn select_same_matches_compound_paths_on_their_own_paint() {
assert_eq!(same(&mut s, outer, "select.same.shapeType"), vec![outer, inner]);
}
/// Select > Same compares the other objects with the first selected object that can be selected:
/// one that is hidden or locked, on a template layer, or outside the isolated group is passed
/// over. A selection that holds no such object has nothing to compare with.
#[test]
fn select_same_skips_a_reference_that_cannot_be_selected() {
let mut s = session();
let [red, blue, other_blue, other_red, grouped_red] = [0.0, 20.0, 40.0, 60.0, 80.0].map(|x| rect(&mut s, x, 0.0, 10.0, 10.0));
for id in [red, other_red, grouped_red] {
fill(&mut s, id, "#ff0000");
}
for id in [blue, other_blue] {
fill(&mut s, id, "#0000ff");
}
let same = |s: &mut Session, from: &[NodeId], cmd: &str| {
sel(s, from);
assert_eq!(selected(s), from, "select.set keeps {from:?}");
s.execute(cmd, &json!({})).unwrap();
let mut v = selected(s);
v.sort();
v
};
// A hidden or locked red path selected first is not the reference.
for (on, off) in [
(json!({"id": red.0, "visible": false}), json!({"id": red.0, "visible": true})),
(json!({"id": red.0, "locked": true}), json!({"id": red.0, "locked": false})),
] {
s.execute("layer.setProps", &on).unwrap();
for cmd in ["select.same.fillColor", "select.same.fillAndStroke"] {
assert_eq!(same(&mut s, &[red, blue], cmd), vec![blue, other_blue], "{cmd} {on}");
}
s.execute("layer.setProps", &off).unwrap();
}
// A red path on a template layer is not the reference.
let l2 = id_of(&s.execute("layer.new", &json!({"top": true})).unwrap());
let on_template = rect(&mut s, 100.0, 0.0, 10.0, 10.0);
fill(&mut s, on_template, "#ff0000");
s.execute("layer.setProps", &json!({"id": l2.0, "template": true})).unwrap();
assert_eq!(same(&mut s, &[on_template, blue], "select.same.fillColor"), vec![blue, other_blue]);
// A red path outside the isolated group is not the reference.
sel(&mut s, &[blue, grouped_red]);
let g = id_of(&s.execute("object.group", &json!({})).unwrap());
s.execute("object.isolate", &json!({"id": g.0})).unwrap();
assert_eq!(same(&mut s, &[other_red, blue], "select.same.fillColor"), vec![blue]);
s.execute("object.exitIsolation", &json!({})).unwrap();
// With no object that can be selected, there is nothing to compare with, and the selection stays.
s.execute("layer.setProps", &json!({"id": red.0, "visible": false})).unwrap();
sel(&mut s, &[red, on_template]);
let r = s.execute("select.same.fillColor", &json!({}));
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if *msg == "nothing selected"), "{r:?}");
assert_eq!(selected(&s), vec![red, on_template]);
}
/// Select > Same > Fill Color and Fill & Stroke from a Live Paint face select the faces of that
/// Live Paint group with the same fill, and nothing outside it. From an object outside every Live
/// Paint group, they select no face.
+26
View File
@@ -286,6 +286,32 @@ fn select_same_style_and_attribute_from_a_compound_path_member() {
assert!(s.execute("select.same.graphicStyle", &json!({})).is_err());
}
/// Select > Same > Graphic Style and Appearance Attribute pass over a hidden object selected first
/// and compare the other objects with the next one.
#[test]
fn select_same_style_and_attribute_skip_a_hidden_reference() {
let mut s = session();
let a = rect(&mut s, 10.0);
set_fill(&mut s, a, "#ff0000");
run(&mut s, "effect.apply", json!({"effect": "distort.roughen"}));
run(&mut s, "graphicStyle.new", json!({"name": "Rough"}));
run(&mut s, "graphicStyle.apply", json!({"name": "Rough"}));
let b = rect(&mut s, 100.0);
run(&mut s, "graphicStyle.apply", json!({"name": "Rough"}));
let hidden = rect(&mut s, 200.0);
set_fill(&mut s, hidden, "#0000ff");
run(&mut s, "graphicStyle.new", json!({"name": "Blue"}));
run(&mut s, "graphicStyle.apply", json!({"name": "Blue"}));
rect(&mut s, 300.0);
run(&mut s, "graphicStyle.apply", json!({"name": "Blue"}));
run(&mut s, "layer.setProps", json!({"id": hidden.0, "visible": false}));
for cmd in ["select.same.graphicStyle", "select.same.appearanceAttribute"] {
run(&mut s, "select.set", json!({"ids": [hidden.0, a.0]}));
run(&mut s, cmd, json!({}));
assert_eq!(s.doc().unwrap().selection.objects, vec![a, b], "{cmd}");
}
}
#[test]
fn document_info_lists_style_names() {
let mut s = session();
+12 -6
View File
@@ -23,7 +23,9 @@ use vectorcraft_engine::cmd::color_value;
use super::form::PREVIEWED;
use super::{DialogAction, DialogSpec};
use crate::panels::color::{GAMUT_WARNING, WEB_WARNING, gamut_fix, hex_digits, is_web_safe, parse_hex, warning_chip, web_safe};
use crate::panels::color::{
GAMUT_WARNING, WEB_WARNING, gamut_fix, hex_digits, is_web_safe, parse_hex, warning_chip, web_safe, working_hsb, working_rgb,
};
use crate::panels::{c32, color_json};
use crate::state::Dialog;
use crate::theme::Tokens;
@@ -123,8 +125,9 @@ pub struct Picked {
}
impl Picked {
/// Read HSB from the color's native working RGB components.
pub fn of(color: Color) -> Self {
Self { color, hsb: color.to_hsb() }
Self { color, hsb: working_hsb(&color) }
}
fn from_hsb(hsb: [f32; 3]) -> Self {
Self { color: Color::from_hsb(hsb[0], hsb[1], hsb[2]), hsb }
@@ -137,7 +140,7 @@ impl Picked {
/// The slider value and the field point (x right, y down; 0..1 each) of a colour.
pub fn field_pos(ch: Channel, p: &Picked) -> (f32, f32, f32) {
let v = if ch.is_hsb() { [p.hsb[0] / 360.0, p.hsb[1], p.hsb[2]] } else { p.color.to_rgb() };
let v = if ch.is_hsb() { [p.hsb[0] / 360.0, p.hsb[1], p.hsb[2]] } else { working_rgb(&p.color) };
let (z, x, y) = ch.axes();
(v[z], v[x], 1.0 - v[y])
}
@@ -162,7 +165,7 @@ pub fn slider_color(ch: Channel, z: f32, x: f32, y: f32) -> Color {
/// The number and suffix that [`value_fields`] shows in the field of `ch` for `p`, and the field's
/// largest number: H in degrees (to 360), S and B in percent (to 100), and R, G and B from 0 to 255.
fn channel_value(ch: Channel, p: &Picked) -> (f32, &'static str, f32) {
let [r, g, b] = p.color.to_rgb();
let [r, g, b] = working_rgb(&p.color);
match ch {
Channel::Hue => (p.hsb[0], "°", 360.0),
Channel::Saturation => (p.hsb[1] * 100.0, "%", 100.0),
@@ -174,6 +177,7 @@ fn channel_value(ch: Channel, p: &Picked) -> (f32, &'static str, f32) {
}
/// The colour the dialog holds: an edited `hex` wins over `color` (agents set either one).
/// Cached HSB is reused when it reconstructs working RGB within 1e-6 per channel.
fn picked(d: &Dialog) -> Picked {
let hex = d.str("hex");
let color = match parse_hex(&hex) {
@@ -182,7 +186,9 @@ fn picked(d: &Dialog) -> Picked {
};
let hsb = d.fields.get("__hsb").and_then(|v| serde_json::from_value::<[f32; 3]>(v.clone()).ok());
let p = match hsb {
Some(h) if Picked::from_hsb(h).color.to_hex() == color.to_hex() => Picked { color, hsb: h },
Some(h) if working_rgb(&Picked::from_hsb(h).color).into_iter().zip(working_rgb(&color)).all(|(a, b)| (a - b).abs() <= 1e-6) => {
Picked { color, hsb: h }
}
_ => Picked::of(color),
};
p.snapped(d.bool("webOnly"))
@@ -493,7 +499,7 @@ fn new_original_chips(ui: &mut Ui, new: &Color, original: &Color) -> bool {
/// K, with a stop at each channel's radio button before its field.
fn value_fields(ui: &mut Ui, p: &Picked, channel: Channel) -> (Option<Picked>, Option<Channel>) {
let t = Tokens::get(ui.ctx());
let rgb = p.color.to_rgb();
let rgb = working_rgb(&p.color);
let lab = p.color.to_lab();
let cms = cms::active();
let cmyk = p.color.to_cmyk_managed(cms.settings().intent);
+2 -2
View File
@@ -717,7 +717,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"ui.colorPicker",
"Color Picker…",
"",
"{stroke?: bool (default: the active proxy), artboard?: index (the picker is for that artboard's background color instead), color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray} (default: the proxy's color, or the artboard's color, white when it has none)} open the Color Picker (fields: hex or color, channel, webOnly, swatches); OK runs paint.setFill / paint.setStroke, or artboard.setProps {index, color} for an artboard",
"{stroke?: bool (default: the active proxy), artboard?: index (the picker is for that artboard's background color instead), color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray} (default: the proxy's color, or the artboard's color, white when it has none)} open the Color Picker (fields: hex or color, channel, webOnly, swatches); RGB, HSB and hex values use the working RGB space; OK runs paint.setFill / paint.setStroke, or artboard.setProps {index, color} for an artboard",
),
(
"ui.sampleScreenColor",
@@ -1092,7 +1092,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"ui.colorPanel",
"Color Panel Options",
"",
"{mode?: grayscale|rgb|hsb|cmyk|websafe, options?: bool} pick the Color panel's mode as its panel menu does (the active color converts to the mode's model as one undo step), and Show Options (true) or Hide Options (false); both are kept with the preferences (ui.inspect: ui.color_mode, ui.color_hide_options) → {mode (null: none picked yet), options, shown: the mode the panel shows the color in, or tint for a tint of a global or spot swatch}",
"{mode?: grayscale|rgb|hsb|cmyk|websafe, options?: bool} pick the Color panel's mode as its panel menu does (the active color converts to the mode's model as one undo step), and Show Options (true) or Hide Options (false); both are kept with the preferences; RGB, HSB, Web Safe RGB and hex values use the working RGB space (ui.inspect: ui.color_mode, ui.color_hide_options) → {mode (null: none picked yet), options, shown: the mode the panel shows the color in, or tint for a tint of a global or spot swatch}",
),
(
"ui.savePdfDialog",
+18 -5
View File
@@ -211,7 +211,7 @@ pub const NEW_ACTION: &str = "ui.action.new";
/// recording into it; Stop Playing/Recording ends the recording. `key` (1 to 15, F1 to F15; 0 or
/// null for none) with `cmd` (Ctrl, Command on macOS) and `shift` is the function key that plays
/// it, and `color` (one of [`COLORS`]; null or `""` for none) its Button Mode color. The action's
/// function key takes priority over command shortcuts. The set becomes current and the new action is selected. While an
/// function key takes priority over command shortcuts. The set becomes current and the only selected set, and the new action is selected. While an
/// action is recording, and for an index past the last set, an empty name, a key or color that is
/// not one of them, nothing is added. The New Action dialog's Record runs this. → {set, action}
pub fn new_action(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
@@ -235,10 +235,8 @@ pub fn new_action(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String>
s.actions.push(action);
let index = s.actions.len() - 1;
start_recording(app, set, name, Some(id));
if app.ui.action_set != Some(set) {
app.ui.action_set = Some(set);
app.ui.action_sets_selected = vec![set];
}
app.ui.action_set = Some(set);
app.ui.action_sets_selected = vec![set];
if let Some(ctx) = app.ui_ctx.clone() {
set_pstate::<Option<(usize, usize)>>(&ctx, "actions-selected", Some((set, index)));
}
@@ -1406,6 +1404,21 @@ mod tests {
assert_eq!(pstate::<Option<(usize, usize)>>(&ctx, "actions-selected"), None, "the action is no longer selected");
}
/// New Action in the current set of a Shift-selected range leaves that set as the only selected
/// one, so Delete Selection does not ask about the rest of the range afterwards.
#[test]
fn new_action_in_the_current_set_clears_the_selected_range() {
let mut app = app_with_sets();
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
let ctx = egui::Context::default();
click_set(&ctx, &mut app, "Mine", egui::Modifiers::NONE);
click_set(&ctx, &mut app, "Other", egui::Modifiers::SHIFT);
assert_eq!((app.ui.action_set, app.ui.action_sets_selected.clone()), (Some(1), vec![1, 2]));
app.run(NEW_ACTION, json!({"set": 1, "name": "Fresh"})).unwrap();
assert_eq!((app.ui.action_set, app.ui.action_sets_selected.clone()), (Some(1), vec![1]));
assert_eq!(selected_sets(&app), [1]);
}
/// The default set can be deleted, and so can the last set: the panel is then empty, no set is
/// current, and New Set makes a set again.
#[test]
+31 -14
View File
@@ -16,6 +16,7 @@
use egui::{Color32, Rect, Sense, Stroke, StrokeKind, Ui, vec2};
use serde_json::{Value, json};
use vectorcraft_color::cms;
use vectorcraft_color::{Color, GradientKind, Paint};
use super::{active_paint, color_json, live_run, pstate, set_pstate};
@@ -102,7 +103,19 @@ impl Mode {
}
}
/// Displayed component values of `c` in `mode` (RGB 0–255, HSB °/%/%, CMYK %, K %, Lab L*a*b*).
/// The color's numeric components in the active working RGB space.
pub(crate) fn working_rgb(c: &Color) -> [f32; 3] {
let cms = cms::active();
cms.convert(c, cms::Model::Rgb, cms.settings().intent).to_rgb_uncalibrated()
}
/// HSB of the working RGB components, with hue in degrees.
pub(crate) fn working_hsb(c: &Color) -> [f32; 3] {
let hsb = egui::ecolor::Hsva::from_rgb(working_rgb(c));
[hsb.h * 360.0, hsb.s, hsb.v]
}
/// Numeric component values of `c` in `mode` (working RGB 0 to 255, HSB °/%/%, CMYK %, K %, Lab L*a*b*).
pub fn components(mode: Mode, c: &Color) -> Vec<f32> {
match mode {
Mode::Grayscale => {
@@ -115,9 +128,9 @@ pub fn components(mode: Mode, c: &Color) -> Vec<f32> {
};
vec![k * 100.0]
}
Mode::Rgb | Mode::WebSafe => c.to_rgb().iter().map(|v| (v * 255.0).clamp(0.0, 255.0)).collect(),
Mode::Rgb | Mode::WebSafe => working_rgb(c).iter().map(|v| (v * 255.0).clamp(0.0, 255.0)).collect(),
Mode::Hsb => {
let [h, s, b] = c.to_hsb();
let [h, s, b] = working_hsb(c);
vec![h, s * 100.0, b * 100.0]
}
Mode::Cmyk => c.to_cmyk().iter().map(|v| v * 100.0).collect(),
@@ -146,15 +159,16 @@ pub fn from_components(mode: Mode, v: &[f32]) -> Color {
}
}
/// The nearest web-safe colour (each channel a multiple of 0x33).
/// The nearest web-safe color (each working RGB channel a multiple of 0x33).
pub fn web_safe(c: &Color) -> Color {
let q = |v: f32| (v.clamp(0.0, 1.0) * 5.0).round() / 5.0;
let [r, g, b] = c.to_rgb();
let [r, g, b] = working_rgb(c);
Color::rgb(q(r), q(g), q(b))
}
/// Whether the working RGB hex value uses only web-safe channel levels.
pub fn is_web_safe(c: &Color) -> bool {
web_safe(c).to_hex() == c.to_hex()
hex_digits(&web_safe(c)) == hex_digits(c)
}
/// Track colour of slider `i` at position `t` (0..1) with the other components fixed (dynamic
@@ -190,9 +204,10 @@ pub fn spectrum_at(mode: Mode, x: f32, y: f32) -> Color {
}
}
/// The hex field text of a colour (`E67828`).
/// The hex field text of a color's working RGB components (`E67828`).
pub fn hex_digits(c: &Color) -> String {
c.to_hex().trim_start_matches('#').to_uppercase()
let [r, g, b] = working_rgb(c).map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8);
format!("{r:02X}{g:02X}{b:02X}")
}
/// Parse a hex field (`E67828`, `#e67828`, `fff`).
@@ -220,7 +235,7 @@ pub(crate) fn warning_chip(ui: &mut Ui, (icon, warning): (&str, &str), fix: &Col
widgets::swatch_tile(ui, r, &Paint::solid(*fix), false, resp.hovered());
resp.on_hover_text(crate::i18n::fmt(
tl!("{warning}: click to correct to the closest color ({hex})"),
&[("warning", tl!(warning)), ("hex", &fix.to_hex())],
&[("warning", tl!(warning)), ("hex", &format!("#{}", hex_digits(fix).to_ascii_lowercase()))],
))
.clicked()
})
@@ -575,6 +590,7 @@ fn spectrum(ui: &mut Ui, mode: Mode, size: egui::Vec2) -> Option<SpectrumHit> {
Some(SpectrumHit::Pick(spectrum_at(mode, (p.x - r.left()) / r.width(), (p.y - r.top()) / r.height()), phase))
}
/// Draw numeric paint controls, retaining live components while the color and settings match.
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let tgt = target(app, ui);
@@ -597,9 +613,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
}
return;
}
// Keep the displayed components while the colour is unchanged (hue survives S = 0 etc.).
let key = color.map(|c| (c.to_hex(), mode as u8));
let comps: Vec<f32> = match (color, pstate::<Option<((String, u8), Vec<f32>)>>(ui.ctx(), "color-comps")) {
// Keep the components while the native color and settings are unchanged (hue survives S = 0).
let settings = cms::active_settings();
let key = color.map(|c| (c, mode as u8, settings.clone()));
let comps: Vec<f32> = match (color, pstate::<Option<((Color, u8, cms::ColorSettings), Vec<f32>)>>(ui.ctx(), "color-comps")) {
(Some(_), Some((k, v))) if Some(&k) == key.as_ref() => v,
(Some(c), _) => components(mode, &c),
(None, _) => vec![0.0; mode.labels().len()],
@@ -737,7 +754,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
apply_tint(app, &tgt, tint, t, phase);
}
if let Some((c, phase, comps2)) = new {
let key = (c.to_hex(), mode as u8);
let key = (c, mode as u8, settings);
set_pstate(ui.ctx(), "color-comps", Some((key, comps2)));
choose(app, &tgt, c, phase, false);
}
@@ -779,7 +796,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
if menu_item(ui, tl!("Copy Color Value (Hex)"), color.is_some(), false)
&& let Some(c) = color
{
ui.ctx().copy_text(c.to_hex());
ui.ctx().copy_text(format!("#{}", hex_digits(&c).to_ascii_lowercase()));
}
}
@@ -0,0 +1,155 @@
//! Numeric color controls edit the working RGB channels, including colors outside display sRGB.
// Integration tests may unwrap and panic (AGENTS.md, Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::sync::{Mutex, MutexGuard};
use serde_json::json;
use vectorcraft_color::Color;
use vectorcraft_color::cms::{self, ColorSettings, DISPLAY_P3, PROPHOTO_RGB, SRGB, WIDE_GAMUT_RGB};
use vectorcraft_engine::Session;
use vectorcraft_ui_egui::VectorcraftApp;
use vectorcraft_ui_egui::panels::color::{Mode, components, from_components, hex_digits, parse_hex, web_safe};
// This integration binary contains only these tests; its app-wide CMS is independent of other
// test binaries. Serialize its cases and restore settings even when an assertion fails.
static CMS: Mutex<()> = Mutex::new(());
struct WorkingSpace {
previous: ColorSettings,
_lock: MutexGuard<'static, ()>,
}
impl WorkingSpace {
/// Select a working profile for one case without retaining it after a panic.
fn new(rgb: &str) -> Self {
let lock = CMS.lock().unwrap_or_else(|e| e.into_inner());
let previous = cms::active_settings();
cms::set_active(&ColorSettings { rgb: rgb.into(), ..ColorSettings::default() }).unwrap();
Self { previous, _lock: lock }
}
}
impl Drop for WorkingSpace {
fn drop(&mut self) {
cms::set_active(&self.previous).unwrap();
}
}
/// Compare native channel values without converting them through the display profile.
fn native(color: Color, expected: [f32; 3]) {
let Color::Rgb { r, g, b } = color else { panic!("expected RGB, got {color:?}") };
for (actual, expected) in [r, g, b].into_iter().zip(expected) {
assert!((actual - expected).abs() < 1e-6, "{color:?}: expected {expected}, got {actual}");
}
}
/// Editing R preserves the stored G and B components.
#[test]
fn rgb_edits_preserve_untouched_working_channels() {
for profile in [SRGB, DISPLAY_P3, WIDE_GAMUT_RGB, PROPHOTO_RGB] {
let _space = WorkingSpace::new(profile);
let color = Color::rgb(0.5, 0.3, 0.2);
let mut values = components(Mode::Rgb, &color);
for (actual, expected) in values.iter().zip([127.5, 76.5, 51.0]) {
assert!((actual - expected).abs() < 1e-4, "{profile}: {values:?}");
}
values[0] = 153.0;
native(from_components(Mode::Rgb, &values), [0.6, 0.3, 0.2]);
}
}
/// HSB describes the native RGB triplet; changing hue preserves its saturation and brightness.
#[test]
fn hsb_edits_preserve_native_saturation_and_brightness() {
for profile in [SRGB, DISPLAY_P3, WIDE_GAMUT_RGB, PROPHOTO_RGB] {
let _space = WorkingSpace::new(profile);
let mut values = components(Mode::Hsb, &Color::rgb(0.5, 0.3, 0.2));
for (actual, expected) in values.iter().zip([20.0, 60.0, 50.0]) {
assert!((actual - expected).abs() < 1e-4, "{profile}: {values:?}");
}
values[0] = 140.0;
native(from_components(Mode::Hsb, &values), [0.2, 0.5, 0.3]);
}
}
/// Native primaries survive numeric round trips even when display conversion clips their gamut.
#[test]
fn numeric_roundtrips_preserve_out_of_display_gamut_colors() {
{
let _space = WorkingSpace::new(DISPLAY_P3);
let red = Color::rgb(1.0, 0.0, 0.0);
let red_with_green = Color::rgb(1.0, 0.05, 0.0);
assert_eq!(red.to_rgb(), red_with_green.to_rgb(), "display clipping loses their native distinction");
native(from_components(Mode::Rgb, &components(Mode::Rgb, &red_with_green)), [1.0, 0.05, 0.0]);
}
for profile in [DISPLAY_P3, WIDE_GAMUT_RGB, PROPHOTO_RGB] {
let _space = WorkingSpace::new(profile);
for rgb in [[1.0, 0.2, 0.0], [0.0, 1.0, 0.2], [0.2, 0.0, 1.0]] {
let color = Color::rgb(rgb[0], rgb[1], rgb[2]);
for mode in [Mode::Rgb, Mode::Hsb] {
native(from_components(mode, &components(mode, &color)), rgb);
}
}
}
}
/// Opening the picker reads HSB from the stored RGB components.
#[test]
fn picker_initialization_preserves_working_values() {
for profile in [SRGB, DISPLAY_P3, WIDE_GAMUT_RGB, PROPHOTO_RGB] {
let _space = WorkingSpace::new(profile);
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
app.run("ui.colorPicker", json!({"color": [0.5, 0.3, 0.2]})).unwrap();
let dialog = app.ui.dialog.as_ref().unwrap();
let hsb = dialog.fields.get("__hsb").unwrap().as_array().unwrap();
for (actual, expected) in hsb.iter().zip([20.0, 0.6, 0.5]) {
assert!((actual.as_f64().unwrap() - expected).abs() < 1e-4, "{profile}: {hsb:?}");
}
}
}
/// Shared hex and web-safe numeric controls round the native working RGB components.
#[test]
fn shared_hex_and_websafe_preserve_working_values() {
for profile in [SRGB, DISPLAY_P3, WIDE_GAMUT_RGB, PROPHOTO_RGB] {
let _space = WorkingSpace::new(profile);
let color = Color::rgb(0.5, 0.3, 0.2);
assert_eq!(hex_digits(&color), "804D33", "{profile}");
native(parse_hex("804D33").unwrap(), [128.0 / 255.0, 77.0 / 255.0, 0.2]);
native(web_safe(&color), [0.6, 0.4, 0.2]);
}
}
/// A panel slider click changes one native component and makes one undo step.
#[test]
fn color_panel_slider_preserves_native_channels_and_undo() {
use egui_kittest::kittest::Queryable;
let _space = WorkingSpace::new(DISPLAY_P3);
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
app.run("paint.setFill", json!({"color": [0.5, 0.3, 0.2]})).unwrap();
let mut fonts = false;
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(320.0, 400.0)).with_accessibility_check(false).build_ui_state(
move |ui, app: &mut VectorcraftApp| {
if !std::mem::replace(&mut fonts, true) {
vectorcraft_ui_egui::theme::install_fonts(ui.ctx());
return;
}
vectorcraft_ui_egui::panels::color::show(app, ui);
},
app,
);
harness.run_steps(3);
harness.get_by_role_and_label(egui::accesskit::Role::Slider, "R").click();
harness.run_steps(3);
let color = harness.state().session.proxy_paints().0.color().unwrap();
let Color::Rgb { r, .. } = color else { panic!("expected RGB") };
assert_ne!(r, 0.5, "the slider was edited");
native(color, [r, 0.3, 0.2]);
harness.state_mut().run("edit.undo", json!({})).unwrap();
native(harness.state().session.proxy_paints().0.color().unwrap(), [0.5, 0.3, 0.2]);
}
+21 -6
View File
@@ -410,6 +410,11 @@ paint command (and the eyedropper) feeds.
`paint.proxies` returns what the proxies show: the fill and stroke, which one is active, and whether the selected
objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" proxy).
In the app's Color panel and Color Picker, RGB, HSB, hex and Web Safe RGB values describe the active working RGB
space. RGB edits preserve the other native channels; HSB uses those same working RGB components. Colors outside
display sRGB retain their native numeric values. `ui.colorPanel` selects the panel mode and `ui.colorPicker`
opens the picker through `run_command` with the remote backend.
| Tool | Arguments | Notes |
|---|---|---|
| `list_commands` | `{filter?, query?, menu?, enabledOnly?, brief?}` | The command catalogue: id, label, menu, shortcut, params doc (left out with `brief`), enablement. |
@@ -445,8 +450,9 @@ For example, create editable Japanese area type with the engine's vertical writi
```
`vertical` also applies to point type and to `path` + `mode` (`area` or `onPath`). Omit it or pass `false`
for horizontal writing. This is the same option as `text.create` / `text.createInPath`; it does not change
the engine's existing glyph orientation or CJK composition.
for horizontal writing. This is the same option as `text.create` / `text.createInPath`. Vertical text turns
on the OpenType `vert` feature, so a font's vertical glyph alternates are used; it does not change the engine's
CJK composition.
For SVG/SVGZ, `export`'s top-level `outlineText: true` writes text as paths; DXF takes it too (its type is outlined anyway unless `preserve: editability`).
PDF instead uses `options: {"advanced": {"outlineText": true}}`; its default is false (live text).
@@ -1453,6 +1459,12 @@ selected objects and the objects inside them, in selection order, as the one to
inside a hidden or locked group or layer, on a template layer, or outside the isolated group is passed over. When the
selection holds no other type, they return the error "select a text object".
`select.same.fillColor`, `strokeColor`, `fillAndStroke`, `strokeWeight`, `opacity`, `blendingMode`, `appearance`,
`shapeType`, `graphicStyle` and `appearanceAttribute` compare the other objects with the first selected object, in
selection order, that these commands could select. A selected object that is hidden or locked, inside a hidden or
locked group or layer, on a template layer, or outside the isolated group is passed over. When the selection holds no other
object, they return the error "nothing selected".
## Shaper Groups
`shaper.releaseSource {source, group?}` takes the original shape `source` out of its Shaper Group: the shape becomes a
@@ -1603,7 +1615,7 @@ already walks Image Trace. Short agent entry points:
a symbol with guides keeps its corners at their size, and `symbol.breakLink` gives its art as it draws; dragging a
guide on the canvas in symbol editing mode sends `nineSliceGuides` in one undo step; `type` is
Symbol Type, `static` by default for the command and `dynamic` by default in the Symbol Options dialog),
`symbol.place {name?, x?, y?}`, `symbol.breakLink {ids?}`, `symbol.replace`,
`symbol.place {name?, x?, y?}`, `symbol.breakLink {ids?}` (the art keeps the instance's opacity mask), `symbol.replace`,
`symbol.edit {id?, ids?, name?, x?, y?, artboard?, viewCenter?}` (symbol editing mode: the symbol's art on a temporary
isolated layer, where the instance `id` shows it or centered on (x, y), by default on the center of artboard
`artboard`, the active one the app passes, or in a document without artboards on `viewCenter`, the view's center the
@@ -1805,8 +1817,8 @@ already walks Image Trace. Short agent entry points:
not restore a deleted set.
`ui.actionSet.duplicate {set?: index, action?: index}` copies the action at `action` in set `set` to right after it
in the same set, and returns `{set, action}`; without `action` it copies the set with all its actions to right after
it, by default the current set, and returns `{set}`. The copy is named after the original with " copy" added, a
copied set gets an id of its own, and the copy is selected. An index past the last set or action is an error. The
it, and returns `{set}`. In both cases `set` defaults to the current set. The copy is named after the original with
" copy" added, a copied set gets an id of its own, and the copy is selected. An index past the last set or action is an error. The
panel menu's Duplicate runs this for the selected action, else the current set.
- Image Trace: `imageTrace.presets`, `imageTrace.make {id?, preset?, params?, view?}` (or `imageTrace.makeAndExpand`),
`imageTrace.setView`, `imageTrace.expand`, `imageTrace.release`. A key inside `params` that is not one of the trace
@@ -2404,6 +2416,8 @@ dash?, dashOffset?, alignDashes?}}`. `text.setStyle {ids?, stroke?, strokeWidth?
and weight of the characters of each object given (without `start` and `end`: all of its text), with its other keys in
one undo step; `strokeWidth` is in document points, as `stroke.set` gives the weight of scaled type. A `stroke` color
without `strokeWidth` gives characters that have no stroke weight 1 pt, as `paint.setStroke` does.
`text.setRangeStyle` does the same for its range, and its `strokeWidth` and `strokeOptions` lengths are in document
points too. The 1 pt default of both commands is in the type's own units, so on type scaled to 200% it draws 2 pt wide.
`align: "inside"|"outside"` keeps the characters' stroke on that side of the
glyph outlines, drawn at twice the weight and clipped, while the type stays editable (beyond Illustrator); SVG export
writes the text live and that stroke as glyph outlines over it. `inspect_document` reports each object's stroke as `strokeOptions` (type: its first
@@ -6846,7 +6860,8 @@ there is no step. → `{symbols: [name…], ids: [instance id…], skipped: [{la
Type Tools is a built-in add-on for type: its command is `addon.type.textTweaker`, and in the desktop app it lives in
Object › Plug-ins › Type Tools (#4924). Text Tweaker gives one character attribute to the characters letter by letter,
from a start value to an end value along a shape, as one undo step; the text stays live and editable. Each value is set
as `text.setRangeStyle` sets it (the Character panel), in its units and ranges, so on scaled type it is the size shown.
as `text.setRangeStyle` sets it (the Character panel), in its units and ranges ([formatting type](https://helpx.adobe.com/illustrator/using/formatting-type.html)), so on scaled
type it is the size shown.
It changes the range of `id` (`start`/`end` bytes), else all the text of the type objects in `ids`, else the text the
Type tool has selected (at a caret: all of that object's text), else all the text of every selected type object; each
object gets its own ramp, and at most 100000 characters change in one run. A run that changes nothing makes no step.
+2
View File
@@ -924,6 +924,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
Select › Same › Link Block Series selects every frame of the threads of the selected type; type in no thread selects itself (`select.same.linkBlockSeries`). The menu entry is listed in [Select objects by characteristics](https://helpx.adobe.com/illustrator/desktop/manage-objects/select-objects/select-objects-by-characteristics.html); selecting several threads at once and type in no thread selecting itself are VectorCraft's own behavior.
Select › Same › Text Fill & Stroke Color selects the type whose characters match both the fill and the stroke of the selected type (`select.same.textFillAndStrokeColor`). The menu entry is listed in [Select objects by characteristics](https://helpx.adobe.com/illustrator/desktop/manage-objects/select-objects/select-objects-by-characteristics.html); requiring one run of characters to match both is VectorCraft's own behavior.
The Select › Same font and text color commands take the type to match from the selected type that can be selected: type that is hidden or locked, on a template layer, or outside the isolated group is passed over. This is VectorCraft's own behavior.
Select › Same › Fill Color, Stroke Color, Fill & Stroke, Stroke Weight, Opacity, Blending Mode, Appearance, Shape, Graphic Style and Appearance Attribute compare the other objects with the first selected object that can be selected: an object that is hidden or locked, on a template layer, or outside the isolated group is passed over. This is VectorCraft's own behavior.
Object › Flatten Transparency takes a frame of threaded text out of the thread in the same way when Convert All Text to Outlines outlines it or when the frame becomes flat regions or an image, and the other frames keep the text they hold.
New Symbol and Redefine Symbol on a frame of threaded text take it out of the thread with the text it shows, which the symbol's art holds, and the other frames keep the text they hold. When the last frame goes into the symbol, the text it has no room for goes after the text of the last frame that stays. When every frame of a thread goes into the symbol, the copy of the last frame keeps all its text.
With Optical Margin Alignment on, or Roman Hanging Punctuation on in the first paragraph, the glyphs at the ends of left-to-right type on a path hang outside its brackets, measured along the path. The type hangs at the start bracket when its line is placed there, hangs at the end bracket when the line is placed there, and hangs at both when it is centered. Glyphs that hang past the end bracket do not make the type overflow, and a hang stops at an open path's end. SVG export writes such type as outlines.
@@ -1022,6 +1023,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Recolor Artwork layout (#3416):** the Edit chip list uses the modal scroll helper and its actual remaining height; the tab's minimum-height space scrolls with the chips. `tests_recolor` covers 5/20/200/400 colors, window resizing, 100/125/150/200% UI scaling, scrolling to and editing the final chip, Preview, Cancel, Confirm and Undo, alongside the existing #1733 drag regressions. Scores and estimates unchanged.
- **Recolor Artwork Edit displays:** buttons beside the Edit tab's wheel show the colors on a smooth color wheel, a segmented color wheel or as color bars ([Edit colors using Recolor Artwork](https://helpx.adobe.com/illustrator/desktop/manage-colors/modify-colors/edit-colors-using-edit-colors-dialog.html)). The segmented wheel draws 24 hues by 5 saturations as solid patches, and its markers move freely as on the smooth wheel. The bars show one solid bar per recolored color in row order: a click selects a bar for the color sliders, and a drag moves its row without changing a color ([Advanced recolor options overview](https://helpx.adobe.com/illustrator/desktop/manage-colors/learn-color-basics/edit-colors-dialog-overview.html)). The dialog opens on the smooth wheel; `display` (`smooth`, `segmented`, `bars`) is the dialog field. `tests_recolor` covers switching, the dialog height, segmented dragging and Brightness, and bar selection, editing, reordering, scrolling, Preview, Cancel and OK. Scores and estimates unchanged.
- **Edit Colors parameters:** `edit.colors.saturate` needs a number `intensity`, the value from -100% to 100% that Saturate takes ([Adjusting colors](https://web.archive.org/web/20230128232631/https://helpx.adobe.com/illustrator/using/adjusting-colors.html)), and answers "missing number `intensity`" without one; a number outside the range counts as its nearest end. `edit.colors.adjustBalance` refuses a channel and `color.harmony` refuses `steps` or `amount` that is not a number; both read `null` as a value left out. `tests::editcolors` and `tests::colorguide` cover them.
- **Working RGB numeric controls:** the Color panel and Color Picker read RGB, HSB, hex and Web Safe RGB values in the active working RGB space. Editing one RGB channel preserves the other native channels, including colors outside display sRGB. Regressions cover sRGB, Display P3, Wide Gamut RGB and ProPhoto RGB, picker initialization, native numeric round trips, and a slider click with one Undo step. Scores and estimates unchanged.
- **Color Picker layout (#2008):** a centered draggable header, equal-width actions above aligned HSB/RGB/Hex and Lab/CMYK fields, compact field and slider, and warnings below the preview. No horizontal scrolling; narrow windows stack the sections with vertical scrolling. Tests in `dialogs/color_picker.rs` cover actual pointer/key input, typed values before OK/Enter, Cancel/Escape rollback, one Undo step, mode-size stability, warnings and layout at two UI scales. The screen sampler sits above the field and the upstream two-grid Tab order is retained. AccessKit regressions check the action names and each channel slider's displayed value, units and range. German and enlarged English UI renders were reviewed locally; no reference images are shipped. This is a layout improvement, with no change to scores or remaining-hour estimates.
- **Eyedropper sources (#1251):** ordinary and Shift clicks, Shift+Alt appearance append, raster pixels and gradient-stop sampling can read visible locked/template art and sources outside isolation. Sampling leaves the source and selection intact; Alt reverse still uses editable hits, and hidden art stays excluded. Engine/tool/hit regressions cover the editing boundary, color models and one-step Undo/Redo. The general object/image workflow is described in [public HelpX](https://helpx.adobe.com/illustrator/using/tool-techniques/eyedropper-tool.html); the lock/isolation expectations come from the user report and these regressions, without a new full-parity measurement.
- **Eyedropper colors in a CMYK document (#2276):** a CMYK image (a four-component JPEG, or an 8- or 16-bit chunky CMYK TIFF) gives its ink amounts as they are, and an RGB image's pixel or an RGB color given to `paint.setFill`, `paint.setStroke`, `paint.sampleColor` or `swatch.new {color}` (hex input and gradient stops included) is separated through the color settings' CMYK working space and intent, as Document Color Mode converts it. With the default Generic CMYK, RGB 83/170/223 separates to C59 M12 Y3 K0, where the profile-free formula gave C63 M24 Y0 K13.