Merge pull request #7175 from dotCooCoo/fix/graph-series-group-appearance

Graphs: Appearance commands and graphic styles given a series group edit its marks
This commit is contained in:
BFlat
2026-11-14 00:51:00 +07:00
committed by GitHub
8 changed files with 612 additions and 59 deletions
+153 -38
View File
@@ -19,7 +19,10 @@
//! shape that a Convert to Shape or Outline Object effect on them gives them, below or above the
//! image as its Contents row says; new ones go right below that row.
use std::{collections::HashMap, sync::Arc};
use std::{
collections::{HashMap, HashSet},
sync::Arc,
};
use serde_json::{Value, json};
use vectorcraft_color::{BlendMode, Paint};
@@ -45,7 +48,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add New Fill",
["Window", "Appearance"],
Some("Cmd+/"),
"{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack, on an image right below its Contents row, with its top fill's paint (type without one: its characters' fill; else the default fill). With a fill row active (appearance.setActiveItem), no `ids`, no `id` and no `target: \"contents\"`, the new fill takes that row's paint, goes right above it, and becomes the active row; appearance.duplicateItem copies a whole row. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
"{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack, on an image right below its Contents row, with its top fill's paint (type without one: its characters' fill; else the default fill). With a fill row active (appearance.setActiveItem), no `ids`, no `id` and no `target: \"contents\"`, the new fill takes that row's paint, goes right above it, and becomes the active row; appearance.duplicateItem copies a whole row. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
has_doc,
|s, p| add_item(s, p, true)
),
@@ -54,7 +57,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add New Stroke",
["Window", "Appearance"],
Some("Cmd+Alt+/"),
"{ids?|id?, target?} add a stroke on top of each selected object's own stack, on an image right below its Contents row, with its top stroke's paint (else the default stroke) and that stroke's weight when it has paint, at least 1 pt. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
"{ids?|id?, target?} add a stroke on top of each selected object's own stack, on an image right below its Contents row, with its top stroke's paint (else the default stroke) and that stroke's weight when it has paint, at least 1 pt. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
has_doc,
|s, p| add_item(s, p, false)
),
@@ -63,7 +66,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Clear Appearance",
["Window", "Appearance"],
None,
"{ids?|id?, target?} leave each object one empty fill and stroke (None; a group none of its own) and reset its opacity and blend mode. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md)",
"{ids?|id?, target?} leave each object one empty fill and stroke (None; a group none of its own) and reset its opacity and blend mode. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md)",
has_doc,
clear_appearance
),
@@ -72,7 +75,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Reduce to Basic Appearance",
["Window", "Appearance"],
None,
"{ids?|id?, target?} keep only one visible fill and stroke (without ids: the Appearance panel's active row in place of the topmost one of its kind; else the topmost), without their own opacity, blend mode or effects (a group keeps no rows it lacked), and drop the object's effects. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
"{ids?|id?, target?} keep only one visible fill and stroke (without ids: the Appearance panel's active row in place of the topmost one of its kind; else the topmost), without their own opacity, blend mode or effects (a group keeps no rows it lacked), and drop the object's effects. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
has_doc,
reduce_basic
),
@@ -81,7 +84,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Appearance Item",
[],
None,
"{index: paint-order item index, ids?|id? (default: the selection), target?: \"object\"|\"contents\", opacity?: 0..100, blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill), tint?: 0..100, keepModel?: bool (both as paint.setFill), aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} edit one fill/stroke of each target object's own appearance stack. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
"{index: paint-order item index, ids?|id? (default: the selection), target?: \"object\"|\"contents\", opacity?: 0..100, blend?: name, visible?: bool, weight?: pt (strokes), color?|none?|swatch?|gradient? (as paint.setFill), tint?: 0..100, keepModel?: bool (both as paint.setFill), aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} edit one fill/stroke of each target object's own appearance stack. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
has_doc,
set_item
),
@@ -90,7 +93,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Remove Item",
[],
None,
"{index | indices: [..] (paint-order item indices), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} remove those fills/strokes from each target object's own stack. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
"{index | indices: [..] (paint-order item indices), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} remove those fills/strokes from each target object's own stack. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
has_doc,
remove_item
),
@@ -108,7 +111,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Duplicate Item",
["Window", "Appearance"],
None,
"{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (with several, of the lowest copy: they land together in their order; default: each right above its item; not with aligned rows of a mixed selection), contents?: count (with `to`: how many items paint below the Contents (Characters) row afterwards; default: it stays between the same items), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} copy fills/strokes with their effects, as Alt-dragging rows places them. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
"{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (with several, of the lowest copy: they land together in their order; default: each right above its item; not with aligned rows of a mixed selection), contents?: count (with `to`: how many items paint below the Contents (Characters) row afterwards; default: it stays between the same items), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} copy fills/strokes with their effects, as Alt-dragging rows places them. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
has_doc,
duplicate_item
),
@@ -117,7 +120,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Reorder Appearance Item",
[],
None,
"{from: paint-order item index | \"contents\", indices?: [..] (several items instead of from: they move as one block in their order, the lowest landing at `to`; a block landing next to the Contents row keeps its lowest item's side), to, contents?: count, ids?|id?, target?, aligned?: bool (refused when the targets' appearances differ)} move fill/stroke `from` to paint-order index `to`; on a group, layer or type the Contents (Characters) row stays between the same items (a moved item landing next to it keeps its side) unless `contents` says how many items paint below it afterwards. from \"contents\": move that row so `to` items paint below the members (characters) and the rest above. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {index: where the item landed | contents: the row's slot}",
"{from: paint-order item index | \"contents\", indices?: [..] (several items instead of from: they move as one block in their order, the lowest landing at `to`; a block landing next to the Contents row keeps its lowest item's side), to, contents?: count, ids?|id?, target?, aligned?: bool (refused when the targets' appearances differ)} move fill/stroke `from` to paint-order index `to`; on a group, layer or type the Contents (Characters) row stays between the same items (a moved item landing next to it keeps its side) unless `contents` says how many items paint below it afterwards. from \"contents\": move that row so `to` items paint below the members (characters) and the rest above. On a path directly in a graph series (a series group's id stands for those paths, except with from \"contents\", which moves the group's own row), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {index: where the item landed | contents: the row's slot}",
has_doc,
move_item
),
@@ -144,7 +147,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Show All Hidden Attributes",
["Window", "Appearance"],
None,
"{ids?|id?, target?} make every hidden fill, stroke and effect (the object's and each item's) of each selected object visible again; errors when nothing is hidden. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {ids}",
"{ids?|id?, target?} make every hidden fill, stroke and effect (the object's and each item's) of each selected object visible again; errors when nothing is hidden. On a path directly in a graph series (a series group's id stands for those paths and the group), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {ids}",
has_doc,
show_all_hidden
),
@@ -162,7 +165,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Move Appearance",
[],
None,
"{source: id, target: id, copy?: bool} give `target` (a layer, group or object) the appearance (fills, strokes, effects) and transparency (opacity, blend mode, isolation, knockout) of `source`, as dragging a target circle onto another in the Layers panel does; the source is left with a cleared appearance (as appearance.clear) unless `copy` (Alt-drag). Opacity masks stay; placed gradients keep their place relative to each object's bounds. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md) → {source, target}",
"{source: id, target: id, copy?: bool} give `target` (a layer, group or object) the appearance (fills, strokes, effects) and transparency (opacity, blend mode, isolation, knockout) of `source`, as dragging a target circle onto another in the Layers panel does; the source is left with a cleared appearance (as appearance.clear) unless `copy` (Alt-drag). Opacity masks stay; placed gradients keep their place relative to each object's bounds. On a path directly in a graph series (a series group's id stands for those paths; as `source`, when the group has no effect, opacity, blend mode, isolation, knockout or style link of its own, the topmost one is read and a move clears them all; as `target` each takes the appearance and the group is cleared, a `source` among them keeps its look, and a `source` that is the group's only path is an error), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md) → {source, target}",
has_doc,
transfer
),
@@ -416,9 +419,87 @@ pub(crate) fn appearance_targets(s: &Session, p: &Value) -> Result<Vec<NodeId>>
if targets_contents(p)? { leaf_targets(s, &roots) } else { Ok(roots) }
}
/// [`appearance_targets`] of `cmd`, which needs at least one.
fn require_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<NodeId>> {
/// `ids` with each graph series group that has no fill or stroke row of its own replaced by the paths directly inside
/// it ([`super::graph::series_group_marks`]). A series group with rows, as a document saved earlier can hold, stays:
/// the Appearance panel lists those rows and edits them by index.
pub(crate) fn rowless_series_marks(d: &Document, ids: &[NodeId]) -> Vec<NodeId> {
super::graph::series_group_marks_where(d, ids, |group| group.appearance.items.is_empty())
}
/// Whether series group `group` holds nothing of its own, as the graph draws it: no fill or stroke row, no effect, the
/// default opacity, blend mode, isolation and knockout, and no graphic style link.
fn holds_no_look(group: &Node) -> bool {
let ap = &group.appearance;
ap.items.is_empty()
&& ap.effects.is_empty()
&& group.opacity == 1.0
&& group.blend == BlendMode::Normal
&& !group.isolate
&& group.knockout == Default::default()
&& !group.knockout_shape
&& group.extras().graphic_style.is_none()
}
/// The objects whose appearance a command reads for `id`: a graph series group that holds nothing of its own
/// ([`holds_no_look`]) gives the paths directly inside it, backmost first. A series group with an effect, an opacity or
/// another attribute of its own is read itself, with that attribute.
fn look_holders(d: &Document, id: NodeId) -> Vec<NodeId> {
super::graph::series_group_marks_where(d, &[id], holds_no_look)
}
/// The object whose appearance a command reads for `n`: a graph series group that holds nothing of its own stands for
/// the topmost path directly inside it ([`look_holders`]).
pub(crate) fn look_holder<'a>(d: &'a Document, n: &'a Node) -> &'a Node {
look_holders(d, n.id).last().and_then(|id| d.node(*id)).unwrap_or(n)
}
/// Objects whose fill and stroke rows a command edits: the [`appearance_targets`], with a graph series group standing
/// for the paths directly inside it ([`rowless_series_marks`]), so the graph can store the edit.
pub(crate) fn row_targets(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
let ids = appearance_targets(s, p)?;
Ok(rowless_series_marks(&s.doc()?.doc, &ids))
}
/// Objects whose whole appearance a command edits: the [`row_targets`], then each graph series group they stand for,
/// which can hold effects and a graphic style link of its own.
pub(crate) fn whole_targets(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
let own = appearance_targets(s, p)?;
let mut ids = rowless_series_marks(&s.doc()?.doc, &own);
let groups = stood_for(&own, &ids);
ids.extend(groups);
Ok(ids)
}
/// The series groups among `own` that `ids` ([`rowless_series_marks`] of `own`) replaced with their paths.
pub(crate) fn stood_for(own: &[NodeId], ids: &[NodeId]) -> Vec<NodeId> {
let kept: HashSet<NodeId> = ids.iter().copied().collect();
own.iter().copied().filter(|id| !kept.contains(id)).collect()
}
/// Clear Appearance on each series group a command gave a whole new appearance through its paths, so the group's own
/// opacity, blend mode, isolation, knockout, effects and style link do not stay on top of it.
pub(crate) fn clear_stood_for(d: &mut Document, groups: &[NodeId]) {
for id in groups {
if let Some(n) = d.node_mut(*id) {
clear(n);
(n.isolate, n.knockout, n.knockout_shape) = (false, Default::default(), false);
n.extras_mut().graphic_style = None;
}
}
}
/// [`whole_targets`] of `cmd`, which needs at least one.
fn require_whole_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<NodeId>> {
let ids = whole_targets(s, p)?;
if ids.is_empty() {
return Err(bad(cmd, "select objects or give ids"));
}
Ok(ids)
}
/// [`row_targets`] of `cmd`, which needs at least one.
fn require_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<NodeId>> {
let ids = row_targets(s, p)?;
if ids.is_empty() {
return Err(bad(cmd, "select objects or give ids"));
}
@@ -616,7 +697,7 @@ fn add_item(s: &mut Session, p: &Value, fill: bool) -> Result<Value> {
}
fn clear_appearance(s: &mut Session, p: &Value) -> Result<Value> {
let ids = require_targets(s, p, "appearance.clear")?;
let ids = require_whole_targets(s, p, "appearance.clear")?;
// When every object is cleared already, the document and its undo history stay as they are.
let before = s.doc()?.doc.clone();
super::overprint::edit_counted(s, "Clear Appearance", |d| {
@@ -732,35 +813,64 @@ fn transfer(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, "`source` and `target` are the same object"));
}
let copy = switch_or(p, "copy", false, C)?;
// A graph series group stands for the paths directly inside it: as the source, the topmost of them is read and a
// move clears them all; as the target, each of them other than a source takes the appearance.
let sources = look_holders(&s.doc()?.doc, source);
let read = sources.last().copied().unwrap_or(source);
let from: Vec<NodeId> = sources.iter().copied().chain(stood_for(&[source], &sources)).collect();
let marks = rowless_series_marks(&s.doc()?.doc, &[target]);
let stands = !marks.contains(&target);
let within: HashSet<NodeId> = from.iter().copied().collect();
let mut targets: Vec<NodeId> = marks.iter().copied().filter(|id| !within.contains(id)).collect();
if targets.is_empty() {
if stands {
return Err(bad(C, "`source` is the only path in series group `target`"));
}
targets.push(target);
}
// A source that is one of the target group's paths keeps its look: the group stands for it too.
let part: HashSet<NodeId> = if stands { marks.into_iter().collect() } else { HashSet::new() };
let from: Vec<NodeId> = stood_for(&from, &targets);
let cleared: Vec<NodeId> = if copy { vec![] } else { from.iter().copied().filter(|id| !part.contains(id)).collect() };
// The group its paths stand for loses its own opacity, blend mode and effects, which would stay on top of them.
let group: Vec<NodeId> = if stands { vec![target] } else { vec![] };
// When both objects come out as they were, the document and its undo history stay as they are.
let before = s.doc()?.doc.clone();
super::overprint::edit_counted(s, if copy { "Copy Appearance" } else { "Move Appearance" }, |d| {
let src = d.node(source).cloned().ok_or(EngineError::NoNode(source))?;
let to = d.node_mut(target).ok_or(EngineError::NoNode(target))?;
let mut ap = src.appearance.clone();
if let (Some(f), Some(t)) = (src.geometric_bounds(), to.geometric_bounds()) {
ap.rebase_gradients(f, t);
d.node(source).ok_or(EngineError::NoNode(source))?;
let src = d.node(read).cloned().ok_or(EngineError::NoNode(read))?;
for id in &targets {
let to = d.node_mut(*id).ok_or(EngineError::NoNode(*id))?;
let mut ap = src.appearance.clone();
if let (Some(f), Some(t)) = (src.geometric_bounds(), to.geometric_bounds()) {
ap.rebase_gradients(f, t);
}
// Only groups, layers, type and images have a Contents (Characters) row to keep a slot for.
if to.contents_label().is_none() {
ap.contents_index = None;
}
to.appearance = ap;
(to.opacity, to.blend, to.isolate, to.knockout, to.knockout_shape) =
(src.opacity, src.blend, src.isolate, src.knockout, src.knockout_shape);
super::object::snap_if_aligned(to);
}
// Only groups, layers, type and images have a Contents (Characters) row to keep a slot for.
if to.contents_label().is_none() {
ap.contents_index = None;
clear_stood_for(d, &group);
for id in &cleared {
if let Some(n) = d.node_mut(*id) {
clear(n);
(n.isolate, n.knockout, n.knockout_shape) = (false, Default::default(), false);
super::object::snap_if_aligned(n);
}
}
to.appearance = ap;
(to.opacity, to.blend, to.isolate, to.knockout, to.knockout_shape) = (src.opacity, src.blend, src.isolate, src.knockout, src.knockout_shape);
super::object::snap_if_aligned(to);
if !copy && let Some(n) = d.node_mut(source) {
clear(n);
(n.isolate, n.knockout, n.knockout_shape) = (false, Default::default(), false);
super::object::snap_if_aligned(n);
}
let changed = if copy { vec![target] } else { vec![source, target] };
let changed: Vec<NodeId> = cleared.iter().chain(&targets).copied().collect();
let kept = super::graph::capture_series_opacity(d, &both_kinds(&changed));
// The target last: on marks of one series, its paint is the one the series keeps.
// The targets last: on marks of one series, their paint is the one the series keeps.
let mut rows = top_paint_changed(&before, d, &changed);
rows.extend(painted_rows(d, both_kinds(&[target])));
rows.extend(painted_rows(d, both_kinds(&targets)));
let painted = super::graph::capture_series_top_paints(d, &rows);
let object = super::graph::capture_series_object_opacity(d, &changed);
Ok(count_changed(d, &before, &[source, target]) + usize::from(kept || painted || object))
let all: Vec<NodeId> = from.iter().chain(&targets).chain(&group).copied().collect();
Ok(count_changed(d, &before, &all) + usize::from(kept || painted || object))
})?;
s.active_appearance_item = None;
Ok(json!({ "source": source.0, "target": target.0 }))
@@ -772,7 +882,7 @@ fn is_group(n: &Node) -> bool {
}
fn reduce_basic(s: &mut Session, p: &Value) -> Result<Value> {
let ids = require_targets(s, p, "appearance.reduceToBasic")?;
let ids = require_whole_targets(s, p, "appearance.reduceToBasic")?;
// When every object comes out as it was, the document and its undo history stay as they are.
let before = s.doc()?.doc.clone();
// The Appearance panel's active row (a row of the first selected object's stack, and in each
@@ -828,7 +938,7 @@ fn set_item(s: &mut Session, p: &Value) -> Result<Value> {
check_numbers(p, &["weight", "opacity"], "appearance.setItem")?;
check_switches(p, &["visible"], "appearance.setItem")?;
let paint = paint_from(s, p)?;
let ids = appearance_targets(s, p)?;
let ids = row_targets(s, p)?;
if ids.is_empty() {
return Err(bad("appearance.setItem", "select objects or give ids"));
}
@@ -1039,7 +1149,7 @@ fn duplicate_item(s: &mut Session, p: &Value) -> Result<Value> {
/// `appearance.showAllHidden`: shows every hidden fill, stroke and effect of the selected objects.
fn show_all_hidden(s: &mut Session, p: &Value) -> Result<Value> {
let roots = require_targets(s, p, "appearance.showAllHidden")?;
let roots = require_whole_targets(s, p, "appearance.showAllHidden")?;
let before = s.doc()?.doc.clone();
let ids = s.edit("Show All Hidden Attributes", |d, _| {
let shown: Vec<NodeId> = roots
@@ -1069,11 +1179,16 @@ fn show_all_hidden(s: &mut Session, p: &Value) -> Result<Value> {
fn move_item(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "appearance.moveItem";
let to = index_param(p, "to", C)?;
let ids = require_targets(s, p, C)?;
let aligned = aligned(p, C)?;
if p.get("from").and_then(Value::as_str) == Some("contents") {
// A graph series group's own Contents row, not its paths, which have none.
let ids = appearance_targets(s, p)?;
if ids.is_empty() {
return Err(bad(C, "select objects or give ids"));
}
return move_contents(s, &ids, to);
}
let ids = require_targets(s, p, C)?;
// Where an aligned row would go in each of several different stacks isn't one place.
if aligned && appearances_differ(&s.doc()?.doc, &ids) {
return Err(bad(C, "rows of objects whose appearances differ can't be reordered together"));
+2 -1
View File
@@ -46,7 +46,8 @@ use generate::generate;
use series::default_series_color;
pub(crate) use series::{
capture_label_anti_alias, capture_label_paragraphs, capture_label_styles, capture_series_object_opacity, capture_series_opacity,
capture_series_paints, capture_series_top_paints, paint_targets, series_object_opacity_differs, shown_paint,
capture_series_paints, capture_series_top_paints, paint_targets, series_group_marks, series_group_marks_where, series_object_opacity_differs,
shown_paint,
};
#[cfg(test)]
+38 -1
View File
@@ -79,6 +79,11 @@ struct SeriesRef {
index: usize,
}
/// How many series groups `spec`'s graph has: a group whose index is past it (the index comes from the file) is not one.
fn series_count(spec: &GraphSpec) -> usize {
spec.rows.iter().map(Vec::len).max().unwrap_or(0).clamp(1, vectorcraft_doc::MAX_GRAPH_SERIES)
}
/// Every node in a graph's series groups (the groups too), found in one pass over the document.
/// Axes, the legend and groups whose index is past the graph's series (the index comes from the
/// file) are left out.
@@ -86,7 +91,7 @@ fn series_members(doc: &Document) -> HashMap<NodeId, SeriesRef> {
let mut out = HashMap::new();
doc.walk(|n| {
let Some(spec) = n.extras().graph.as_deref() else { return };
let count = spec.rows.iter().map(Vec::len).max().unwrap_or(0).clamp(1, vectorcraft_doc::MAX_GRAPH_SERIES);
let count = series_count(spec);
for group in n.children().into_iter().flatten() {
let Some(index) = group.extras().series_index.and_then(|i| usize::try_from(i).ok()).filter(|i| *i < count) else { continue };
let r = SeriesRef { graph: n.id, group: group.id, index };
@@ -124,6 +129,38 @@ pub(crate) fn paint_targets(doc: &Document, ids: &[NodeId]) -> Vec<NodeId> {
out
}
/// `ids` with each graph series group replaced by the paths directly inside it, as [`paint_targets`] reads a series
/// group: the graph draws its series groups again with no rows of their own. A series group with no such path, other
/// ids and the art of a graph design pass through, and the order of `ids` is kept.
pub(crate) fn series_group_marks(doc: &Document, ids: &[NodeId]) -> Vec<NodeId> {
series_group_marks_where(doc, ids, |_| true)
}
/// [`series_group_marks`] for the series groups `stands` accepts; the others pass through.
pub(crate) fn series_group_marks_where(doc: &Document, ids: &[NodeId], stands: impl Fn(&Node) -> bool) -> Vec<NodeId> {
// A series group is a child of a graph with one of the graph's series indexes ([`series_members`]). Each id is
// looked up through its parent, with no pass over the document: the panels call this while they draw.
let is_series_group = |n: &Node| {
let Some(index) = n.extras().series_index.and_then(|i| usize::try_from(i).ok()) else { return false };
let graph = doc.parent_of(n.id).and_then(|parent| doc.node(parent)).and_then(|parent| parent.extras().graph.as_deref());
graph.is_some_and(|spec| index < series_count(spec))
};
let mut seen = std::collections::HashSet::new();
let mut out = Vec::new();
for id in ids {
match doc.node(*id) {
Some(group) if is_series_group(group) && stands(group) => {
let marks: Vec<NodeId> =
group.children().into_iter().flatten().filter(|m| matches!(m.kind, NodeKind::Path { .. })).map(|m| m.id).collect();
if marks.is_empty() { out.push(*id) } else { out.extend(marks) }
}
_ => out.push(*id),
}
}
out.retain(|id| seen.insert(*id));
out
}
/// Copy the paint just applied to generated series art into that series' fill or stroke. Called
/// inside the same document edit, so the visible paint and future regeneration undo together.
/// Gradients, patterns and swatch links are kept as [`Paint`], not reduced to a solid colour.
+358
View File
@@ -1804,6 +1804,364 @@ fn redefining_a_graphic_style_from_a_series_mark_keeps_the_paint_another_mark_st
assert_eq!(spec(&s, id).series_paints, was);
}
/// Series 0's group, and whether it is as the graph draws it: the group has no rows of its own and each path directly
/// inside it has rows.
fn series_0_group(s: &Session, id: NodeId) -> (NodeId, bool) {
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let g = series(&n, 0);
let drawn = g.appearance.items.is_empty() && g.children().unwrap().iter().all(|m| !m.appearance.items.is_empty());
(g.id, drawn)
}
/// `appearance.setItem` given a series group's id edits the paths directly inside it, so the graph stores the color and
/// opacity and every mark has them after the graph is drawn again. The group keeps no rows of its own.
#[test]
fn an_appearance_item_set_on_a_series_group_edits_its_marks() {
let (mut s, id, _, marks) = column_marks();
let (group, drawn) = series_0_group(&s, id);
assert!(drawn);
let was = spec(&s, id).series_paints;
let row = top_row(&s, marks[0], true);
s.execute("appearance.setItem", &json!({"ids": [group.0], "index": row, "color": "#0000ff", "opacity": 40})).unwrap();
assert!(series_0_group(&s, id).1, "the group has no rows of its own");
let blue = s.doc().unwrap().doc.node(marks[0]).unwrap().appearance.fill_paint();
let kept = spec(&s, id).series_paints[0].clone();
assert_eq!((kept.fill.clone(), kept.fill_opacity), (Some(blue.clone()), Some(0.4)));
let (stroke, weight) = {
let m = s.doc().unwrap().doc.node(marks[0]).unwrap().clone();
(m.appearance.stroke_paint(), m.appearance.stroke_width())
};
series_0_is(&mut s, id, (&blue, 0.4, &stroke, weight), &was);
}
/// The other Appearance commands given a series group's id, picked with Group Selection or given in `ids`, edit the
/// paths directly inside it, and the graph stores what they change: Add New Fill, Clear Appearance, Move Appearance onto
/// the group, and an opacity set on a row with `transparency.set`.
#[test]
fn appearance_commands_given_a_series_group_edit_its_marks() {
let (mut s, id, marks) = stored_red_stroke();
let (group, _) = series_0_group(&s, id);
s.execute("select.set", &json!({"ids": [group.0]})).unwrap();
s.execute("appearance.addFill", &json!({})).unwrap();
assert!(series_0_group(&s, id).1, "Add New Fill on the selected group");
let rows = s.doc().unwrap().doc.node(marks[0]).unwrap().appearance.items.iter().filter(|it| it.is_fill()).count();
assert_eq!(rows, 2, "each mark has a new fill row");
s.execute("edit.undo", &json!({})).unwrap();
let row = top_row(&s, marks[0], true);
s.execute("transparency.set", &json!({"ids": [group.0], "item": row, "opacity": 30})).unwrap();
assert!(series_0_group(&s, id).1);
assert_eq!(spec(&s, id).series_paints[0].fill_opacity, Some(0.3));
s.execute("edit.undo", &json!({})).unwrap();
s.execute("appearance.clear", &json!({"ids": [group.0]})).unwrap();
assert_eq!(spec(&s, id).series_paints[0].fill, Some(Paint::None));
s.execute("edit.undo", &json!({})).unwrap();
let (rect, blue) = blue_style(&mut s, "Blue", false);
let was = spec(&s, id).series_paints;
s.execute("object.setProps", &json!({"ids": [group.0], "opacity": 50})).unwrap();
s.execute("appearance.transfer", &json!({"source": rect.0, "target": group.0, "copy": true})).unwrap();
assert!(series_0_group(&s, id).1);
assert_eq!(s.doc().unwrap().doc.node(group).unwrap().opacity, 1.0, "the appearance replaces the group's own opacity");
let kept = spec(&s, id).series_paints[0].clone();
assert_eq!((kept.fill.clone(), kept.fill_opacity, kept.stroke), (Some(blue.clone()), Some(0.5), Some(Paint::None)));
series_0_is(&mut s, id, (&blue, 0.5, &Paint::None, 0.0), &was);
}
/// Moving the Contents row of a series group as the graph draws it, with no rows of its own, moves the group's own
/// row: the paths inside it have none.
#[test]
fn moving_a_series_groups_contents_row_moves_its_own_row() {
let (mut s, id, _, _) = column_marks();
let (group, drawn) = series_0_group(&s, id);
assert!(drawn);
let out = s.execute("appearance.moveItem", &json!({"ids": [group.0], "from": "contents", "to": 0})).unwrap();
assert_eq!(out["contents"], json!(0));
}
/// Add New Stroke, Duplicate Item, Reorder Appearance Item and Remove Item given a series group's id edit the rows of
/// the paths directly inside it, and the group keeps no rows of its own.
#[test]
fn row_commands_given_a_series_group_edit_its_marks_rows() {
let (mut s, id, _, marks) = column_marks();
let (group, _) = series_0_group(&s, id);
let count = |s: &Session, fill: bool| -> Vec<usize> {
let d = &s.doc().unwrap().doc;
marks.iter().map(|m| d.node(*m).unwrap().appearance.items.iter().filter(|it| it.is_fill() == fill).count()).collect()
};
let (fills, strokes) = (count(&s, true), count(&s, false));
s.execute("appearance.addStroke", &json!({"ids": [group.0]})).unwrap();
assert_eq!(count(&s, false), strokes.iter().map(|n| n + 1).collect::<Vec<_>>(), "each mark has a new stroke row");
let fill = top_row(&s, marks[0], true);
s.execute("appearance.duplicateItem", &json!({"ids": [group.0], "index": fill})).unwrap();
assert_eq!(count(&s, true), fills.iter().map(|n| n + 1).collect::<Vec<_>>(), "each mark has a copy of its fill row");
let rows = s.doc().unwrap().doc.node(marks[0]).unwrap().appearance.items.len();
let out = s.execute("appearance.moveItem", &json!({"ids": [group.0], "from": 0, "to": rows - 1})).unwrap();
assert_eq!(out["index"], json!(rows - 1));
s.execute("appearance.removeItem", &json!({"ids": [group.0], "index": top_row(&s, marks[0], true)})).unwrap();
assert_eq!(count(&s, true), fills);
assert!(series_0_group(&s, id).1, "the group has no rows of its own");
}
/// Reduce to Basic Appearance given a series group's id reduces the paths directly inside it and drops an effect the
/// group holds.
#[test]
fn reducing_a_series_group_reduces_its_marks_and_drops_its_own_effect() {
let (mut s, id, _, marks) = column_marks();
let (group, _) = series_0_group(&s, id);
s.execute("appearance.addFill", &json!({"ids": [group.0]})).unwrap();
s.execute("effect.apply", &json!({"ids": [group.0], "effect": "distort.twist"})).unwrap();
s.execute("appearance.reduceToBasic", &json!({"ids": [group.0]})).unwrap();
let d = &s.doc().unwrap().doc;
assert!(d.node(group).unwrap().appearance.effects.is_empty(), "the group's effect is dropped");
assert!(marks.iter().all(|m| d.node(*m).unwrap().appearance.items.iter().filter(|it| it.is_fill()).count() == 1));
assert!(series_0_group(&s, id).1);
}
/// A graphic style added on top (Alt-click) given a series group's id adds its rows to the paths directly inside the
/// group and unlinks a group that held a style link.
#[test]
fn a_graphic_style_added_to_a_series_group_adds_rows_to_its_marks_and_unlinks_the_group() {
let (mut s, id, _, marks) = column_marks();
let (group, _) = series_0_group(&s, id);
blue_style(&mut s, "Blue", false);
let style = s.doc().unwrap().doc.graphic_style("Blue").unwrap().id;
s.edit("Link", |d, _| {
d.node_mut(group).unwrap().extras_mut().graphic_style = Some(style);
Ok(())
})
.unwrap();
let fills = |s: &Session, m: NodeId| s.doc().unwrap().doc.node(m).unwrap().appearance.items.iter().filter(|it| it.is_fill()).count();
let was = fills(&s, marks[0]);
s.execute("graphicStyle.apply", &json!({"name": "Blue", "ids": [group.0], "add": true})).unwrap();
assert!(marks.iter().all(|m| fills(&s, *m) == was + 1), "each mark has the style's fill on top");
assert!(series_0_group(&s, id).1, "the group takes no rows of the style");
assert_eq!(s.doc().unwrap().doc.node(group).unwrap().extras().graphic_style, None, "the group is unlinked");
}
/// Move Appearance from one of a series group's paths onto the group gives the other paths its look and leaves that
/// path as it was. When the path is the only one directly in the group, the command is refused and the group takes no
/// rows.
#[test]
fn moving_an_appearance_from_a_mark_onto_its_own_series_group_keeps_the_mark() {
let (mut s, id, _, marks) = column_marks();
let (group, _) = series_0_group(&s, id);
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": top_row(&s, marks[0], true), "color": "#00ff00"})).unwrap();
let green = s.doc().unwrap().doc.node(marks[0]).unwrap().appearance.fill_paint();
s.execute("appearance.transfer", &json!({"source": marks[0].0, "target": group.0})).unwrap();
let d = &s.doc().unwrap().doc;
assert!(marks.iter().all(|m| d.node(*m).unwrap().appearance.fill_paint() == green), "every mark, the source too, is green");
assert_eq!(spec(&s, id).series_paints[0].fill, Some(green));
s.edit("One path", |d, _| {
d.children_mut(Some(group)).unwrap().truncate(1);
Ok(())
})
.unwrap();
let only = s.doc().unwrap().doc.node(group).unwrap().children().unwrap()[0].id;
let err = s.execute("appearance.transfer", &json!({"source": only.0, "target": group.0})).unwrap_err();
assert!(err.to_string().contains("only path"), "{err}");
assert!(series_0_group(&s, id).1, "the group has no rows of its own");
}
/// A series group that holds an opacity and an effect of its own is read itself as a source, as any group is: a new
/// graphic style and Move Appearance take the group's opacity and effect, and the group, not a path, is linked.
#[test]
fn a_series_group_with_a_look_of_its_own_is_read_itself_as_a_source() {
let (mut s, id, _, marks) = column_marks();
let (group, _) = series_0_group(&s, id);
s.execute("transparency.set", &json!({"ids": [group.0], "opacity": 50, "item": null})).unwrap();
s.execute("effect.apply", &json!({"ids": [group.0], "effect": "distort.twist"})).unwrap();
s.execute("graphicStyle.new", &json!({"id": group.0, "name": "Series"})).unwrap();
let d = &s.doc().unwrap().doc;
let style = d.graphic_style("Series").unwrap();
assert_eq!((style.opacity, style.appearance.effects.len()), (0.5, 1));
assert_eq!(d.node(group).unwrap().extras().graphic_style, Some(style.id));
assert!(marks.iter().all(|m| d.node(*m).unwrap().extras().graphic_style.is_none()));
let rect = NodeId(s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap());
s.execute("appearance.transfer", &json!({"source": group.0, "target": rect.0, "copy": true})).unwrap();
let r = s.doc().unwrap().doc.node(rect).unwrap().clone();
assert_eq!((r.opacity, r.appearance.effects.len()), (0.5, 1));
}
/// Clear Appearance and Show All Hidden Attributes given a series group's id also reach an effect the group holds, and
/// Break Link to Graphic Style unlinks the paths a style applied to the group linked.
#[test]
fn whole_appearance_commands_given_a_series_group_edit_the_group_and_its_marks() {
let (mut s, id, marks) = stored_red_stroke();
let (group, _) = series_0_group(&s, id);
let effects = |s: &Session| s.doc().unwrap().doc.node(group).unwrap().appearance.effects.clone();
s.execute("effect.apply", &json!({"ids": [group.0], "effect": "distort.twist"})).unwrap();
s.execute("effect.setParams", &json!({"ids": [group.0], "index": 0, "visible": false, "item": null})).unwrap();
assert!(!effects(&s)[0].visible);
let shown = s.execute("appearance.showAllHidden", &json!({"ids": [group.0]})).unwrap();
assert_eq!(shown["ids"], json!([group.0]));
assert!(effects(&s)[0].visible);
s.execute("appearance.clear", &json!({"ids": [group.0]})).unwrap();
assert!(effects(&s).is_empty(), "the group's effect is cleared with its marks' rows");
assert_eq!(spec(&s, id).series_paints[0].fill, Some(Paint::None));
s.execute("edit.undo", &json!({})).unwrap();
blue_style(&mut s, "Blue", false);
s.execute("select.set", &json!({"ids": [group.0]})).unwrap();
s.execute("graphicStyle.apply", &json!({"name": "Blue"})).unwrap();
assert_eq!(s.selection_graphic_style().map(|(g, linked)| (g.name.clone(), linked)), Some(("Blue".into(), true)));
s.execute("graphicStyle.breakLink", &json!({})).unwrap();
let d = &s.doc().unwrap().doc;
assert!(marks.iter().all(|m| d.node(*m).unwrap().extras().graphic_style.is_none()), "each mark is unlinked");
assert!(s.selection_graphic_style().is_none());
}
/// Redefine Graphic Style on a selected series group whose paths a style links takes the look of the topmost of them.
#[test]
fn redefining_a_graphic_style_from_a_selected_series_group_reads_its_topmost_mark() {
let (mut s, id, marks) = stored_red_stroke();
let (group, _) = series_0_group(&s, id);
blue_style(&mut s, "Blue", false);
s.execute("select.set", &json!({"ids": [group.0]})).unwrap();
s.execute("graphicStyle.apply", &json!({"name": "Blue"})).unwrap();
let top = *marks.last().unwrap();
s.execute("appearance.setItem", &json!({"ids": [top.0], "index": top_row(&s, top, true), "color": "#00ff00"})).unwrap();
let green = s.doc().unwrap().doc.node(top).unwrap().appearance.fill_paint();
s.execute("graphicStyle.redefine", &json!({})).unwrap();
let d = &s.doc().unwrap().doc;
assert_eq!(d.graphic_style("Blue").unwrap().appearance.fill_paint(), green);
assert!(marks.iter().all(|m| d.node(*m).unwrap().appearance.fill_paint() == green), "the marks still linked follow");
}
/// A series group as the graph draws it, read as a source, gives the look of the topmost path directly inside it: New
/// Graphic Style keeps that path's fill and links it, Move Appearance copies it, and a move clears every path of the
/// group.
#[test]
fn a_series_group_read_as_a_source_gives_its_topmost_marks_look() {
let (mut s, id, marks) = stored_red_stroke();
let (group, _) = series_0_group(&s, id);
let top = *marks.last().unwrap();
s.execute("appearance.setItem", &json!({"ids": [group.0], "index": top_row(&s, marks[0], true), "color": "#0000ff", "opacity": 30})).unwrap();
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": top_row(&s, marks[0], true), "color": "#00ff00"})).unwrap();
let look = s.doc().unwrap().doc.node(top).unwrap().appearance.clone();
assert_ne!(look.fill_paint(), s.doc().unwrap().doc.node(marks[0]).unwrap().appearance.fill_paint());
s.execute("graphicStyle.new", &json!({"id": group.0, "name": "Series"})).unwrap();
let d = &s.doc().unwrap().doc;
assert_eq!(d.graphic_style("Series").unwrap().appearance.fill_paint(), look.fill_paint());
assert_eq!(d.node(top).unwrap().extras().graphic_style, Some(d.graphic_style("Series").unwrap().id));
let rect = NodeId(s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap());
s.execute("appearance.transfer", &json!({"source": group.0, "target": rect.0})).unwrap();
let d = &s.doc().unwrap().doc;
assert_eq!(d.node(rect).unwrap().appearance.fill_paint(), look.fill_paint());
assert!(marks.iter().all(|m| d.node(*m).unwrap().appearance.fill_paint() == Paint::None), "the move clears each mark");
assert_eq!(spec(&s, id).series_paints[0].fill, Some(Paint::None));
}
/// A series group that has a row of its own, as a document saved earlier can hold, is edited itself: the Appearance
/// panel lists that row and edits it by its index.
#[test]
fn a_series_group_with_a_row_of_its_own_is_edited_itself() {
let (mut s, id, _, marks) = column_marks();
let (group, _) = series_0_group(&s, id);
s.edit("Row", |d, _| {
let g = d.node_mut(group).unwrap();
g.appearance = vectorcraft_doc::Appearance::basic(Paint::None, Paint::None, 1.0).into();
Ok(())
})
.unwrap();
let was = s.doc().unwrap().doc.node(marks[0]).unwrap().appearance.clone();
s.execute("appearance.setItem", &json!({"ids": [group.0], "index": top_row(&s, group, true), "color": "#0000ff"})).unwrap();
let d = &s.doc().unwrap().doc;
assert_ne!(d.node(group).unwrap().appearance.fill_paint(), Paint::None);
assert_eq!(d.node(marks[0]).unwrap().appearance, was, "the marks keep their rows");
}
/// A graphic style applied to a series group's id styles and links the paths directly inside it, and the graph stores
/// the style's fill and stroke.
#[test]
fn a_graphic_style_applied_to_a_series_group_styles_its_marks() {
let (mut s, id, marks) = stored_red_stroke();
let (group, _) = series_0_group(&s, id);
let (_, blue) = blue_style(&mut s, "Blue", false);
let was = spec(&s, id).series_paints;
let own = json!({"ids": [group.0], "opacity": 50, "blend": "multiply", "isolate": true, "knockout": "on", "item": null});
s.execute("transparency.set", &own).unwrap();
s.execute("effect.apply", &json!({"ids": [group.0], "effect": "distort.twist"})).unwrap();
let g = s.doc().unwrap().doc.node(group).unwrap().clone();
assert!(g.isolate && g.knockout != Default::default() && g.blend != vectorcraft_color::BlendMode::Normal);
s.execute("graphicStyle.apply", &json!({"name": "Blue", "ids": [group.0]})).unwrap();
assert!(series_0_group(&s, id).1, "the group takes no rows of the style");
let g = s.doc().unwrap().doc.node(group).unwrap().clone();
assert_eq!((g.opacity, g.isolate), (1.0, false), "the style replaces the group's own opacity and isolation");
assert_eq!((g.blend, g.knockout), (vectorcraft_color::BlendMode::Normal, Default::default()), "and its blend mode and knockout");
assert!(g.appearance.effects.is_empty(), "and its effects");
let style = s.doc().unwrap().doc.graphic_style("Blue").unwrap().id;
let d = &s.doc().unwrap().doc;
assert!(marks.iter().all(|m| d.node(*m).unwrap().extras().graphic_style == Some(style)), "each mark is linked to the style");
assert_eq!(d.node(group).unwrap().extras().graphic_style, None);
let kept = spec(&s, id).series_paints[0].clone();
assert_eq!((kept.fill.clone(), kept.fill_opacity, kept.stroke), (Some(blue.clone()), Some(0.5), Some(Paint::None)));
series_0_is(&mut s, id, (&blue, 0.5, &Paint::None, 0.0), &was);
}
/// Redefine Graphic Style on a series group linked to the style (as a file can hold it) restyles the paths directly
/// inside the group and links them, and the graph stores the new look; the group goes back to no rows of its own.
#[test]
fn redefining_a_graphic_style_restyles_the_marks_of_a_linked_series_group() {
let (mut s, id, marks) = stored_red_stroke();
let (group, _) = series_0_group(&s, id);
let (rect, _) = blue_style(&mut s, "Blue", false);
let style = s.doc().unwrap().doc.graphic_style("Blue").unwrap().clone();
s.edit("Link", |d, _| {
let g = d.node_mut(group).unwrap();
g.appearance = style.appearance.clone().into();
g.extras_mut().graphic_style = Some(style.id);
Ok(())
})
.unwrap();
s.execute("appearance.setItem", &json!({"ids": [rect.0], "index": top_row(&s, rect, true), "color": "#00ff00", "opacity": 100})).unwrap();
let green = s.doc().unwrap().doc.node(rect).unwrap().appearance.fill_paint();
let was = spec(&s, id).series_paints;
s.execute("graphicStyle.redefine", &json!({"name": "Blue", "id": rect.0})).unwrap();
assert!(series_0_group(&s, id).1, "the group has no rows of its own");
let d = &s.doc().unwrap().doc;
assert_eq!(d.node(group).unwrap().extras().graphic_style, None);
assert!(marks.iter().all(|m| d.node(*m).unwrap().extras().graphic_style == Some(style.id)));
assert!(marks.iter().all(|m| d.node(*m).unwrap().appearance.fill_paint() == green));
let kept = spec(&s, id).series_paints[0].clone();
assert_eq!((kept.fill.clone(), kept.fill_opacity, kept.stroke), (Some(green.clone()), None, Some(Paint::None)));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(spec(&s, id).series_paints, was, "one undo takes back the redefinition and what the graph stored");
s.execute("edit.redo", &json!({})).unwrap();
series_0_is(&mut s, id, (&green, 1.0, &Paint::None, 0.0), &was);
}
/// Redefine Graphic Style on a linked series group leaves a path of the group that is linked to the style but no
/// longer has its look as it is, unlinked, and restyles the group's other paths.
#[test]
fn redefining_a_graphic_style_keeps_an_edited_mark_of_a_linked_series_group() {
let (mut s, id, _, marks) = column_marks();
let (group, _) = series_0_group(&s, id);
let (rect, _) = blue_style(&mut s, "Blue", false);
let style = s.doc().unwrap().doc.graphic_style("Blue").unwrap().clone();
s.edit("Link", |d, _| {
let g = d.node_mut(group).unwrap();
g.appearance = style.appearance.clone().into();
g.extras_mut().graphic_style = Some(style.id);
d.node_mut(marks[0]).unwrap().extras_mut().graphic_style = Some(style.id);
Ok(())
})
.unwrap();
let edited = s.doc().unwrap().doc.node(marks[0]).unwrap().appearance.clone();
s.execute("appearance.setItem", &json!({"ids": [rect.0], "index": top_row(&s, rect, true), "color": "#00ff00", "opacity": 100})).unwrap();
let green = s.doc().unwrap().doc.node(rect).unwrap().appearance.fill_paint();
s.execute("graphicStyle.redefine", &json!({"name": "Blue", "id": rect.0})).unwrap();
let d = &s.doc().unwrap().doc;
let first = d.node(marks[0]).unwrap();
assert_eq!((&first.appearance, first.extras().graphic_style), (&edited, None), "the edited mark keeps its look, unlinked");
assert!(marks[1..].iter().all(|m| d.node(*m).unwrap().appearance.fill_paint() == green));
assert!(marks[1..].iter().all(|m| d.node(*m).unwrap().extras().graphic_style == Some(style.id)));
}
/// Default Fill and Stroke on a series mark stores a white fill and a 1 pt black stroke, both opaque, on the graph.
#[test]
fn default_fill_and_stroke_on_a_series_mark_is_kept() {
+3 -3
View File
@@ -6,7 +6,7 @@ use serde_json::{Value, json};
use vectorcraft_color::{BlendMode, Color, GradientPaint, Paint};
use vectorcraft_doc::{Appearance, CharStyle, Document, GraySource, Node, NodeId, NodeKind};
use super::appearance::{ItemTarget, appearance_targets, edit_items, edit_items_then, item_target};
use super::appearance::{ItemTarget, edit_items, edit_items_then, item_target, row_targets};
use super::gradient::{item_paint_bounds, place_paint, place_run_paint, run_paint_mut, run_stroke_weight, unplaced};
use super::{
Affine, CommandSpec, Result, Session, always, bad, bool_or, check_numbers, check_switches, cmd, color_value, has_doc, leaf_targets, ok,
@@ -73,7 +73,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Transparency",
["Window", "Transparency"],
None,
"{ids?|id?, opacity?: 0..100, blend?: name, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, item?: appearance item index|null, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} for `ids`, the selection, or the object whose opacity mask is being edited; opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects; a gradient mesh point last clicked with the Mesh tool or Direct Selection takes the opacity itself). With an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, opacity and blend change that object for this instance only (isolate, knockout, knockoutShape, aligned and target are refused). On a path directly in a graph series, the graph also stores the opacity and blend mode of the object and of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
"{ids?|id?, opacity?: 0..100, blend?: name, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, item?: appearance item index|null, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} for `ids`, the selection, or the object whose opacity mask is being edited; opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects; a gradient mesh point last clicked with the Mesh tool or Direct Selection takes the opacity itself). With an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, opacity and blend change that object for this instance only (isolate, knockout, knockoutShape, aligned and target are refused). On a path directly in a graph series (for a fill or stroke item, a series group's id stands for those paths), the graph also stores the opacity and blend mode of the object and of the topmost visible fill and stroke (exceptions: docs/mcp.md)",
has_doc,
transparency
),
@@ -1062,7 +1062,7 @@ fn transparency(s: &mut Session, p: &Value) -> Result<Value> {
return s.execute("object.setProps", &Value::Object(q));
};
// Opacity and blend belong to the targeted fill/stroke; isolate/knockout/knockoutShape stay object-level.
let item_ids = if editing_mask { ids } else { appearance_targets(s, p)? };
let item_ids = if editing_mask { ids } else { row_targets(s, p)? };
if explicit {
let d = &s.doc()?.doc;
if !item_ids.iter().any(|id| d.node(*id).is_some_and(|n| index < n.appearance.items.len())) {
+32 -12
View File
@@ -14,7 +14,7 @@
//! paint shows instead of the character colour under it.
use std::borrow::Cow;
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use serde_json::{Value, json};
use vectorcraft_color::Paint;
@@ -31,7 +31,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Apply Graphic Style",
["Window", "Graphic Styles"],
None,
"{name, add?: bool, ids?|id? (layers too), target?: \"object\"|\"contents\"} give the objects (default: the selection; a group or layer itself, its fills and strokes painting its members and its effects applying to them as one piece; target contents: the objects inside instead) the style's appearance, opacity, blend mode, isolate and knockout, and link them to it (placed gradients land at the same place relative to each object's bounds); add: true (Alt-click) adds its fills, strokes and effects on top of the existing appearance instead and unlinks. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (with `add`: of the kinds of row the style shows) and, without `add`, the object's opacity and blend mode (exceptions: docs/mcp.md). With nothing selected (and no ids) the next object drawn takes the style instead (see appearance.newArt) → {newArt: true}",
"{name, add?: bool, ids?|id? (layers too), target?: \"object\"|\"contents\"} give the objects (default: the selection; a group or layer itself, its fills and strokes painting its members and its effects applying to them as one piece; target contents: the objects inside instead) the style's appearance, opacity, blend mode, isolate and knockout, and link them to it (placed gradients land at the same place relative to each object's bounds); add: true (Alt-click) adds its fills, strokes and effects on top of the existing appearance instead and unlinks. On a path directly in a graph series (a series group's id stands for those paths), the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (with `add`: of the kinds of row the style shows) and, without `add`, the object's opacity and blend mode (exceptions: docs/mcp.md). With nothing selected (and no ids) the next object drawn takes the style instead (see appearance.newArt) → {newArt: true}",
has_doc,
style_apply
),
@@ -40,7 +40,7 @@ pub fn specs() -> Vec<CommandSpec> {
"New Graphic Style",
["Window", "Graphic Styles"],
None,
"{name?, id?} a style from object `id` or the first selected object: its appearance, opacity, blend mode, isolate and knockout (a group without fills or strokes of its own lends its topmost object's, type its characters'; placed gradients are kept relative to its bounds); links the object. Names are made unique → {name}",
"{name?, id?} a style from object `id` or the first selected object: its appearance, opacity, blend mode, isolate and knockout (a group without fills or strokes of its own lends its topmost object's, type its characters'; a graph series group with no fill, stroke, effect, opacity, blend mode, isolation, knockout or style link of its own stands for the topmost path directly in it; placed gradients are kept relative to its bounds); links the object. Names are made unique → {name}",
has_doc,
style_new
),
@@ -68,7 +68,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Redefine Graphic Style",
["Window", "Appearance"],
None,
"{name?, id?} replace the style (default: the one the object was last linked to) with the look of object `id` or the first selected object; the objects still linked to it update, and that object becomes linked. On a linked path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md) → {name}",
"{name?, id?} replace the style (default: the one the object was last linked to) with the look of object `id` or the first selected object (a graph series group with no fill, stroke, effect, opacity, blend mode, isolation, knockout or style link of its own: the topmost path directly in it); the objects still linked to it update, and that object becomes linked. A linked graph series group passes the style to the paths directly in it, other than the source and paths linked to the style whose look was edited, and is left cleared (as appearance.clear) and unlinked. On a linked path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke and the object's opacity and blend mode (exceptions: docs/mcp.md) → {name}",
has_doc,
style_redefine
),
@@ -77,7 +77,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Break Link to Graphic Style",
["Window", "Graphic Styles"],
None,
"{ids?|id?, target?: \"object\"|\"contents\"} unlink the objects (default: the selection) from their graphic style; they keep their look",
"{ids?|id?, target?: \"object\"|\"contents\"} unlink the objects (default: the selection) from their graphic style; they keep their look. A graph series group's id stands for the group and the paths directly in it",
has_doc,
style_break_link
),
@@ -307,10 +307,11 @@ fn source<'a>(s: &'a Session, p: &Value, cmd: &str) -> Result<(NodeId, &'a Node)
impl Session {
/// The graphic style of the first selected object: the one it was last linked to, and whether
/// it is still linked (still has that style's look).
/// it is still linked (still has that style's look). A graph series group that holds nothing of its own stands
/// for the topmost path directly inside it, which a style applied to the group links.
pub fn selection_graphic_style(&self) -> Option<(&GraphicStyle, bool)> {
let st = self.active()?;
let n = st.doc.node(*st.selection.subjects().first()?)?;
let n = super::appearance::look_holder(&st.doc, st.doc.node(*st.selection.subjects().first()?)?);
let g = st.doc.graphic_style_by_id(n.extras().graphic_style?)?;
Some((g, in_sync(n, g)))
}
@@ -331,7 +332,11 @@ fn style_apply(s: &mut Session, p: &Value) -> Result<Value> {
let i = style_index(&s.doc()?.doc, name)?;
let add = bool_or(p, "add", false);
let chars = s.prefs.override_char_color;
let ids = super::appearance::appearance_targets(s, p)?;
let own = super::appearance::appearance_targets(s, p)?;
let ids = super::appearance::rowless_series_marks(&s.doc()?.doc, &own);
// The series groups their paths stand for: a style that replaces the appearance clears what they hold themselves,
// and a style added on top unlinks them.
let groups = super::appearance::stood_for(&own, &ids);
if ids.is_empty() {
if p.get("ids").is_some() || p.get("id").is_some() {
return Err(bad("graphicStyle.apply", "no such objects"));
@@ -353,6 +358,8 @@ fn style_apply(s: &mut Session, p: &Value) -> Result<Value> {
n.extras_mut().graphic_style = None;
}
}
// The series groups their paths stand for are unlinked with them.
set_link(d, &groups, None);
// The graph stores the rows of the kinds the style shows, now on top of each stack.
let shows = |kind: bool| g.appearance.items.iter().any(|it| it.is_fill() == kind && it.visible());
let kinds = (shows(true), shows(false));
@@ -364,6 +371,7 @@ fn style_apply(s: &mut Session, p: &Value) -> Result<Value> {
for id in &ids {
apply_style(d, *id, &g, &mut shared, chars);
}
super::appearance::clear_stood_for(d, &groups);
super::appearance::capture_series_writes(&before, d, &ids, (true, true), (true, true), false, true);
}
Ok(())
@@ -372,8 +380,11 @@ fn style_apply(s: &mut Session, p: &Value) -> Result<Value> {
}
fn style_new(s: &mut Session, p: &Value) -> Result<Value> {
let (src, n) = source(s, p, "graphicStyle.new")?;
let (_, n) = source(s, p, "graphicStyle.new")?;
let d = &s.doc()?.doc;
// A series group that holds nothing of its own shows the look of its paths: the style is made from the topmost.
let n = super::appearance::look_holder(d, n);
let src = n.id;
let name = match str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()) {
Some(n) => unique_name(n, |x| d.graphic_style(x).is_some()),
None => d.new_graphic_style_name(),
@@ -459,6 +470,9 @@ fn style_redefine(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "graphicStyle.redefine";
let (src, n) = source(s, p, cmd)?;
let d = &s.doc()?.doc;
// A series group that holds nothing of its own is redefined from the topmost of its paths, which a style links.
let n = super::appearance::look_holder(d, n);
let src = if n.id == src { src } else { n.id };
let i = match str_param(p, "name") {
Some(name) => style_index(d, name)?,
None => n
@@ -480,12 +494,18 @@ fn style_redefine(s: &mut Session, p: &Value) -> Result<Value> {
let linked = objects_where(d, |n| n.extras().graphic_style == Some(id) && n.id != src);
let (follow, edited): (Vec<NodeId>, Vec<NodeId>) = linked.into_iter().partition(|nid| d.node(*nid).is_some_and(|n| in_sync(n, &old)));
set_link(d, &edited, None);
// A linked graph series group passes the style to the paths directly inside it, so the graph stores it, and
// goes back to the empty appearance the graph draws it with. The source and the edited paths keep their look.
let mut follow_marks = super::graph::series_group_marks(d, &follow);
let edited: HashSet<NodeId> = edited.into_iter().collect();
follow_marks.retain(|nid| *nid != src && !edited.contains(nid));
super::appearance::clear_stood_for(d, &super::appearance::stood_for(&follow, &follow_marks));
let g = d.graphic_styles[i].clone();
let mut shared = None;
for nid in &follow {
for nid in &follow_marks {
apply_style(d, *nid, &g, &mut shared, chars);
}
super::appearance::capture_series_writes(&before, d, &follow, (true, true), (true, true), false, true);
super::appearance::capture_series_writes(&before, d, &follow_marks, (true, true), (true, true), false, true);
set_link(d, &[src], Some(id));
Ok(())
})?;
@@ -493,7 +513,7 @@ fn style_redefine(s: &mut Session, p: &Value) -> Result<Value> {
}
fn style_break_link(s: &mut Session, p: &Value) -> Result<Value> {
let ids = super::appearance::appearance_targets(s, p)?;
let ids = super::appearance::whole_targets(s, p)?;
s.edit("Break Link to Graphic Style", |d, _| {
set_link(d, &ids, None);
Ok(())
+25 -3
View File
@@ -758,9 +758,31 @@ designs keep their own paint. A row that had no
paint before the command and has none after it does not change the paint the graph stores. `paint.setFill`,
`paint.setStroke`, `paint.none` and the commands that apply one paint kind to the other store the topmost row of the kind
even when it is hidden, and store a stroke set on a band. `stroke.set`, `paint.editGradient` and the recolor commands do
not store a series' paint. An `appearance.*` command or `graphicStyle.apply` given a series group's id, and
`graphicStyle.redefine` on a series group linked to the style, edit the group's own rows, and the graph does not store
them.
not store a series' paint.
`appearance.addFill`, `appearance.addStroke`, `appearance.setItem`, `appearance.removeItem`,
`appearance.duplicateItem`, `appearance.moveItem`, `appearance.clear`, `appearance.reduceToBasic`,
`appearance.showAllHidden`, `appearance.transfer` (as its `target`), `graphicStyle.apply`, and `transparency.set` with a
fill or stroke `item`, given a series group's id or the selected series group, edit the paths directly in the group as
if each had been given, and the group keeps no rows of its own. A series group with no path directly in it, for example
one drawn with a column design, and a series group that has a fill or stroke row of its own, as a document saved by an
earlier version can hold, are edited themselves by these commands, and `appearance.moveItem` with `from: "contents"`
moves the group's own Contents row. `appearance.clear`, `appearance.reduceToBasic`, `appearance.showAllHidden` and
`graphicStyle.breakLink` edit the group as well as its paths. `graphicStyle.apply` without `add` and
`appearance.transfer` onto the group clear the group's own effects, opacity, blend mode, isolation, knockout and style
link, and `graphicStyle.apply` with `add` unlinks the group. With `appearance.transfer`, a `source` that is one of the
target group's paths keeps its look on a move, and a `source` that is the group's only path is an error.
`graphicStyle.redefine` gives the style to the paths directly in a series group linked to it, whether or not the group
has rows of its own, links them, and clears and unlinks the group; the source and the paths linked to the style whose
look was edited keep their look. Read as a source, by `graphicStyle.new`, `graphicStyle.redefine` or
`appearance.transfer`, a series group that has no fill, stroke, effect, opacity, blend mode, isolation, knockout or
style link of its own gives the look and the style link of the topmost path directly in it, and a move with
`appearance.transfer` clears each of those paths; a series group that has one of them is read itself.
The other commands given a series group's id edit the group itself, and the graph does not store what they set on it:
`transparency.set` and `object.setProps` with an opacity or blend mode for the object, the `effect.*` commands and
`appearance.addEffect`, and the paint, stroke and gradient commands with a fill or stroke `item`, which is a row of the
group's own stack. The Appearance panel and `appearance.setActiveItem` read the selected group's own rows.
`text.setFormat` without `start` and `end`, on a graph or on its labels, stores the character and paragraph attributes
it sets for each kind of label it reaches (value axis numbers, category labels, legend labels), and every label of that
+1 -1
View File
@@ -1075,7 +1075,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Blend spine Anchor Point tool (#2544):** on a selected blend, dragging a spine corner makes symmetric Smooth handles; the tool displays and picks existing spine handles when switched to, drags one independently or retracts it on click, and clicking a point with handles retracts both. The spine is edited live without moving its key objects. Windows native canvas checks and screenshots reviewed: symmetric and independent handles, corner/retraction, Shift constraints and perpendicular reshaping, tool switching, key preservation, Undo/Redo, save/reopen, hidden handles and overlapping Path/Blend priority (PR #2643). Behaviour reference: [public path-editing documentation](https://helpx.adobe.com/illustrator/using/adjust-path-segments.html).
- **Blend steps of brushed strokes:** the steps interpolate the pen pressure, tilt, bearing and rotation recorded with the Paintbrush, so a Calligraphic, Scatter or Art brush that varies with the pen changes gradually from one key to the next. A key without a recording counts as half pressure, half tilt and 0°, as brushes read it, and angles turn the shorter way round. When the blend starts a key's path at another anchor (an open path run from its end, a closed one turned to another start), the key's recordings, width profile and arrowheads turn with the path. Different brushes still switch halfway (VectorCraft's own rule; strokes blend their options, as [Blended objects overview](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blended-objects-overview.html) describes; `crates/doc/src/blend.rs`, `pen_pressure_tilt_and_angles_interpolate_for_brushes`, `stroke_profiles_turn_with_a_key_path_started_elsewhere`).
- **Path editing (#494):** the handles shown are the handles that drag: Direct Selection drags those of a path selected as a whole (as the Pen leaves it), the Anchor Point tool and Pen+Alt no longer grab handles that Show handles when multiple anchors are selected hides, a handle shorter than the tolerance leaves its anchor to be picked, and the anchor tools pick within Selection & Anchor Display › Tolerance. A dragged segment keeps a smooth anchor at either end smooth. With Direct Selection, the Pen, the Curvature and the anchor tools, a path selected as a whole shows all its anchors selected, and the Control bar and the Properties panel offer Convert (corner, smooth) for them too; Remove and Cut wait for anchors direct-selected (#504). A handle drag gathers its snap targets once per drag (it took 21 ms per pointer move on a 20,000-path document, now 1 ms; `vectorcraft-cli perf` measures handle and segment drags on a 5,000-anchor path). Join merges the selected anchor points that lie on one another, as after Average, into one anchor (#1582, fixes #1533), and removes the merged anchors in one pass over the path, so a long run of them no longer moves the anchors after it once per merge (`cmd::path::tests::a_long_run_before_many_other_anchors_merges_in_one_pass`); Object › Path › Average & Join… (`path.averageJoin`, Cmd+Alt+Shift+J, the "Create corner or smooth join" shortcut of [Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)) moves two open path ends to their midpoint and joins them there, a corner or smooth point as its dialog's Points choice asks, in one undo step.
- **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke, the paint, weight, opacity and blend mode of a mark's top visible fill or stroke row after an Appearance command, the Eyedropper, a graphic style, Default Fill and Stroke or Swap Fill and Stroke, and a mark's object opacity and blend mode, are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character, Paragraph or Appearance controls, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485).
- **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke, the paint, weight, opacity and blend mode of a mark's top visible fill or stroke row after an Appearance command or a graphic style (each given a mark or its series picked with Group Selection), the Eyedropper, Default Fill and Stroke or Swap Fill and Stroke, and a mark's object opacity and blend mode, are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character, Paragraph or Appearance controls, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485).
An Eyedropper pick that includes transparency and leaves a mark's top fill or stroke row without paint, or removes that row when the source has no row of that kind, where that row had paint before, also replaces the opacity and blend mode the graph keeps for that kind of row in the series; a row that had no paint before the pick leaves them as they are.
- **Graph column designs:** Object › Graph › Column (`graph.column`) draws the columns, bars or stacked segments and the legend swatch of the series picked with Group Selection with a graph design: Vertically Scaled stretches it to each column's length, Uniformly Scaled scales it both ways and centers it, Repeating stacks a copy for each Each Design Represents value and chops or scales the last one (For Fractions), and Sliding keeps the part above the design's guide line in proportion and stretches the part below. The dialog lists the designs with a preview; the choice is saved with the graph and follows the design's rename and delete.
Type in a column design that holds a `%` followed by two digits shows each column's value in place of the `%` and the digits: the first digit is the least number of places before the decimal point (zeros are added in front; 0 gives as many as the value needs), and the second digit sets the places after it, rounded. Unless it is inside a group that clips or has its own opacity or appearance, or inside a blend, compound shape, envelope, repeat or intertwined objects, the type keeps its size and is drawn once per column in a repeating design. The legend swatch leaves it out.