diff --git a/crates/doc/src/graph.rs b/crates/doc/src/graph.rs index b19adc8b0..15f04ce15 100644 --- a/crates/doc/src/graph.rs +++ b/crates/doc/src/graph.rs @@ -321,6 +321,20 @@ pub struct SeriesPaint { /// Opacity (0..1) of the topmost stroke of each path directly in the series group, as [`Self::fill_opacity`]. #[serde(skip_serializing_if = "Option::is_none")] pub stroke_opacity: Option, + /// Blend mode of the topmost fill of each path directly in the series group, as [`Self::fill_opacity`]. Absent: Normal. + #[serde(skip_serializing_if = "Option::is_none")] + pub fill_blend: Option, + /// Blend mode of the topmost stroke of each path directly in the series group, as [`Self::fill_opacity`]. Absent: + /// Normal. + #[serde(skip_serializing_if = "Option::is_none")] + pub stroke_blend: Option, + /// Object opacity (0..1) of each path directly in the series group, taken from the object opacity set on one of them. + /// Absent: fully opaque. + #[serde(skip_serializing_if = "Option::is_none")] + pub opacity: Option, + /// Object blend mode of each path directly in the series group, as [`Self::opacity`]. Absent: Normal. + #[serde(skip_serializing_if = "Option::is_none")] + pub blend: Option, } /// Graph data and options. `rows` are categories (one per row of the Graph Data window), each diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 02990a662..be452f6c6 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -45,7 +45,7 @@ pub fn specs() -> Vec { "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 and opacity 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, 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 +54,7 @@ pub fn specs() -> Vec { "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 and opacity 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, 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 +63,7 @@ pub fn specs() -> Vec { "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 and opacity of the topmost visible fill and stroke (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, 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 +72,7 @@ pub fn specs() -> Vec { "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 and opacity 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, 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 +81,7 @@ pub fn specs() -> Vec { "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 and opacity 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, 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 +90,7 @@ pub fn specs() -> Vec { "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 and opacity 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, 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 +108,7 @@ pub fn specs() -> Vec { "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 and opacity 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, 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 +117,7 @@ pub fn specs() -> Vec { "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 and opacity 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, 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 ), @@ -126,7 +126,7 @@ pub fn specs() -> Vec { "Eyedropper", [], None, - "{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes) from `source` to ids (default: highlighted characters while editing type, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art); with an object of a dynamic symbol instance selected by Direct Selection and no ids, reverse, append or defaultsOnly, that object takes the source's solid fill and stroke for this instance only, as the Eyedropper Options allow, and a sampled gradient or pattern, a source that is not a path, compound path or type, or options that pick up neither color, are refused. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {ids}", + "{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes) from `source` to ids (default: highlighted characters while editing type, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art); with an object of a dynamic symbol instance selected by Direct Selection and no ids, reverse, append or defaultsOnly, that object takes the source's solid fill and stroke for this instance only, as the Eyedropper Options allow, and a sampled gradient or pattern, a source that is not a path, compound path or type, or options that pick up neither color, are refused. 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}", has_doc, copy_from ), @@ -144,7 +144,7 @@ pub fn specs() -> Vec { "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 and opacity 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, 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 +162,7 @@ pub fn specs() -> Vec { "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 and opacity of the topmost visible fill and stroke (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, 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 ), @@ -628,25 +628,33 @@ fn clear_appearance(s: &mut Session, p: &Value) -> Result { } let kept = super::graph::capture_series_opacity(d, &both_kinds(&ids)); let painted = super::graph::capture_series_top_paints(d, &top_paint_changed(&before, d, &ids)); - Ok(count_changed(d, &before, &ids) + usize::from(kept || painted)) + let object = super::graph::capture_series_object_opacity(d, &ids); + Ok(count_changed(d, &before, &ids) + usize::from(kept || painted || object)) })?; s.active_appearance_item = None; ok() } +/// Each of `ids` whose object opacity or blend mode is another in `d` than in `before`, for +/// [`super::graph::capture_series_object_opacity`]. +fn object_opacity_changed(before: &vectorcraft_doc::Document, d: &vectorcraft_doc::Document, ids: &[NodeId]) -> Vec { + let of = |d: &vectorcraft_doc::Document, id: NodeId| d.node(id).map(|n| (n.opacity, n.blend)); + ids.iter().copied().filter(|id| of(before, *id) != of(d, *id)).collect() +} + /// Each of `ids` with both kinds of row (fill, then stroke), for [`super::graph::capture_series_opacity`] after an /// edit that replaces whole stacks. fn both_kinds(ids: &[NodeId]) -> Vec<(NodeId, bool)> { ids.iter().flat_map(|id| [(*id, true), (*id, false)]).collect() } -/// Each of `ids` with each kind of row (fill `true`, stroke `false`) whose topmost visible row has another opacity in `d` -/// than in `before` (no row counts as opaque), for [`super::graph::capture_series_opacity`]. +/// Each of `ids` with each kind of row (fill `true`, stroke `false`) whose topmost visible row has another opacity or blend +/// mode in `d` than in `before` (no row counts as opaque and Normal), for [`super::graph::capture_series_opacity`]. fn top_opacity_changed(before: &vectorcraft_doc::Document, d: &vectorcraft_doc::Document, ids: &[NodeId]) -> Vec<(NodeId, bool)> { let top = |d: &vectorcraft_doc::Document, id: NodeId, fill: bool| { d.node(id) - .and_then(|n| n.appearance.items.iter().rev().find(|it| it.is_fill() == fill && it.visible()).map(AppearanceItem::opacity)) - .unwrap_or(1.0) + .and_then(|n| n.appearance.items.iter().rev().find(|it| it.is_fill() == fill && it.visible()).map(|it| (it.opacity(), it.blend()))) + .unwrap_or((1.0, BlendMode::Normal)) }; ids.iter().flat_map(|id| [(*id, true), (*id, false)]).filter(|(id, fill)| top(before, *id, *fill) != top(d, *id, *fill)).collect() } @@ -666,9 +674,10 @@ fn painted_rows(d: &vectorcraft_doc::Document, rows: impl IntoIterator bool { let top_shows = |d: &vectorcraft_doc::Document, id: NodeId, kind: bool| { let items = d.node(id).map_or(&[][..], |n| &n.appearance.items[..]); items.iter().rev().find(|it| it.is_fill() == kind).is_some_and(AppearanceItem::visible) }; - let written = |d: &vectorcraft_doc::Document, (fill, stroke): (bool, bool)| -> Vec<(NodeId, bool)> { + let kinds = |(fill, stroke): (bool, bool)| -> Vec<(NodeId, bool)> { let rows = ids.iter().flat_map(|id| [fill.then_some((*id, true)), stroke.then_some((*id, false))]).flatten(); - painted_rows(d, rows.filter(|(id, kind)| !top_only || top_shows(d, *id, *kind))) + rows.filter(|(id, kind)| !top_only || top_shows(d, *id, *kind)).collect() }; + let written = |d: &vectorcraft_doc::Document, rows: (bool, bool)| painted_rows(d, kinds(rows)); let mut opacity_rows = top_opacity_changed(before, d, ids); + // A written row that had paint before the edit counts too when the edit left it without paint: only a row without + // paint both before and after keeps what the graph stores. + opacity_rows.extend(painted_rows(before, kinds(opacities))); opacity_rows.extend(written(d, opacities)); let mut rows = top_paint_changed(before, d, ids); rows.extend(written(d, paints)); let kept = super::graph::capture_series_opacity(d, &opacity_rows); let painted = super::graph::capture_series_top_paints(d, &rows); - kept || painted + let objects = if object { ids.to_vec() } else { object_opacity_changed(before, d, ids) }; + let stored = super::graph::capture_series_object_opacity(d, &objects); + kept || painted || stored } /// The kind of the row at `at` (fill `true`, stroke `false`) when it is the topmost visible row of that kind in `ap`. @@ -743,7 +759,8 @@ fn transfer(s: &mut Session, p: &Value) -> Result { let mut rows = top_paint_changed(&before, d, &changed); rows.extend(painted_rows(d, both_kinds(&[target]))); let painted = super::graph::capture_series_top_paints(d, &rows); - Ok(count_changed(d, &before, &[source, target]) + usize::from(kept || painted)) + let object = super::graph::capture_series_object_opacity(d, &changed); + Ok(count_changed(d, &before, &[source, target]) + usize::from(kept || painted || object)) })?; s.active_appearance_item = None; Ok(json!({ "source": source.0, "target": target.0 })) @@ -862,10 +879,10 @@ fn set_item(s: &mut Session, p: &Value) -> Result { edited.push((*id, fill)); } } - // The graph keeps an opacity, paint or weight set on the topmost visible fill or stroke of its series art for when - // it is drawn again, and hiding or showing a row changes which row that is. + // The graph keeps an opacity, blend mode, paint or weight set on the topmost visible fill or stroke of its series art + // for when it is drawn again, and hiding or showing a row changes which row that is. let mut opacities = top_opacity_changed(&before, d, &ids); - if p.get("opacity").and_then(Value::as_f64).is_some() { + if p.get("opacity").and_then(Value::as_f64).is_some() || blend.is_some() { opacities.extend(edited.iter().copied()); } let kept = super::graph::capture_series_opacity(d, &opacities); @@ -1347,7 +1364,10 @@ fn copy_from(s: &mut Session, p: &Value) -> Result { rows.extend(painted_rows(d, written(paints))); let kept = super::graph::capture_series_opacity(d, &opacity_rows); let painted = super::graph::capture_series_top_paints(d, &rows); - Ok(count_changed(d, &before, &targets) + usize::from(kept || painted)) + // A pick of the object's transparency writes it on every target. + let objects = if attrs.appearance.transparency { targets.clone() } else { object_opacity_changed(&before, d, &targets) }; + let object = super::graph::capture_series_object_opacity(d, &objects); + Ok(count_changed(d, &before, &targets) + usize::from(kept || painted || object)) })?; Ok(json!({ "ids": targets.iter().map(|i| i.0).collect::>() })) } diff --git a/crates/engine/src/cmd/graph.rs b/crates/engine/src/cmd/graph.rs index 7a47457c2..8c838d421 100644 --- a/crates/engine/src/cmd/graph.rs +++ b/crates/engine/src/cmd/graph.rs @@ -42,7 +42,10 @@ use data::{fmt_value, parse_csv, to_csv}; use generate::generate; #[cfg(test)] use series::default_series_color; -pub(crate) use series::{capture_label_styles, capture_series_opacity, capture_series_paints, capture_series_top_paints, paint_targets, shown_paint}; +pub(crate) use series::{ + 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, +}; #[cfg(test)] mod tests; diff --git a/crates/engine/src/cmd/graph/series.rs b/crates/engine/src/cmd/graph/series.rs index 8ab2573ff..7d7d75a16 100644 --- a/crates/engine/src/cmd/graph/series.rs +++ b/crates/engine/src/cmd/graph/series.rs @@ -1,3 +1,5 @@ +use vectorcraft_color::BlendMode; + use super::axis::radar_axis; use super::labels::{Role, filled_lines_on, label_style}; use super::{Arc, CharStyle, Color, Document, GraphKind, GraphSpec, HashMap, Node, NodeId, NodeKind, Paint, Result, Session, Value, bad}; @@ -26,26 +28,43 @@ pub(super) fn series_marks(g: &GraphSpec, i: usize, series_fill: bool, stroke: P (fill, stroke, width) } -/// The paths directly inside series `i`'s group (`items`) with the series' kept fill and stroke opacity on their -/// topmost fill and stroke. Other art, such as a graph design, keeps its own opacity. A value read from a file is -/// held to 0..1, and one that is not a number is left out. +/// The paths directly inside series `i`'s group (`items`) with the series' kept fill and stroke opacity and blend mode on +/// their topmost fill and stroke, and its kept object opacity and blend mode. Other art, such as a graph design, keeps its +/// own. An opacity read from a file is held to 0..1, and one that is not a number is left out. pub(super) fn with_series_opacity(g: &GraphSpec, i: usize, mut items: Vec>) -> Vec> { let Some(slot) = g.series_paints.get(i) else { return items }; let kept = |o: Option| o.filter(|o| o.is_finite()).map(|o| o.clamp(0.0, 1.0)); - let (fill, stroke) = (kept(slot.fill_opacity), kept(slot.stroke_opacity)); - if fill.is_none() && stroke.is_none() { + let (fill, stroke, object) = (kept(slot.fill_opacity), kept(slot.stroke_opacity), kept(slot.opacity)); + if fill.is_none() && stroke.is_none() && object.is_none() && slot.fill_blend.is_none() && slot.stroke_blend.is_none() && slot.blend.is_none() { return items; } for item in &mut items { if !matches!(item.kind, NodeKind::Path { .. }) { continue; } - let ap = &mut Arc::make_mut(item).appearance; - if let (Some(o), Some(f)) = (fill, ap.fill_mut()) { - f.opacity = o; + let n = Arc::make_mut(item); + if let Some(o) = object { + n.opacity = o; } - if let (Some(o), Some(st)) = (stroke, ap.stroke_mut()) { - st.opacity = o; + if let Some(b) = slot.blend { + n.blend = b; + } + let ap = &mut n.appearance; + if let Some(f) = ap.fill_mut() { + if let Some(o) = fill { + f.opacity = o; + } + if let Some(b) = slot.fill_blend { + f.blend = b; + } + } + if let Some(st) = ap.stroke_mut() { + if let Some(o) = stroke { + st.opacity = o; + } + if let Some(b) = slot.stroke_blend { + st.blend = b; + } } } items @@ -216,10 +235,10 @@ fn store_series_paints(doc: &mut Document, captures: Vec<(NodeId, usize, bool, P changed } -/// Copy the opacity of the topmost visible fill (`true`) or stroke (`false`) of generated series art just edited (`edited`: -/// each object with the kind of row) into that series' fill or stroke opacity; art left without a row of that kind -/// keeps none. Called inside the same document edit, so both undo together. Returns whether a series' kept opacity -/// changed. +/// Copy the opacity and blend mode of the topmost visible fill (`true`) or stroke (`false`) of generated series art just +/// edited (`edited`: each object with the kind of row) into that series' fill or stroke opacity and blend mode; art left +/// without a row of that kind keeps neither. Called inside the same document edit, so both undo together. Returns +/// whether a series' kept opacity or blend mode changed. pub(crate) fn capture_series_opacity(doc: &mut Document, edited: &[(NodeId, bool)]) -> bool { if edited.is_empty() { return false; @@ -236,14 +255,15 @@ pub(crate) fn capture_series_opacity(doc: &mut Document, edited: &[(NodeId, bool let node = doc.node(*id).filter(|n| matches!(n.kind, NodeKind::Path { .. }))?; let row = node.appearance.items.iter().rev().find(|it| it.is_fill() == *fill && it.visible()); let opacity = row.map_or(1.0, vectorcraft_doc::AppearanceItem::opacity); - Some((r.graph, r.index, *fill, (opacity < 1.0).then_some(opacity))) + let blend = row.map_or(BlendMode::Normal, vectorcraft_doc::AppearanceItem::blend); + Some((r.graph, r.index, *fill, (opacity < 1.0).then_some(opacity), (blend != BlendMode::Normal).then_some(blend))) }) .collect(); let mut changed = false; - for (graph, index, fill, kept) in captures { + for (graph, index, fill, kept, blend) in captures { let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue }; - // A fully opaque row on a series the graph keeps nothing for leaves the graph as it is. - if kept.is_none() && spec.series_paints.len() <= index { + // A fully opaque Normal row on a series the graph keeps nothing for leaves the graph as it is. + if kept.is_none() && blend.is_none() && spec.series_paints.len() <= index { continue; } let was = spec.series_paints.clone(); @@ -253,15 +273,73 @@ pub(crate) fn capture_series_opacity(doc: &mut Document, edited: &[(NodeId, bool } let Some(slot) = spec.series_paints.get_mut(index) else { continue }; if fill { - slot.fill_opacity = kept; + (slot.fill_opacity, slot.fill_blend) = (kept, blend); } else { - slot.stroke_opacity = kept; + (slot.stroke_opacity, slot.stroke_blend) = (kept, blend); } changed |= spec.series_paints != was; } changed } +/// Copy the object opacity and blend mode of generated series art just edited (`edited`) into that series' object opacity +/// and blend mode. Called inside the same document edit, so both undo together. Returns whether a series' kept object +/// opacity or blend mode changed. +pub(crate) fn capture_series_object_opacity(doc: &mut Document, edited: &[NodeId]) -> bool { + let mut changed = false; + for (graph, index, opacity, blend) in object_opacity_captures(doc, edited) { + let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue }; + // A fully opaque Normal object on a series the graph keeps nothing for leaves the graph as it is. + if opacity.is_none() && blend.is_none() && spec.series_paints.len() <= index { + continue; + } + let was = spec.series_paints.clone(); + // `index` is below the graph's series count (`series_members`). + if spec.series_paints.len() <= index { + spec.series_paints.resize(index + 1, vectorcraft_doc::SeriesPaint::default()); + } + let Some(slot) = spec.series_paints.get_mut(index) else { continue }; + (slot.opacity, slot.blend) = (opacity, blend); + changed |= spec.series_paints != was; + } + changed +} + +/// Would [`capture_series_object_opacity`] change what the graph stores for `edited`? Reads the document without copying +/// it. +pub(crate) fn series_object_opacity_differs(doc: &Document, edited: &[NodeId]) -> bool { + let mut after: HashMap<(NodeId, usize), (Option, Option)> = HashMap::new(); + let stored = |graph: NodeId, index: usize| { + let spec = doc.node(graph).and_then(|n| n.extras().graph.as_deref()); + spec.and_then(|g| g.series_paints.get(index)).map_or((None, None), |slot| (slot.opacity, slot.blend)) + }; + for (graph, index, opacity, blend) in object_opacity_captures(doc, edited) { + after.insert((graph, index), (opacity, blend)); + } + after.into_iter().any(|((graph, index), pair)| stored(graph, index) != pair) +} + +/// The series and object opacity and blend mode (None for 100% and Normal) of each of `edited` that is a path directly in a +/// series group, in order. +fn object_opacity_captures(doc: &Document, edited: &[NodeId]) -> Vec<(NodeId, usize, Option, Option)> { + if edited.is_empty() { + return Vec::new(); + } + let members = series_members(doc); + if members.is_empty() { + return Vec::new(); + } + edited + .iter() + .filter_map(|id| { + // Only the paths the graph draws again, not the copy of a graph design. + let r = members.get(id).filter(|r| doc.parent_of(*id) == Some(r.group))?; + let node = doc.node(*id).filter(|n| matches!(n.kind, NodeKind::Path { .. }))?; + Some((r.graph, r.index, (node.opacity < 1.0).then_some(node.opacity), (node.blend != BlendMode::Normal).then_some(node.blend))) + }) + .collect() +} + /// Graph Data › Transpose row/column: categories become series and series categories. pub(super) fn transpose(g: &mut GraphSpec) { let cells = g.cells(); diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index 5825c5fc0..cc986da63 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -1,5 +1,5 @@ use serde_json::json; -use vectorcraft_color::Paint; +use vectorcraft_color::{BlendMode, Paint}; use vectorcraft_doc::{CharStyle, ColumnDesign, ColumnType, Fractions, GraphKind, GraphSpec, NodeKind}; use crate::{NodeId, Session}; @@ -347,14 +347,14 @@ fn clearing_a_series_marks_appearance_forgets_its_kept_opacity() { } /// Removing the fill whose opacity the graph keeps takes the kept opacity away; an edit that leaves a mark opaque -/// (a blend mode alone) keeps nothing for its series. +/// and Normal (an opacity of 100% alone) keeps nothing for its series. #[test] fn removing_a_series_marks_fill_forgets_its_kept_opacity() { let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); let id = graph(&mut s, "column"); let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].id; - s.execute("transparency.set", &json!({"ids": [bar.0], "item": 0, "blend": "multiply"})).unwrap(); + s.execute("transparency.set", &json!({"ids": [bar.0], "item": 0, "opacity": 100})).unwrap(); assert!(spec(&s, id).series_paints.is_empty()); s.execute("transparency.set", &json!({"ids": [bar.0], "item": 0, "opacity": 40})).unwrap(); assert_eq!(spec(&s, id).series_paints[0].fill_opacity, Some(0.4)); @@ -503,6 +503,198 @@ fn a_stroke_opacity_on_a_series_line_survives_regeneration() { assert!(!after.is_empty() && after.iter().all(|m| m.appearance.stroke().unwrap().opacity == 0.6)); } +/// Each mark of series `index`: its object opacity and blend mode, and the blend modes of its top fill and stroke. +fn transparency_of(s: &Session, id: NodeId, index: u32) -> Vec<(f32, BlendMode, BlendMode, BlendMode)> { + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let marks = series(&n, index).children().unwrap().to_vec(); + marks.iter().map(|m| (m.opacity, m.blend, m.appearance.fill().unwrap().blend, m.appearance.stroke().unwrap().blend)).collect() +} + +/// An object opacity and blend mode set on one series mark with no fill or stroke row picked are kept by the graph: +/// every mark of that series has them after the graph is drawn again, through save and open, the other series stay +/// opaque and Normal, 100% and Normal forget them, and undo takes them back. +#[test] +fn an_object_opacity_and_blend_mode_on_a_series_mark_survive_regeneration() { + let (mut s, id, _, marks) = column_marks(); + s.execute("transparency.set", &json!({"ids": [marks[0].0], "opacity": 40, "blend": "multiply"})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.opacity, kept.blend), (Some(0.4), Some(BlendMode::Multiply))); + let restored: GraphSpec = serde_json::from_value(serde_json::to_value(spec(&s, id)).unwrap()).unwrap(); + assert_eq!(restored, spec(&s, id)); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + let normal = BlendMode::Normal; + assert!(transparency_of(&s, id, 0).iter().all(|t| *t == (0.4, BlendMode::Multiply, normal, normal)), "{:?}", transparency_of(&s, id, 0)); + assert!(transparency_of(&s, id, 1).iter().all(|t| *t == (1.0, normal, normal, normal)), "{:?}", transparency_of(&s, id, 1)); + let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].id; + s.execute("transparency.set", &json!({"ids": [bar.0], "opacity": 100, "blend": "normal"})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.opacity, kept.blend), (None, None)); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(spec(&s, id).series_paints.is_empty(), "undo takes back the edit and what the graph stored"); +} + +/// `object.setProps` with the opacity a mark already shows replaces the opacity another mark of the series left on the +/// graph, in one undo step, and an opacity on a graph design's copy in a series is not kept. +#[test] +fn an_object_opacity_a_series_mark_already_shows_replaces_the_kept_opacity() { + let (mut s, id, _, marks) = column_marks(); + s.execute("object.setProps", &json!({"id": marks[0].0, "opacity": 50})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.5)); + s.execute("object.setProps", &json!({"id": marks[1].0, "opacity": 100})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].opacity, None); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + assert!(transparency_of(&s, id, 0).iter().all(|t| t.0 == 1.0), "{:?}", transparency_of(&s, id, 0)); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.5)); + // Setting it again where the graph already stores it leaves the document and its history as they are. + let undo = s.doc().unwrap().history.undo.len(); + s.execute("object.setProps", &json!({"id": marks[0].0, "opacity": 50})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + save_design(&mut s, "Square"); + s.execute("graph.column", &json!({"id": id.0, "design": "Square"})).unwrap(); + let copy = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].clone(); + assert!(matches!(copy.kind, NodeKind::Group { .. })); + s.execute("object.setProps", &json!({"id": copy.id.0, "opacity": 50})).unwrap(); + assert!(spec(&s, id).series_paints.iter().all(|p| p.opacity.is_none())); +} + +/// A blend mode set on the top fill or stroke row of one series mark is kept by the graph and drawn on every mark of +/// the series again; Normal forgets it. +#[test] +fn a_row_blend_mode_on_a_series_mark_survives_regeneration() { + let (mut s, id, _, marks) = column_marks(); + let (fill, stroke) = (top_row(&s, marks[0], true), top_row(&s, marks[0], false)); + s.execute("transparency.set", &json!({"ids": [marks[0].0], "item": fill, "blend": "screen"})).unwrap(); + s.execute("transparency.set", &json!({"ids": [marks[1].0], "item": stroke, "blend": "darken"})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill_blend, kept.stroke_blend), (Some(BlendMode::Screen), Some(BlendMode::Darken))); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + let normal = BlendMode::Normal; + assert!(transparency_of(&s, id, 0).iter().all(|t| *t == (1.0, normal, BlendMode::Screen, BlendMode::Darken)), "{:?}", transparency_of(&s, id, 0)); + assert!(transparency_of(&s, id, 1).iter().all(|t| *t == (1.0, normal, normal, normal)), "{:?}", transparency_of(&s, id, 1)); + let bar = series(s.doc().unwrap().doc.node(id).unwrap(), 0).children().unwrap()[0].id; + s.execute("transparency.set", &json!({"ids": [bar.0], "item": top_row(&s, bar, true), "blend": "normal"})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].fill_blend, None); +} + +/// The graph stores an object's or a row's opacity and blend mode as one pair from the mark just edited: setting only +/// one half on another mark replaces both, either way round, and the redraw and undo follow the stored pair. +#[test] +fn setting_half_of_a_series_marks_transparency_stores_that_marks_whole_pair() { + let normal = BlendMode::Normal; + let (mut s, id, _, marks) = column_marks(); + s.execute("transparency.set", &json!({"ids": [marks[0].0], "blend": "multiply"})).unwrap(); + s.execute("object.setProps", &json!({"id": marks[1].0, "opacity": 50})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.opacity, kept.blend), (Some(0.5), None)); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + assert!(transparency_of(&s, id, 0).iter().all(|t| (t.0, t.1) == (0.5, normal)), "{:?}", transparency_of(&s, id, 0)); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.opacity, kept.blend), (None, Some(BlendMode::Multiply))); + + let (mut s, id, _, marks) = column_marks(); + s.execute("object.setProps", &json!({"id": marks[0].0, "opacity": 30})).unwrap(); + s.execute("transparency.set", &json!({"ids": [marks[1].0], "blend": "screen"})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.opacity, kept.blend), (None, Some(BlendMode::Screen))); + s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap(); + assert!(transparency_of(&s, id, 0).iter().all(|t| (t.0, t.1) == (1.0, BlendMode::Screen)), "{:?}", transparency_of(&s, id, 0)); + + // A fill row: a blend alone on one mark replaces the opacity another mark's row left, and the other way round. + let (mut s, id, _, marks) = column_marks(); + let fill = top_row(&s, marks[0], true); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": fill, "opacity": 40})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": fill, "blend": "darken"})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill_opacity, kept.fill_blend), (None, Some(BlendMode::Darken))); + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": fill, "opacity": 60})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill_opacity, kept.fill_blend), (Some(0.6), None)); + s.execute("edit.undo", &json!({})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.fill_opacity, kept.fill_blend), (None, Some(BlendMode::Darken))); +} + +/// Clear Appearance on a series mark sets its object opacity back to 100% and Normal, and the graph keeps neither. +#[test] +fn clearing_a_series_marks_appearance_forgets_its_kept_object_opacity() { + let (mut s, id, _, marks) = column_marks(); + s.execute("transparency.set", &json!({"ids": [marks[0].0], "opacity": 40, "blend": "multiply"})).unwrap(); + s.execute("appearance.clear", &json!({"ids": [marks[1].0]})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.opacity, kept.blend), (None, None)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.4)); +} + +/// An appearance moved or copied onto a mark of another series takes the source's object opacity and blend mode, and +/// the graph keeps them for the target's series; a move leaves the source's series opaque and Normal. +#[test] +fn moving_an_appearance_onto_a_series_mark_keeps_its_object_opacity() { + for copy in [false, true] { + let (mut s, id, _, marks) = column_marks(); + let to = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id; + s.execute("transparency.set", &json!({"ids": [marks[0].0], "opacity": 40, "blend": "multiply"})).unwrap(); + s.execute("appearance.transfer", &json!({"source": marks[0].0, "target": to.0, "copy": copy})).unwrap(); + let paints = spec(&s, id).series_paints; + assert_eq!((paints[1].opacity, paints[1].blend), (Some(0.4), Some(BlendMode::Multiply)), "copy {copy}"); + let source = if copy { (Some(0.4), Some(BlendMode::Multiply)) } else { (None, None) }; + assert_eq!((paints[0].opacity, paints[0].blend), source, "copy {copy}"); + } +} + +/// An Eyedropper pick of the object's transparency onto a series mark makes the graph keep the picked opacity and blend +/// mode for that series, even an opaque Normal pick onto a mark that already shows it. +#[test] +fn an_eyedropper_pick_of_object_transparency_onto_a_series_mark_is_kept() { + let (mut s, id, _, marks) = column_marks(); + let to = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[0].id; + s.execute("transparency.set", &json!({"ids": [marks[0].0], "opacity": 40, "blend": "multiply"})).unwrap(); + s.execute("appearance.copyFrom", &json!({"source": marks[0].0, "ids": [to.0]})).unwrap(); + let kept = spec(&s, id).series_paints[1].clone(); + assert_eq!((kept.opacity, kept.blend), (Some(0.4), Some(BlendMode::Multiply))); + // An opaque Normal source picked onto another opaque mark of series 0 replaces what series 0 stores. + let opaque = series(s.doc().unwrap().doc.node(id).unwrap(), 1).children().unwrap()[1].id; + s.execute("appearance.copyFrom", &json!({"source": opaque.0, "ids": [marks[1].0]})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.opacity, kept.blend), (None, None)); +} + +/// A graphic style made from an object at 50% Multiply, applied to a series mark, makes the graph keep the style's object +/// opacity and blend mode; redefining the style updates them for a mark still linked to it. +#[test] +fn a_graphic_styles_object_opacity_on_a_series_mark_is_kept() { + let (mut s, id, _, marks) = column_marks(); + let (rect, _) = blue_style(&mut s, "Blue", true); + s.execute("object.setProps", &json!({"id": rect.0, "opacity": 50, "blend": "multiply"})).unwrap(); + s.execute("graphicStyle.new", &json!({"id": rect.0, "name": "Faded"})).unwrap(); + s.execute("graphicStyle.apply", &json!({"name": "Faded", "ids": [marks[0].0]})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.opacity, kept.blend), (Some(0.5), Some(BlendMode::Multiply))); + s.execute("object.setProps", &json!({"id": rect.0, "opacity": 100, "blend": "normal"})).unwrap(); + s.execute("graphicStyle.redefine", &json!({"name": "Faded", "id": rect.0})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.opacity, kept.blend), (None, None)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.5), "one undo takes back the redefinition and what the graph stored"); + + // An opaque Normal style applied to an opaque mark replaces the opacity another mark of the series left. + let (mut s, id, _, marks) = column_marks(); + s.execute("object.setProps", &json!({"id": marks[0].0, "opacity": 40})).unwrap(); + blue_style(&mut s, "Blue", true); + s.execute("graphicStyle.apply", &json!({"name": "Blue", "ids": [marks[1].0]})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].opacity, None); +} + /// The colors of series `index`'s marks: each mark's top fill and stroke paint and its stroke weight. fn marks_of(s: &Session, id: NodeId, index: u32) -> Vec<(Paint, Paint, f64)> { let n = s.doc().unwrap().doc.node(id).unwrap().clone(); @@ -1010,19 +1202,46 @@ fn a_stroke_set_on_a_filled_line_band_does_not_repaint_it() { assert_eq!(redrawn_band(&mut s, id), (was, Paint::solid(vectorcraft_color::Color::BLACK), 0.25)); } -/// An opacity set on a Filled Lines band's stroke row is kept, without a stroke paint, and goes on the band's thin black -/// outline when the graph is drawn again. +/// An opacity and blend mode set on a Filled Lines band's stroke row are kept and drawn on its outline, which keeps its +/// black paint and 0.25 pt weight. #[test] -fn a_stroke_opacity_set_on_a_filled_line_band_goes_on_its_outline() { +fn a_filled_line_bands_outline_keeps_a_stroke_opacity_and_blend_mode() { let (mut s, id, band) = band_graph(); let row = top_row(&s, band, false); - s.execute("appearance.setItem", &json!({"ids": [band.0], "index": row, "opacity": 30})).unwrap(); - let kept = spec(&s, id).series_paints.first().cloned().unwrap_or_default(); - assert_eq!((kept.stroke.is_none(), kept.stroke_opacity), (true, Some(0.3))); + s.execute("appearance.setItem", &json!({"ids": [band.0], "index": row, "opacity": 50, "blend": "multiply"})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.stroke, kept.stroke_opacity, kept.stroke_blend), (None, Some(0.5), Some(BlendMode::Multiply))); let (_, outline, width) = redrawn_band(&mut s, id); assert_eq!((outline, width), (Paint::solid(vectorcraft_color::Color::BLACK), 0.25)); let band = series_parts(&s, id, 0)[0].clone(); - assert_eq!(band.appearance.stroke().unwrap().opacity, 0.3); + let st = band.appearance.stroke().unwrap(); + assert_eq!((st.opacity, st.blend), (0.5, BlendMode::Multiply)); +} + +/// A graphic style without a stroke applied to a marker whose stroke had paint replaces the stroke opacity and blend mode +/// the series' Filled Lines band left: the band's outline is drawn opaque and Normal again. A marker that had no stroke +/// before the style leaves them as they are. +#[test] +fn a_style_that_removes_a_marks_stroke_replaces_the_kept_stroke_opacity() { + for stroked in [true, false] { + let (mut s, id, band) = band_graph(); + let marker = series_parts(&s, id, 0)[1].id; + if stroked { + let row = top_row(&s, marker, false); + s.execute("appearance.setItem", &json!({"ids": [marker.0], "index": row, "color": "#ff0000"})).unwrap(); + } + let row = top_row(&s, band, false); + s.execute("appearance.setItem", &json!({"ids": [band.0], "index": row, "opacity": 40, "blend": "multiply"})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].stroke_opacity, Some(0.4)); + blue_style(&mut s, "Blue", false); + s.execute("graphicStyle.apply", &json!({"name": "Blue", "ids": [marker.0]})).unwrap(); + let kept = spec(&s, id).series_paints[0].clone(); + let want = if stroked { (None, None) } else { (Some(0.4), Some(BlendMode::Multiply)) }; + assert_eq!((kept.stroke_opacity, kept.stroke_blend), want, "stroked {stroked}"); + redrawn_band(&mut s, id); + let st = series_parts(&s, id, 0)[0].appearance.stroke().unwrap().clone(); + assert_eq!((Some(st.opacity).filter(|o| *o < 1.0), Some(st.blend).filter(|b| *b != BlendMode::Normal)), want, "stroked {stroked}"); + } } /// A Filled Lines band's stroke row removed with the Appearance panel or set to None with `paint.setStroke` leaves the @@ -1307,13 +1526,15 @@ fn a_paint_on_a_design_copy_or_the_other_kind_of_row_is_not_kept() { s.execute("appearance.setItem", &json!({"ids": [line.id.0], "index": row, "color": "#0000ff"})).unwrap(); assert!(spec(&s, id).series_paints[0].fill.is_none(), "a stroke edit on a line keeps no fill"); - // A blend mode on another mark of the series leaves the stored paint as that mark's own paint differs from it. + // A blend mode on another mark of the series leaves the stored paint as that mark's own paint differs from it, and + // stores only the blend mode. let (mut s, id, _, marks) = column_marks(); let fill = top_row(&s, marks[0], true); s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": fill, "color": "#ff0000"})).unwrap(); - let kept = spec(&s, id); + let mut kept = spec(&s, id).series_paints; s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": fill, "blend": "multiply"})).unwrap(); - assert_eq!(spec(&s, id), kept); + kept[0].fill_blend = Some(vectorcraft_color::BlendMode::Multiply); + assert_eq!(spec(&s, id).series_paints, kept); } /// A graphic style `name` made from a new rectangle with a blue fill at 50% opacity, and without a stroke row unless diff --git a/crates/engine/src/cmd/object/commands.rs b/crates/engine/src/cmd/object/commands.rs index f248294f2..a923ab2eb 100644 --- a/crates/engine/src/cmd/object/commands.rs +++ b/crates/engine/src/cmd/object/commands.rs @@ -251,7 +251,7 @@ pub fn specs() -> Vec { "Object Properties", [], None, - "{ids?|id?, name?, visible?, locked? (visible: false or locked: true deselects every selected object hidden or locked after the call, the key object included), opacity?: 0..100, blend?: \"Multiply\"…, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, data?: {key: \"value\" | null (removes it)} (the object's own data, SVG data-* attributes: {pivot: \"100,180\"} is data-pivot; document.node → attrs.data)}", + "{ids?|id?, name?, visible?, locked? (visible: false or locked: true deselects every selected object hidden or locked after the call, the key object included), opacity?: 0..100, blend?: \"Multiply\"…, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, data?: {key: \"value\" | null (removes it)} (the object's own data, SVG data-* attributes: {pivot: \"100,180\"} is data-pivot; document.node → attrs.data)}. On a path directly in a graph series, the graph also stores the object's opacity and blend mode (exceptions: docs/mcp.md)", has_doc, set_props ), diff --git a/crates/engine/src/cmd/object/props.rs b/crates/engine/src/cmd/object/props.rs index 716181b1e..1fb2faf76 100644 --- a/crates/engine/src/cmd/object/props.rs +++ b/crates/engine/src/cmd/object/props.rs @@ -65,7 +65,11 @@ pub(super) fn set_props(s: &mut Session, p: &Value) -> Result { }) }; let d = &s.doc()?.doc; - if ids.iter().all(|id| d.node(*id).is_some_and(has_values)) { + // A graph keeps the object opacity and blend mode set on a mark of a series for the whole series, so a value a mark + // already has still replaces the one another mark left on the graph. + let transparency = opacity.is_some() || blend.is_some(); + let stores = || transparency && crate::cmd::graph::series_object_opacity_differs(d, &ids); + if ids.iter().all(|id| d.node(*id).is_some_and(has_values)) && !stores() { // Hidden or locked objects can't stay selected. if hides { s.select(|d, sel| sel.deselect_uneditable(d))?; @@ -118,6 +122,9 @@ pub(super) fn set_props(s: &mut Session, p: &Value) -> Result { }); } } + if transparency { + crate::cmd::graph::capture_series_object_opacity(d, &ids); + } // Hidden or locked objects can't stay selected. if hides { sel.deselect_uneditable(d); diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index a2cd9a14a..eadf47847 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -48,7 +48,7 @@ pub fn specs() -> Vec { "Default Fill and Stroke", [], Some("D"), - "{ids?|id?} white fill and 1 pt black stroke for the selection and the defaults; type gets black fill and no stroke. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md)", + "{ids?|id?} white fill and 1 pt black stroke for the selection and the defaults; type gets black fill and no stroke. 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)", has_doc, default_paint ), @@ -73,7 +73,7 @@ pub fn specs() -> Vec { "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)", + "{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). 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)", has_doc, transparency ), @@ -855,7 +855,7 @@ fn swap(s: &mut Session, p: &Value) -> Result { n.appearance.set_stroke(f); } // The swap writes the paint of the topmost fill and stroke, not their opacity. - let kept = super::appearance::capture_series_writes(&before, d, &ids, (true, true), (false, false), true); + let kept = super::appearance::capture_series_writes(&before, d, &ids, (true, true), (false, false), true, false); Ok(super::appearance::count_changed(d, &before, &ids) + usize::from(kept)) })?; } @@ -884,7 +884,7 @@ fn default_paint(s: &mut Session, p: &Value) -> Result { _ => n.appearance = Appearance::default_art().into(), } } - let kept = super::appearance::capture_series_writes(&before, d, &ids, (true, true), (true, true), false); + let kept = super::appearance::capture_series_writes(&before, d, &ids, (true, true), (true, true), false, false); Ok(super::appearance::count_changed(d, &before, &ids) + usize::from(kept)) })?; } diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 5981396f1..d73f01eaa 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -30,7 +30,7 @@ pub fn specs() -> Vec { "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 and opacity of the topmost visible fill and stroke (with `add`: of the kinds of row the style shows; 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, 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 ), @@ -67,7 +67,7 @@ pub fn specs() -> Vec { "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 and opacity of the topmost visible fill and stroke (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; 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}", has_doc, style_redefine ), @@ -349,14 +349,14 @@ fn style_apply(s: &mut Session, p: &Value) -> Result { // 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)); - super::appearance::capture_series_writes(&before, d, &ids, kinds, kinds, false); + super::appearance::capture_series_writes(&before, d, &ids, kinds, kinds, false, false); } else { d.graphic_style_id(i); let g = d.graphic_styles[i].clone(); for id in &ids { apply_style(d, *id, &g, chars); } - super::appearance::capture_series_writes(&before, d, &ids, (true, true), (true, true), false); + super::appearance::capture_series_writes(&before, d, &ids, (true, true), (true, true), false, true); } Ok(()) })?; @@ -476,7 +476,7 @@ fn style_redefine(s: &mut Session, p: &Value) -> Result { for nid in &follow { apply_style(d, *nid, &g, chars); } - super::appearance::capture_series_writes(&before, d, &follow, (true, true), (true, true), false); + super::appearance::capture_series_writes(&before, d, &follow, (true, true), (true, true), false, true); set_link(d, &[src], Some(id)); Ok(()) })?; diff --git a/docs/mcp.md b/docs/mcp.md index 6b63cd585..a945dc6ac 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -571,16 +571,23 @@ stroke instead of the whole object: `run_command effect.apply {"effect": "path.o On a path directly inside a graph series, `appearance.setItem`, `appearance.addFill`, `appearance.addStroke`, `appearance.removeItem`, `appearance.moveItem`, `appearance.duplicateItem`, `appearance.clear`, `appearance.reduceToBasic`, `appearance.showAllHidden`, `appearance.transfer`, `appearance.copyFrom`, `paint.default`, -`graphicStyle.apply` and `graphicStyle.redefine` (on the marks still linked to the style) store the paint, weight and -opacity of the topmost visible fill and stroke on the graph. `graphicStyle.apply` with `add` stores only the kinds of row -that are visible in the style. `paint.swap` stores the swapped paints and the stroke weight, keeps the stored opacity, -and does not store a hidden topmost row that it swaps. The graph does not store the rest of a graphic style's look (more -fill and stroke rows, effects, the object's opacity and blend mode, and the link to the style), and a mark loses it when -the graph is drawn again. When the graph is drawn again, columns, bars, wedges, areas, markers and legend swatches take -the stored fill and stroke. A thin line takes the stored stroke and has no fill. A Filled Lines band takes the stored -fill, or the stored stroke when no fill is stored and that stroke has paint, and has a 0.25 pt black outline, so these -commands do not store a stroke paint or weight for a series that draws a band. Except for `paint.swap`, they still store -a stroke opacity set on a band, and the band's outline takes it. Graph designs keep their own paint. A row that had no +`graphicStyle.apply` and `graphicStyle.redefine` (on the marks still linked to the style) store the paint, weight, +opacity and blend mode of the topmost visible fill and stroke on the graph. `graphicStyle.apply` with `add` stores only +the kinds of row that are visible in the style. `paint.swap` stores the swapped paints and the stroke weight, keeps the +stored opacity and blend mode, and does not store a hidden topmost row that it swaps. `transparency.set` and +`object.setProps` with an opacity or blend mode for the object, `appearance.clear`, `appearance.transfer`, +`appearance.copyFrom` with Transparency picked up, `graphicStyle.apply` without `add` and `graphicStyle.redefine` store +the object's opacity and blend mode on the graph, and the other commands above store them when they change them on a +mark. The graph stores an opacity and blend mode as a pair, taken from the mark the command edited: a command that sets +only the opacity of one mark's object or row also stores that mark's blend mode in place of the one another mark +stored. Every path directly in the series takes the stored values when the graph is drawn again. The graph does not +store the rest of a graphic style's look (more fill and stroke rows, effects, and the link to the style), and a mark +loses it when the graph is drawn again. When the graph is drawn again, columns, bars, wedges, areas, markers and legend +swatches take the stored fill and stroke. A thin line takes the stored stroke and has no fill. A Filled Lines band takes +the stored fill, or the stored stroke when no fill is stored and that stroke has paint, and has a 0.25 pt black +outline, so these commands do not store a stroke paint or weight for a series that draws a band. Except for +`paint.swap`, they still store a stroke opacity and blend mode set on a band, and the band's outline takes them. Graph +designs keep their own paint. A row that had no paint before the command and has none after it does not change what 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 diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 134285477..579d08afe 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -896,7 +896,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur View › Hide Edges hides the Live Corners widgets, the bounding box and the type widget, and the selection tools then do not answer a press on them or change the cursor over them. - **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). - **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); 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, and the paint, weight and opacity 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, 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 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, 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 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). - **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. - **Other:** Eyedropper, Gradient annotator, Artboard (Alt-drag duplicates an artboard, and its art with Move/Copy Artwork with Artboard on; Copy, Cut and Paste take the artboard with its art, Paste right of the last artboard, Paste in Place where it was, into this or another document, #446; Object › Artboards › Rearrange All Artboards lays them out in a grid by row or by column, in one row or one column, left to right or right to left, from the menu, the Artboards panel or Properties' Rearrange All, #681; Scale Artwork with Artboard, in the Control bar and Properties beside Move Artwork with Artboard, resizes the artboard proportionally with the art fully inside it and its guides, strokes and corners per the Scale preferences, #602), Measure, Type, Hand, Zoom, Rotate View.