From 5c0f22b345dfde6bcf39154ad0e619a6fb71f13c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 07:31:54 -0800 Subject: [PATCH] Graphs: Appearance edits on a label are kept for every label of its kind A fill or stroke row that the Appearance commands gave a graph label, its object opacity, and the characters the Eyedropper gave it lasted only until the graph was drawn again: the graph stored nothing for a label's rows, and the Eyedropper's character attributes were not stored. - The graph keeps, for value axis numbers, category labels and legend labels, the paint, weight, opacity and blend mode of a label's topmost visible fill and stroke row, their order and place relative to the Characters row, and the label's object opacity and blend mode (labelStyles valuesRows, categoriesRows, legendRows). Every label of the kind gets those rows when the graph is drawn again, with a placed gradient fitted to each label. - Every appearance.* command stores them, and so do graphicStyle.apply and redefine and the paint, stroke, gradient and transparency edits aimed at a row, even when the label already showed them. transparency.set without a row and object.setProps store only the object opacity and blend mode. With several labels of a kind, the last one edited wins. - appearance.copyFrom onto all of a label's text stores the character attributes it applied and, from type, the paragraph attributes; a transparency pick stores the object opacity and blend mode, and append stores the rows. - paint.setFill and paint.setStroke store a character fill or stroke only when the characters take the paint (the whole label, or all of its text highlighted with the Type tool); aimed at a row, they store the row. - Document Color Mode and swatch edits reach the kept rows. Docs: docs/mcp.md, the commands' params text, docs/target-app-parity.md. --- crates/doc/src/graph.rs | 26 ++ crates/doc/src/lib.rs | 4 +- crates/doc/src/swatches.rs | 20 +- crates/engine/src/cmd/appearance.rs | 111 +++++-- crates/engine/src/cmd/colormgmt.rs | 5 + crates/engine/src/cmd/freeform.rs | 2 +- crates/engine/src/cmd/gradient.rs | 4 +- crates/engine/src/cmd/graph.rs | 6 +- crates/engine/src/cmd/graph/labels.rs | 50 ++- crates/engine/src/cmd/graph/series.rs | 146 +++++++++ crates/engine/src/cmd/graph/tests.rs | 401 +++++++++++++++++++++++ crates/engine/src/cmd/object/commands.rs | 2 +- crates/engine/src/cmd/object/props.rs | 8 +- crates/engine/src/cmd/paint.rs | 38 ++- crates/engine/src/cmd/stroke.rs | 2 +- crates/engine/src/cmd/style.rs | 7 +- crates/engine/src/tests/swatches.rs | 10 +- docs/mcp.md | 16 + docs/target-app-parity.md | 2 +- 19 files changed, 805 insertions(+), 55 deletions(-) diff --git a/crates/doc/src/graph.rs b/crates/doc/src/graph.rs index f3b655d76a..43074a4e80 100644 --- a/crates/doc/src/graph.rs +++ b/crates/doc/src/graph.rs @@ -166,6 +166,16 @@ pub struct LabelStyles { /// The anti-aliasing method the legend labels keep once set. `None` draws them Sharp. #[serde(skip_serializing_if = "Option::is_none")] pub legend_anti_alias: Option, + /// The object fill and stroke rows the value axis numbers keep once set with the Appearance controls ([`LabelRows`]). + /// `None`: no rows, opaque and Normal. + #[serde(skip_serializing_if = "Option::is_none")] + pub values_rows: Option, + /// The object fill and stroke rows the category labels keep, as [`Self::values_rows`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub categories_rows: Option, + /// The object fill and stroke rows the legend labels keep, as [`Self::values_rows`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub legend_rows: Option, } impl LabelStyles { @@ -355,6 +365,22 @@ pub struct SeriesPaint { pub blend: Option, } +/// The object rows one kind of graph label keeps ([`LabelStyles::values_rows`]): the paint, weight, opacity and blend mode +/// of a label's topmost visible fill and stroke row (an absent paint: no row of that kind), the label's object opacity and +/// blend mode, and where the two rows paint relative to each other and to the characters. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LabelRows { + #[serde(flatten)] + pub paint: SeriesPaint, + /// How many of the kept rows paint below the characters, counted from the bottom. Absent: none. + #[serde(skip_serializing_if = "Option::is_none")] + pub below: Option, + /// The stroke row paints below the fill row. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub stroke_under: bool, +} + /// Graph data and options. `rows` are categories (one per row of the Graph Data window), each /// holding one value per series; `series` are the legend labels (the first row of the window). /// Scatter graphs read each series as (y, x) column pairs, like Illustrator. diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 63a2676f77..bc5a4a6272 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -92,8 +92,8 @@ pub use compound_shape::ShapeMode; pub use corners::LiveCorners; pub use depth::{MAX_DEPTH, MAX_SYMBOL_NODES}; pub use graph::{ - ColumnDesign, ColumnType, Fractions, GraphDesign, GraphKind, GraphSpec, LabelStyles, MAX_GRAPH_CATEGORIES, MAX_GRAPH_SERIES, PiePosition, - PieSort, SeriesPaint, TickLength, ValueAxisSide, + ColumnDesign, ColumnType, Fractions, GraphDesign, GraphKind, GraphSpec, LabelRows, LabelStyles, MAX_GRAPH_CATEGORIES, MAX_GRAPH_SERIES, + PiePosition, PieSort, SeriesPaint, TickLength, ValueAxisSide, }; pub use hit::{Hit, HitKind}; pub use links::{LinkInfo, PlacementOptions}; diff --git a/crates/doc/src/swatches.rs b/crates/doc/src/swatches.rs index ba79f7c280..844aa8c810 100644 --- a/crates/doc/src/swatches.rs +++ b/crates/doc/src/swatches.rs @@ -388,7 +388,9 @@ pub fn graph_paints(n: &Node) -> impl Iterator { let g = n.extras().graph.as_deref(); let series = g.into_iter().flat_map(|g| &g.series_paints).flat_map(|s| [s.fill.as_ref(), s.stroke.as_ref()]).flatten(); let labels = g.into_iter().flat_map(|g| [&g.label_styles.values, &g.label_styles.categories, &g.label_styles.legend]).flatten(); - series.chain(labels.flat_map(|st| [&st.fill, &st.stroke])) + let l = g.map(|g| &g.label_styles); + let rows = l.into_iter().flat_map(|l| [&l.values_rows, &l.categories_rows, &l.legend_rows]).flatten(); + series.chain(labels.flat_map(|st| [&st.fill, &st.stroke])).chain(rows.flat_map(|r| [r.paint.fill.as_ref(), r.paint.stroke.as_ref()]).flatten()) } /// Mutable [`graph_paints`] of graph `g`, in the same order. @@ -396,7 +398,10 @@ fn graph_paints_mut(g: &mut crate::graph::GraphSpec) -> impl Iterator, _: &mut f32| s.as_deref() == Some("Brand") && std::mem::replace(c, blue) != blue; - assert_eq!(d.map_solid_paints(&mut to_blue), 4); + assert_eq!(d.map_solid_paints(&mut to_blue), 5); let g = d.node(a).unwrap().extras().graph.clone().unwrap(); assert_eq!(g.series_paints[0].fill, Some(linked(blue, "Brand"))); assert_eq!(g.series_paints[0].stroke, Some(linked(blue, "Brand"))); let legend = g.label_styles.legend.unwrap(); assert_eq!((legend.fill, legend.stroke), (linked(blue, "Brand"), Paint::None)); + assert_eq!(g.label_styles.values_rows.unwrap().paint.fill, Some(linked(blue, "Brand"))); let mut cleared = |p: &Paint| (*p == linked(blue, "Brand")).then_some(Paint::None); - assert_eq!(d.map_paints(&mut cleared), 4, "the fill and the graph's three"); + assert_eq!(d.map_paints(&mut cleared), 5, "the fill and the graph's four"); assert!(graph_paints(d.node(a).unwrap()).all(Paint::is_none)); } diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 5fb747b7e2..e30ba811d4 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -48,7 +48,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 (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)", + "{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); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", has_doc, |s, p| add_item(s, p, true) ), @@ -57,7 +57,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 (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)", + "{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); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", has_doc, |s, p| add_item(s, p, false) ), @@ -66,7 +66,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 (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)", + "{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); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", has_doc, clear_appearance ), @@ -75,7 +75,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 (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?|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); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", has_doc, reduce_basic ), @@ -84,7 +84,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 (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)", + "{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); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", has_doc, set_item ), @@ -93,7 +93,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 (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)", + "{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); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", has_doc, remove_item ), @@ -111,7 +111,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 (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)", + "{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); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind", has_doc, duplicate_item ), @@ -120,7 +120,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 (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}", + "{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); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {index: where the item landed | contents: the row's slot}", has_doc, move_item ), @@ -129,7 +129,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 using effective character dimensions while preserving target transforms) 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 fill and stroke (solid, none, gradient or pattern) for this instance only, as the Eyedropper Options allow, and 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}", + "{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 using effective character dimensions while preserving target transforms) 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 fill and stroke (solid, none, gradient or pattern) for this instance only, as the Eyedropper Options allow, and 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); on a graph label, the graph stores, for every label of its kind, the character attributes the pick gives all of its text, its object opacity and blend mode, and with `append` its topmost visible fill and stroke rows → {ids}", has_doc, copy_from ), @@ -147,7 +147,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 (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}", + "{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); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {ids}", has_doc, show_all_hidden ), @@ -165,7 +165,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 (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}", + "{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); on a graph label, the graph stores that label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {source, target}", has_doc, transfer ), @@ -545,15 +545,21 @@ pub(crate) fn edit_items_then( let per = item.per_target(s, ids); super::overprint::edit_counted(s, label, |d| { let mut edited = Vec::new(); + // The objects whose rows `f` edited: on type, no row means its characters. + let mut rows = Vec::new(); for (id, item) in ids.iter().zip(&per) { let Some(item) = item else { continue }; let Some(n) = d.node_mut(*id) else { continue }; let index = item.resolve(&n.appearance, fill, cmd)?; f(n, index)?; edited.push(*id); + if index.is_some() { + rows.push(*id); + } } let more = after(&before, d, &edited)?; - Ok(count_changed(d, &before, &edited) + usize::from(more)) + let labels = super::graph::capture_label_rows(d, &rows); + Ok(count_changed(d, &before, &edited) + usize::from(more || labels)) })?; Ok(()) } @@ -690,6 +696,7 @@ fn add_item(s: &mut Session, p: &Value, fill: bool) -> Result { let mut rows = top_paint_changed(&before, d, &ids); rows.extend(painted_rows(d, added)); super::graph::capture_series_top_paints(d, &rows); + super::graph::capture_label_rows(d, &ids); Ok(()) })?; // A fill added above the active row becomes the active row; otherwise the rows above the new @@ -712,7 +719,8 @@ 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)); let object = super::graph::capture_series_object_opacity(d, &ids); - Ok(count_changed(d, &before, &ids) + usize::from(kept || painted || object)) + let labels = super::graph::capture_label_rows(d, &ids); + Ok(count_changed(d, &before, &ids) + usize::from(kept || painted || object || labels)) })?; s.active_appearance_item = None; ok() @@ -911,8 +919,10 @@ fn transfer(s: &mut Session, p: &Value) -> Result { 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); + // The targets last: on two labels of one kind, the target's rows are the ones the kind keeps. + let labels = super::graph::capture_label_rows(d, &changed); let all: Vec = from.iter().chain(&targets).chain(&group).copied().collect(); - Ok(count_changed(d, &before, &all) + usize::from(kept || painted || object)) + Ok(count_changed(d, &before, &all) + usize::from(kept || painted || object || labels)) })?; s.active_appearance_item = None; Ok(json!({ "source": source.0, "target": target.0 })) @@ -968,7 +978,8 @@ fn reduce_basic(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 labels = super::graph::capture_label_rows(d, &ids); + Ok(count_changed(d, &before, &ids) + usize::from(kept || painted || labels)) })?; s.active_appearance_item = None; ok() @@ -1043,7 +1054,8 @@ fn set_item(s: &mut Session, p: &Value) -> Result { let mut rows = top_paint_changed(&before, d, &ids); rows.extend(painted_rows(d, written)); let painted = super::graph::capture_series_top_paints(d, &rows); - Ok(count_changed(d, &before, &ids) + usize::from(kept || painted)) + let labels = super::graph::capture_label_rows(d, &ids); + Ok(count_changed(d, &before, &ids) + usize::from(kept || painted || labels)) })?; if let Some(pa) = &paint { s.remember_paint(pa); @@ -1089,7 +1101,8 @@ fn remove_item(s: &mut Session, p: &Value) -> Result { } let kept = super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &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 labels = super::graph::capture_label_rows(d, &ids); + Ok(count_changed(d, &before, &ids) + usize::from(kept || painted || labels)) })?; // The rows removed from the active row's object, which need not come first in `ids`. let active = s.active_appearance_item.as_ref().and_then(|a| a.objects.first().copied()); @@ -1173,6 +1186,7 @@ fn duplicate_item(s: &mut Session, p: &Value) -> Result { let mut rows = top_paint_changed(&before, d, &ids); rows.extend(painted_rows(d, added)); super::graph::capture_series_top_paints(d, &rows); + super::graph::capture_label_rows(d, &ids); Ok(()) })?; // Rows at or above each copy move up by one (by the block, for copies placed at `to`). @@ -1212,6 +1226,7 @@ fn show_all_hidden(s: &mut Session, p: &Value) -> Result { } super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &shown)); super::graph::capture_series_top_paints(d, &top_paint_changed(&before, d, &shown)); + super::graph::capture_label_rows(d, &shown); Ok(shown) })?; Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>() })) @@ -1257,7 +1272,8 @@ fn move_item(s: &mut Session, p: &Value) -> Result { } let kept = super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &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 labels = super::graph::capture_label_rows(d, &ids); + Ok(count_changed(d, &before, &ids) + usize::from(kept || painted || labels)) })?; // The moved row stays the active one; the rows it passed shift by one. s.remap_appearance_item(&ids, |a| { @@ -1302,7 +1318,8 @@ fn move_items(s: &mut Session, p: &Value, ids: &[NodeId], to: usize) -> Result Result { slot.get_or_insert(n.appearance.contents_at()); } slot.ok_or_else(|| bad("appearance.moveItem", "only groups, layers, type and images have a Contents (Characters) row"))?; - Ok(count_changed(d, &before, ids)) + // A graph label's kind keeps where its rows paint relative to the characters. + let labels = super::graph::capture_label_rows(d, ids); + Ok(count_changed(d, &before, ids) + usize::from(labels)) })?; Ok(json!({ "contents": slot })) } @@ -1489,9 +1508,46 @@ fn copy_from(s: &mut Session, p: &Value) -> Result { if (targets.is_empty() && ranges.is_empty()) || attrs.is_empty() { return Ok(finish(s, &[])); } + // What a graph keeps for the kind of a label whose whole text took the pick: the attributes the pick gave its + // characters, with a picked gradient fitted to each label, and the picked paragraph attributes. + let source_rows = picked.placed(None); + let kept_text = |id: NodeId, st: &mut CharStyle| { + Picked::run(st, &source_rows, &attrs, characters.get(&id)); + if attrs.appearance.fill.color { + st.fill = super::gradient::unplaced(&st.fill); + } + if attrs.appearance.stroke.color { + st.stroke = super::gradient::unplaced(&st.stroke); + } + }; + let kept_para = picked.text.as_ref().filter(|_| attrs.paragraph).map(|(_, para)| para); + // Only a pick that applies character attributes or the paints, overprint and stroke options characters take changes + // what a kind keeps for its characters. + let (f, k) = (attrs.appearance.fill, attrs.appearance.stroke); + let to_characters = (attrs.character && picked.text.is_some()) + || f.color + || f.overprint + || k.color + || k.overprint + || k.weight + || k.cap + || k.join + || k.miter + || k.dash; + let kept_text: Option = to_characters.then_some(&kept_text); if !ranges.is_empty() { let ids: Vec = ranges.iter().map(|(f, _)| *f).collect(); let before = s.doc()?.doc.clone(); + // The labels whose whole text is highlighted. + let whole: Vec = ranges + .iter() + .filter(|(id, range)| match (range, before.node(*id).map(|n| &n.kind)) { + (None, _) => true, + (Some((start, end)), Some(NodeKind::Text(t))) => *start == 0 && *end >= vectorcraft_text::edit::runs_len(&t.runs), + _ => false, + }) + .map(|(id, _)| *id) + .collect(); super::overprint::edit_counted(s, "Eyedropper", |d| { for &(id, range) in &ranges { let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) }; @@ -1504,7 +1560,8 @@ fn copy_from(s: &mut Session, p: &Value) -> Result { super::typecmd::refresh_bounds(t); } check_text_bounds(d)?; - Ok(count_changed(d, &before, &ids)) + let labels = super::graph::capture_label_text(d, &whole, kept_text, kept_para)?; + Ok(count_changed(d, &before, &ids) + usize::from(labels)) })?; return Ok(finish(s, &ids)); } @@ -1559,7 +1616,17 @@ fn copy_from(s: &mut Session, p: &Value) -> Result { // 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)) + // Graph labels keep their new rows, characters and paragraphs per kind of label. + // On type, only `append` adds rows and a transparency pick changes the object's opacity and blend mode; the other + // attributes go to the characters. + let rows = if append { + super::graph::capture_label_rows(d, &targets) + } else { + attrs.appearance.transparency && super::graph::capture_label_object_opacity(d, &targets) + }; + // `append` adds rows and leaves the characters as they were. + let text = !append && super::graph::capture_label_text(d, &targets, kept_text, kept_para)?; + Ok(count_changed(d, &before, &targets) + usize::from(kept || painted || object || rows || text)) })?; Ok(finish(s, &targets)) } diff --git a/crates/engine/src/cmd/colormgmt.rs b/crates/engine/src/cmd/colormgmt.rs index c94de5d3b2..66295f50f2 100644 --- a/crates/engine/src/cmd/colormgmt.rs +++ b/crates/engine/src/cmd/colormgmt.rs @@ -666,6 +666,11 @@ fn map_graph_colors(g: &mut vectorcraft_doc::GraphSpec, f: &mut dyn FnMut(&Color map_paint_colors(&mut st.fill, f); map_paint_colors(&mut st.stroke, f); } + for r in [&mut styles.values_rows, &mut styles.categories_rows, &mut styles.legend_rows].into_iter().flatten() { + for paint in [&mut r.paint.fill, &mut r.paint.stroke].into_iter().flatten() { + map_paint_colors(paint, f); + } + } } /// `f` applied to solid, gradient-stop and freeform-control colors, with any swatch link. diff --git a/crates/engine/src/cmd/freeform.rs b/crates/engine/src/cmd/freeform.rs index f4ec784af3..bdba729211 100644 --- a/crates/engine/src/cmd/freeform.rs +++ b/crates/engine/src/cmd/freeform.rs @@ -19,7 +19,7 @@ pub fn specs() -> Vec { "Add Freeform Point", [], None, - "{at: [x,y] (document coordinates), color? (default: the gradient's colour there; #rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), opacity?: 0..1 (or 0..100), spread?: 0..1 (or 0..100 %; the radius of pure colour around the point, a fraction of half the object's larger side; default 0), line?: true (join it to the selected point: extends the line ending there, else starts one), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\"} add a point to the freeform gradient (an unplaced one keeps its automatic points first) and select it. On a path directly in a graph series, the graph also stores the edited gradient when it is on the topmost visible fill or stroke, as for the other paint.freeform edits (docs/mcp.md) → {index}", + "{at: [x,y] (document coordinates), color? (default: the gradient's colour there; #rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), opacity?: 0..1 (or 0..100), spread?: 0..1 (or 0..100 %; the radius of pure colour around the point, a fraction of half the object's larger side; default 0), line?: true (join it to the selected point: extends the line ending there, else starts one), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\"} add a point to the freeform gradient (an unplaced one keeps its automatic points first) and select it. On a path directly in a graph series, the graph also stores the edited gradient when it is on the topmost visible fill or stroke, as for the other paint.freeform edits (docs/mcp.md); on a graph label's row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind → {index}", has_doc, add_point ), diff --git a/crates/engine/src/cmd/gradient.rs b/crates/engine/src/cmd/gradient.rs index 9b41eea001..54ca07da7e 100644 --- a/crates/engine/src/cmd/gradient.rs +++ b/crates/engine/src/cmd/gradient.rs @@ -24,7 +24,7 @@ pub fn specs() -> Vec { "Gradient", ["Window", "Gradient"], None, - "{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical, sweeps the stops once around the center, clockwise from the vector, with a seam where the last stop meets the first; diamond runs the stops from the center out to a square with a corner at the end of the vector; freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual|hue-clockwise|hue-counterclockwise (how the colours between the stops mix: in display RGB, perceptually (OKLab) so the steps look even, or around the hue wheel: hue, saturation and brightness each change evenly from one stop to the next, the hue angle falling with hue-clockwise (red, magenta, blue, cyan, green, yellow) and rising with hue-counterclockwise; two stops whose hues are less than half a color level apart keep that hue; a stop whose RGB numbers are less than half a color level apart is gray and takes the hue of the stop it mixes with, and black that stop's saturation too; these two methods are VectorCraft's own, and SVG and PDF exports draw their gradients as RGB images, CMYK and spot color stops included; SVG exports draw a Perceptual gradient as an image, and PDF exports do too unless it is linear, radial, conic or diamond, has spot color stops, every other stop is paper white or written in CMYK or gray (an export that converts colors to a CMYK destination profile writes RGB and Lab stops in CMYK, and so does a CMYK document unless the export converts colors to an RGB destination profile), it is not a dithered fill and its curve does not have too many stops: then PDF writes tints of one spot ink as a Separation shading in that ink, and spot inks mixed with other spot, CMYK or gray inks as a DeviceN shading; freeform gradients mix their own way), spread?: pad|repeat|reflect (linear, radial, conic and diamond: what the gradient paints past the ends of its stops: pad carries the end colors on; repeat and reflect run the stops `repeats` times along the vector (around the circle, for a conic) and on past both ends, reflect mirroring every other run; an error on a freeform gradient), repeats?: 1..100 (a whole number: how many times repeat and reflect run the stops along the vector; VectorCraft's own limit; pad keeps it unused), stops?: [{offset 0..1, color? (needed without swatch; #rrggbbaa and #rgba are refused), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg counter-clockwise (0: the gradient runs left to right; radial, conic and diamond: the vector turns about the center), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep and is an error for the other kinds), reverse?: bool (the same ramp the other way round: the stops in reverse order, and a hue method turned the other way unless `method` is given too), dither?: bool (fills only: a linear, radial, conic or diamond gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient. On a path directly in a graph series, the graph also stores the gradient when it is on the topmost visible fill or stroke (docs/mcp.md)", + "{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical, sweeps the stops once around the center, clockwise from the vector, with a seam where the last stop meets the first; diamond runs the stops from the center out to a square with a corner at the end of the vector; freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual|hue-clockwise|hue-counterclockwise (how the colours between the stops mix: in display RGB, perceptually (OKLab) so the steps look even, or around the hue wheel: hue, saturation and brightness each change evenly from one stop to the next, the hue angle falling with hue-clockwise (red, magenta, blue, cyan, green, yellow) and rising with hue-counterclockwise; two stops whose hues are less than half a color level apart keep that hue; a stop whose RGB numbers are less than half a color level apart is gray and takes the hue of the stop it mixes with, and black that stop's saturation too; these two methods are VectorCraft's own, and SVG and PDF exports draw their gradients as RGB images, CMYK and spot color stops included; SVG exports draw a Perceptual gradient as an image, and PDF exports do too unless it is linear, radial, conic or diamond, has spot color stops, every other stop is paper white or written in CMYK or gray (an export that converts colors to a CMYK destination profile writes RGB and Lab stops in CMYK, and so does a CMYK document unless the export converts colors to an RGB destination profile), it is not a dithered fill and its curve does not have too many stops: then PDF writes tints of one spot ink as a Separation shading in that ink, and spot inks mixed with other spot, CMYK or gray inks as a DeviceN shading; freeform gradients mix their own way), spread?: pad|repeat|reflect (linear, radial, conic and diamond: what the gradient paints past the ends of its stops: pad carries the end colors on; repeat and reflect run the stops `repeats` times along the vector (around the circle, for a conic) and on past both ends, reflect mirroring every other run; an error on a freeform gradient), repeats?: 1..100 (a whole number: how many times repeat and reflect run the stops along the vector; VectorCraft's own limit; pad keeps it unused), stops?: [{offset 0..1, color? (needed without swatch; #rrggbbaa and #rgba are refused), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg counter-clockwise (0: the gradient runs left to right; radial, conic and diamond: the vector turns about the center), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep and is an error for the other kinds), reverse?: bool (the same ramp the other way round: the stops in reverse order, and a hue method turned the other way unless `method` is given too), dither?: bool (fills only: a linear, radial, conic or diamond gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient. On a path directly in a graph series, the graph also stores the gradient when it is on the topmost visible fill or stroke (docs/mcp.md); on a graph label's row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind", has_doc, edit_gradient ), @@ -33,7 +33,7 @@ pub fn specs() -> Vec { "Gradient Vector", [], None, - "{start?: [x,y], end?: [x,y] (document coordinates; both or neither: omitted, the vector stays), aspect?: % (radial, conic and diamond: the extent's height / width across the vector, a negative one mirroring a conic's sweep and an error for the other kinds; default: kept), focal?: [x,y] (document coordinates) | null (radial: the focal point, where the first stop sits, pulled inside the extent ellipse; null centres it; default: it keeps its place in the ellipse; an error on a conic or diamond gradient), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (alias: index; omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\", index?: appearance item index (as item)} set the gradient vector, aspect ratio and focal point (solid paints become the default gradient; type objects set it on their runs, in text space). On a path directly in a graph series, the graph also stores the gradient with its placement when it is on the topmost visible fill or stroke (docs/mcp.md)", + "{start?: [x,y], end?: [x,y] (document coordinates; both or neither: omitted, the vector stays), aspect?: % (radial, conic and diamond: the extent's height / width across the vector, a negative one mirroring a conic's sweep and an error for the other kinds; default: kept), focal?: [x,y] (document coordinates) | null (radial: the focal point, where the first stop sits, pulled inside the extent ellipse; null centres it; default: it keeps its place in the ellipse; an error on a conic or diamond gradient), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (alias: index; omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\", index?: appearance item index (as item)} set the gradient vector, aspect ratio and focal point (solid paints become the default gradient; type objects set it on their runs, in text space). On a path directly in a graph series, the graph also stores the gradient with its placement when it is on the topmost visible fill or stroke (docs/mcp.md); on a graph label's row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind, with the gradient fitted to each label", has_doc, set_gradient_geom ), diff --git a/crates/engine/src/cmd/graph.rs b/crates/engine/src/cmd/graph.rs index 4452991998..e283afd752 100644 --- a/crates/engine/src/cmd/graph.rs +++ b/crates/engine/src/cmd/graph.rs @@ -45,9 +45,9 @@ use generate::generate; #[cfg(test)] 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_top_paints, paint_targets, series_group_marks, series_group_marks_where, series_marks_in, series_object_opacity_differs, - shown_paint, + LabelTextChange, capture_label_anti_alias, capture_label_object_opacity, capture_label_paragraphs, capture_label_rows, capture_label_styles, + capture_label_text, capture_series_object_opacity, capture_series_opacity, capture_series_top_paints, label_object_opacity_differs, + paint_targets, series_group_marks, series_group_marks_where, series_marks_in, series_object_opacity_differs, shown_paint, }; #[cfg(test)] diff --git a/crates/engine/src/cmd/graph/labels.rs b/crates/engine/src/cmd/graph/labels.rs index b115714256..290e099500 100644 --- a/crates/engine/src/cmd/graph/labels.rs +++ b/crates/engine/src/cmd/graph/labels.rs @@ -73,6 +73,44 @@ pub(super) fn label_anti_alias(styles: &vectorcraft_doc::LabelStyles, role: Role kept.unwrap_or_default() } +/// The object rows labels of `role` keep, if any. +fn kept_rows(styles: &vectorcraft_doc::LabelStyles, role: Role) -> Option<&vectorcraft_doc::LabelRows> { + match role { + Role::Value => styles.values_rows.as_ref(), + Role::Category => styles.categories_rows.as_ref(), + Role::Legend => styles.legend_rows.as_ref(), + } +} + +/// Give label `n` the object rows `kept` keeps: a fill row and a stroke row for the kept paints, in their kept order and +/// place relative to the characters, and the object opacity and blend mode. An opacity read from a file is held to 0..1 +/// and a weight to 0..1000 pt; one that is not a number is left out (a stroke then takes 1 pt). +fn apply_label_rows(n: &mut Node, kept: &vectorcraft_doc::LabelRows) { + use vectorcraft_doc::{AppearanceItem, FillLayer, StrokeLayer}; + let rows = &kept.paint; + let unit = |o: Option| o.filter(|o| o.is_finite()).map(|o| o.clamp(0.0, 1.0)); + let fill = rows.fill.as_ref().map(|paint| { + let mut f = FillLayer::new(paint.clone()); + f.opacity = unit(rows.fill_opacity).unwrap_or(1.0); + f.blend = rows.fill_blend.unwrap_or_default(); + AppearanceItem::Fill(f) + }); + let stroke = rows.stroke.as_ref().map(|paint| { + let width = rows.stroke_width.filter(|w| w.is_finite()).map_or(1.0, |w| w.clamp(0.0, 1000.0)); + let mut st = StrokeLayer::new(paint.clone(), width); + st.opacity = unit(rows.stroke_opacity).unwrap_or(1.0); + st.blend = rows.stroke_blend.unwrap_or_default(); + AppearanceItem::Stroke(st) + }); + let items: Vec = if kept.stroke_under { [stroke, fill] } else { [fill, stroke] }.into_iter().flatten().collect(); + let mut ap = Appearance::clone(&n.appearance); + ap.items = items; + ap.set_contents_at(usize::from(kept.below.unwrap_or(0))); + n.appearance = ap.into(); + n.opacity = unit(rows.opacity).unwrap_or(1.0); + n.blend = rows.blend.unwrap_or_default(); +} + /// How big labels of `role` are set: a kept style's size, else 9 pt. The labels' spacing follows it. pub(super) fn label_size(styles: &vectorcraft_doc::LabelStyles, role: Role) -> f64 { label_style(styles, role).size @@ -90,7 +128,15 @@ impl Gen<'_> { } pub(super) fn text(&mut self, role: Role, at: Point, s: &str, justify: Justify) -> Arc { let t = self.text_object(role, at, s, justify); - Arc::new(Node::new(self.d.alloc_id(), NodeKind::Text(Box::new(t)))) + self.label_node(role, t) + } + /// Label text `t` of `role` as a node, with the object rows its kind keeps. + fn label_node(&mut self, role: Role, t: TextObject) -> Arc { + let mut n = Node::new(self.d.alloc_id(), NodeKind::Text(Box::new(t))); + if let Some(rows) = kept_rows(self.styles, role) { + apply_label_rows(&mut n, rows); + } + Arc::new(n) } /// Label `s` of `role` set at `at` and laid out, not yet in the document. fn text_object(&self, role: Role, at: Point, s: &str, justify: Justify) -> TextObject { @@ -121,7 +167,7 @@ impl Gen<'_> { t.xf = Affine::translate((0.0, half - (first + last) / 2.0)) * t.xf; } } - Arc::new(Node::new(self.d.alloc_id(), NodeKind::Text(Box::new(t)))) + self.label_node(role, t) } pub(super) fn group(&mut self, name: &str, children: Vec>) -> Arc { let mut g = Node::group(self.d.alloc_id(), children); diff --git a/crates/engine/src/cmd/graph/series.rs b/crates/engine/src/cmd/graph/series.rs index 3285185b8d..bfde9e881a 100644 --- a/crates/engine/src/cmd/graph/series.rs +++ b/crates/engine/src/cmd/graph/series.rs @@ -250,6 +250,9 @@ fn series_draws_bands(g: &GraphSpec, i: usize) -> bool { /// one changed, so an edit compares what it leaves with these ([`settle_series_paints`]). type SeriesPaintsBefore = HashMap>; +/// What an edit gave the characters of graph label `id`, applied to a character style ([`capture_label_text`]). +pub(crate) type LabelTextChange<'a> = &'a dyn Fn(NodeId, &mut CharStyle); + /// Does any graph in `before` now store other series paints than it did before the edit's first capture? Slots the /// captures added past the old end and left empty are removed again, so they do not count as a change. fn settle_series_paints(doc: &mut Document, before: SeriesPaintsBefore) -> bool { @@ -522,6 +525,149 @@ pub(crate) fn capture_label_paragraphs( Ok(changed) } +/// The object rows graph label `n` shows ([`vectorcraft_doc::LabelRows`]): the paint, weight, opacity and blend mode of +/// its topmost visible fill and stroke row, its object opacity and blend mode, and where those rows paint. A placed +/// gradient is kept without its placement, so it fits each label it lands on. `None`: no visible row, opaque and Normal. +pub(crate) fn label_rows(n: &Node) -> Option { + use vectorcraft_doc::AppearanceItem; + let items = &n.appearance.items; + let top = |fill: bool| items.iter().rposition(|it| it.is_fill() == fill && it.visible()); + let opacity = |o: f32| (o != 1.0).then_some(o); + let blend = |b: BlendMode| (b != BlendMode::Normal).then_some(b); + let unplaced = crate::cmd::gradient::unplaced; + let mut rows = vectorcraft_doc::SeriesPaint { opacity: opacity(n.opacity), blend: blend(n.blend), ..Default::default() }; + let (fill, stroke) = (top(true), top(false)); + if let Some(f) = fill.and_then(|i| items.get(i)) { + (rows.fill, rows.fill_opacity, rows.fill_blend) = (Some(unplaced(f.paint())), opacity(f.opacity()), blend(f.blend())); + } + if let Some(AppearanceItem::Stroke(st)) = stroke.and_then(|i| items.get(i)) { + (rows.stroke, rows.stroke_width, rows.stroke_opacity, rows.stroke_blend) = + (Some(unplaced(&st.paint)), Some(st.width), opacity(st.opacity), blend(st.blend)); + } + let contents = n.appearance.contents_at(); + let below = [fill, stroke].into_iter().flatten().filter(|i| *i < contents).fold(0u8, |k, _| k + 1); + let rows = vectorcraft_doc::LabelRows { + paint: rows, + below: (below > 0).then_some(below), + stroke_under: matches!((fill, stroke), (Some(f), Some(s)) if s < f), + }; + (rows != vectorcraft_doc::LabelRows::default()).then_some(rows) +} + +/// Keep the object rows ([`label_rows`]) of graph labels `ids` for every label of their kind, as +/// [`capture_label_styles`] keeps a character format, even when a label already showed them: the last label of a kind +/// in the order of `ids` sets what that kind keeps. Objects other than type are left out before the graph's labels are +/// looked up. Called inside the same edit, so they undo together. Returns whether kept rows changed. +pub(crate) fn capture_label_rows(doc: &mut Document, ids: &[NodeId]) -> bool { + store_label_rows(doc, ids, false) +} + +/// Keep the object opacity and blend mode of graph labels `ids` for every label of their kind, as [`capture_label_rows`] +/// keeps them, and leave the kept fill and stroke rows as they are. Returns whether what the graph keeps changed. +pub(crate) fn capture_label_object_opacity(doc: &mut Document, ids: &[NodeId]) -> bool { + store_label_rows(doc, ids, true) +} + +/// Would [`capture_label_object_opacity`] change what a graph keeps for labels `ids`? Reads the document without copying +/// it. +pub(crate) fn label_object_opacity_differs(doc: &Document, ids: &[NodeId]) -> bool { + label_rows_of(doc, ids).into_iter().any(|(graph, role, rows)| { + let Some(spec) = doc.node(graph).and_then(|n| n.extras().graph.as_deref()) else { return false }; + let slot = match role { + Role::Value => &spec.label_styles.values_rows, + Role::Category => &spec.label_styles.categories_rows, + Role::Legend => &spec.label_styles.legend_rows, + }; + *slot != kept_label_rows(slot, rows, true) + }) +} + +/// [`capture_label_rows`], or with `object_only` [`capture_label_object_opacity`]. +fn store_label_rows(doc: &mut Document, ids: &[NodeId], object_only: bool) -> bool { + let mut changed = false; + for (graph, role, rows) in label_rows_of(doc, ids) { + let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue }; + let slot = match role { + Role::Value => &mut spec.label_styles.values_rows, + Role::Category => &mut spec.label_styles.categories_rows, + Role::Legend => &mut spec.label_styles.legend_rows, + }; + let kept = kept_label_rows(slot, rows, object_only); + changed |= *slot != kept; + *slot = kept; + } + changed +} + +/// What a kind keeps after a label of it shows `rows`: those rows, or with `object_only` what `slot` keeps with the +/// label's object opacity and blend mode. +fn kept_label_rows( + slot: &Option, + rows: Option, + object_only: bool, +) -> Option { + if !object_only { + return rows; + } + let mut kept = slot.clone().unwrap_or_default(); + let shown = rows.unwrap_or_default(); + (kept.paint.opacity, kept.paint.blend) = (shown.paint.opacity, shown.paint.blend); + (kept != vectorcraft_doc::LabelRows::default()).then_some(kept) +} + +/// The graph, kind and rows ([`label_rows`]) of the last label of each kind among `ids`. +fn label_rows_of(doc: &Document, ids: &[NodeId]) -> Vec<(NodeId, Role, Option)> { + // Graph labels are type: without type among `ids`, the graph's labels are not looked up. + if !ids.iter().any(|id| doc.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Text(_)))) { + return vec![]; + } + let labels = graph_labels(doc); + let mut picked: Vec<(NodeId, Role, Option)> = vec![]; + for (&(g, role), n) in ids.iter().rev().filter_map(|id| Some((labels.get(id)?, doc.node(*id)?))) { + if !picked.iter().any(|(pg, pr, _)| *pg == g && *pr == role) { + picked.push((g, role, label_rows(n))); + } + } + picked +} + +/// Keep what an edit gave all of the text of graph labels `ids` for every label of their kind: `change`, when given (the +/// character attributes the edit applied to label `id`), on the kind's kept style, as [`capture_label_styles`] keeps a +/// character format, and `para`, when given, as the kind's paragraph attributes ([`capture_label_paragraphs`]). +/// Attributes the edit did not apply stay as the kind keeps them. Returns whether a kept style or set of attributes +/// changed. +pub(crate) fn capture_label_text( + doc: &mut Document, + ids: &[NodeId], + change: Option, + para: Option<&vectorcraft_doc::ParaStyle>, +) -> Result { + if (change.is_none() && para.is_none()) || !ids.iter().any(|id| doc.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Text(_)))) { + return Ok(false); + } + // The last label of each kind sets what the kind keeps. + let labels = graph_labels(doc); + let mut last: Vec<((NodeId, Role), NodeId)> = vec![]; + for id in ids.iter().rev() { + if let Some(kind) = labels.get(id).filter(|k| !last.iter().any(|(l, _)| l == *k)) { + last.push((*kind, *id)); + } + } + let mut changed = false; + for id in last.iter().rev().map(|(_, id)| id) { + if let Some(change) = change { + changed |= capture_label_styles(doc, &[*id], &|st| change(*id, st)); + } + if let Some(para) = para { + changed |= capture_label_paragraphs(doc, &[*id], &|p| { + *p = para.clone(); + Ok(()) + })?; + } + } + Ok(changed) +} + /// The graphs and kinds of label among `ids`, each once. fn label_kinds(doc: &Document, ids: &[NodeId]) -> Vec<(NodeId, Role)> { if ids.is_empty() { diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index 067d2faeb5..bcd6f406ed 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -5079,6 +5079,407 @@ fn formatting_one_label_keeps_that_kind_of_label() { assert!(serde_json::to_value(GraphSpec::default()).unwrap().get("labelStyles").is_none()); } +/// Each Axes and Legend text after the graph is drawn again, with its object rows (fill or stroke, color, opacity, and +/// for a stroke its weight) and its object opacity. +type RowLooks = Vec<(bool, String, f32, f64)>; +fn redrawn_label_rows(s: &mut Session, id: NodeId) -> Vec<(String, RowLooks, f32)> { + s.execute("graph.setData", &json!({"id": id.0, "csv": ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let mut out = vec![]; + for g in ["Axes", "Legend"] { + for c in group(&n, g).children().unwrap() { + if let NodeKind::Text(t) = &c.kind { + let rows = c + .appearance + .items + .iter() + .map(|it| { + let w = match it { + vectorcraft_doc::AppearanceItem::Stroke(st) => st.width, + vectorcraft_doc::AppearanceItem::Fill(_) => 0.0, + }; + (it.is_fill(), it.paint().color().map(|c| c.to_hex()).unwrap_or_default(), it.opacity(), w) + }) + .collect(); + out.push((t.plain_text(), rows, c.opacity)); + } + } + } + out +} + +fn rows_of(looks: &[(String, RowLooks, f32)], text: &str) -> (RowLooks, f32) { + looks.iter().find(|(t, _, _)| t == text).map(|(_, r, o)| (r.clone(), *o)).unwrap_or_else(|| panic!("no label {text}")) +} + +/// Fill and stroke rows given to one label with the Appearance commands are kept for every label of its kind when the +/// graph is drawn again, through save and open, and undo with the change. +#[test] +fn appearance_rows_on_one_label_are_kept_for_its_kind() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + // A category label: a red fill row at 50%. + let c = text_id(&s, id, "Axes", "Q2"); + s.execute("appearance.addFill", &json!({"ids": [c.0]})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [c.0], "index": 0, "color": "#ff0000", "opacity": 50})).unwrap(); + // A value axis number: a 2 pt blue stroke row. + let v = text_id(&s, id, "Axes", "4"); + s.execute("appearance.addStroke", &json!({"ids": [v.0]})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [v.0], "index": 0, "color": "#0000ff", "weight": 2})).unwrap(); + let looks = redrawn_label_rows(&mut s, id); + assert_eq!(rows_of(&looks, "Q1"), (vec![(true, "#ff0000".into(), 0.5, 0.0)], 1.0), "every category label: {looks:?}"); + assert_eq!(rows_of(&looks, "0"), (vec![(false, "#0000ff".into(), 1.0, 2.0)], 1.0), "every value axis number: {looks:?}"); + assert_eq!(rows_of(&looks, "2024"), (vec![], 1.0), "the legend keeps none: {looks:?}"); + // One undo takes the data change back, the next the stroke's color and weight together with what the graph keeps. + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + let kept = spec(&s, id).label_styles.values_rows.unwrap(); + assert_ne!(kept.paint.stroke.and_then(|p| p.color()).map(|c| c.to_hex()), Some("#0000ff".into())); + assert_eq!(kept.paint.stroke_width, Some(1.0)); + s.execute("edit.redo", &json!({})).unwrap(); + let path = vectorcraft_testkit::temp_dir("graph-label-rows").join("labels.vectorcraft"); + s.execute("document.save", &json!({"path": path})).unwrap(); + s.execute("document.open", &json!({"path": path})).unwrap(); + let g = spec(&s, id); + assert_eq!(g.label_styles.categories_rows.unwrap().paint.fill_opacity, Some(0.5)); + assert_eq!(g.label_styles.values_rows.unwrap().paint.stroke_width, Some(2.0)); + assert!(serde_json::to_value(vectorcraft_doc::LabelStyles::default()).unwrap().as_object().unwrap().is_empty()); +} + +/// Removing a label's rows, clearing its appearance or hiding its row leaves its kind with what the label then shows. +#[test] +fn removing_a_labels_rows_changes_what_its_kind_keeps() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let c = text_id(&s, id, "Axes", "Q2"); + s.execute("appearance.addFill", &json!({"ids": [c.0]})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [c.0], "index": 0, "color": "#ff0000"})).unwrap(); + s.execute("appearance.removeItem", &json!({"ids": [c.0], "index": 0})).unwrap(); + assert_eq!(spec(&s, id).label_styles.categories_rows, None, "no row left"); + s.execute("appearance.addFill", &json!({"ids": [c.0]})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [c.0], "index": 0, "color": "#ff0000", "visible": false})).unwrap(); + assert_eq!(spec(&s, id).label_styles.categories_rows, None, "a hidden row shows nothing"); + s.execute("appearance.showAllHidden", &json!({"ids": [c.0]})).unwrap(); + let looks = redrawn_label_rows(&mut s, id); + assert_eq!(rows_of(&looks, "Q3").0, vec![(true, "#ff0000".into(), 1.0, 0.0)], "{looks:?}"); + let c = text_id(&s, id, "Axes", "Q1"); + s.execute("appearance.clear", &json!({"ids": [c.0]})).unwrap(); + let looks = redrawn_label_rows(&mut s, id); + assert_eq!(rows_of(&looks, "Q3").0, vec![(true, String::new(), 1.0, 0.0), (false, String::new(), 1.0, 1.0)], "{looks:?}"); +} + +/// A fill row painted with `paint.setFill {item}` is kept with the label's rows, and its characters keep their own fill. +#[test] +fn painting_a_labels_row_keeps_the_row_and_not_its_characters() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let l = text_id(&s, id, "Legend", "2025"); + s.execute("appearance.addFill", &json!({"ids": [l.0]})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [l.0], "item": 0, "color": "#00ff00"})).unwrap(); + let g = spec(&s, id); + assert_eq!(g.label_styles.legend_rows.and_then(|r| r.paint.fill).and_then(|p| p.color()).map(|c| c.to_hex()), Some("#00ff00".into())); + assert_eq!(g.label_styles.legend, None, "the characters keep the default fill"); + let looks = label_looks(&mut s, id); + assert!(looks.iter().find(|(t, _, _)| t == "2024").is_some_and(|(_, _, fill)| fill == "#000000"), "{looks:?}"); +} + +/// Moving or copying an appearance onto a label (Layers panel drag) keeps the source's rows and object opacity for the +/// label's kind. +#[test] +fn an_appearance_moved_onto_a_label_is_kept_for_its_kind() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let r = NodeId(s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 30, "height": 40})).unwrap()["id"].as_u64().unwrap()); + s.execute("paint.setFill", &json!({"ids": [r.0], "color": "#12ab34"})).unwrap(); + s.execute("transparency.set", &json!({"ids": [r.0], "opacity": 40})).unwrap(); + let v = text_id(&s, id, "Axes", "2"); + s.execute("appearance.transfer", &json!({"source": r.0, "target": v.0, "copy": true})).unwrap(); + let looks = redrawn_label_rows(&mut s, id); + let (rows, opacity) = rows_of(&looks, "6"); + assert_eq!((rows.first().map(|r| r.1.clone()), opacity), (Some("#12ab34".into()), 0.4), "{looks:?}"); +} + +/// The Eyedropper onto a label keeps the characters' style, the paragraph attributes and the appended rows it gave the +/// label for every label of its kind. +#[test] +fn the_eyedropper_on_a_label_is_kept_for_its_kind() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let src = NodeId(s.execute("text.create", &json!({"x": 10, "y": 10, "text": "x", "size": 14})).unwrap()["id"].as_u64().unwrap()); + s.execute("paint.setFill", &json!({"ids": [src.0], "color": "#00ff00"})).unwrap(); + let v = text_id(&s, id, "Axes", "4"); + let steps = s.doc().unwrap().history.undo.len(); + s.execute("appearance.copyFrom", &json!({"source": src.0, "ids": [v.0]})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "one step"); + let looks = label_looks(&mut s, id); + assert!(looks.iter().any(|(t, size, fill)| t == "0" && *size == 14.0 && fill == "#00ff00"), "every value axis number: {looks:?}"); + assert!(looks.iter().any(|(t, size, fill)| t == "Q1" && *size == 9.0 && fill == "#000000"), "other labels: {looks:?}"); + // Appended rows (Shift+Alt-click) go with the label's rows. + let r = NodeId(s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 30, "height": 40})).unwrap()["id"].as_u64().unwrap()); + s.execute("paint.setFill", &json!({"ids": [r.0], "color": "#abcdef"})).unwrap(); + let c = text_id(&s, id, "Axes", "Q1"); + s.execute("appearance.copyFrom", &json!({"source": r.0, "ids": [c.0], "append": true})).unwrap(); + let looks = redrawn_label_rows(&mut s, id); + assert!(rows_of(&looks, "Q3").0.iter().any(|(fill, hex, _, _)| *fill && hex == "#abcdef"), "{looks:?}"); +} + +/// Kept rows read from a saved graph's label styles are held to the ranges the controls keep to, and a place for the +/// Characters row past the kept rows puts every row below the characters. +#[test] +fn kept_label_rows_from_a_file_stay_in_range() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let paint = |hex: &str| serde_json::to_value(Paint::solid(vectorcraft_color::Color::from_hex(hex).unwrap())).unwrap(); + let saved = json!({"legendRows": { + "fill": paint("#ff0000"), "fillOpacity": 7.0, + "stroke": paint("#0000ff"), "strokeWidth": 1e9, "strokeOpacity": -2.0, + "opacity": -3.0, "below": 9, + }}); + let styles: vectorcraft_doc::LabelStyles = serde_json::from_value(saved).unwrap(); + s.edit("rows", |d, _| { + d.node_mut(id).unwrap().extras_mut().graph.as_deref_mut().unwrap().label_styles = styles; + Ok(()) + }) + .unwrap(); + let looks = redrawn_label_rows(&mut s, id); + assert_eq!(rows_of(&looks, "2024"), (vec![(true, "#ff0000".into(), 1.0, 0.0), (false, "#0000ff".into(), 0.0, 1000.0)], 0.0), "{looks:?}"); + assert_eq!(label_node(&s, id, "Legend", "2024").appearance.contents_at(), 2); +} + +/// Label `text` of group `grp` of graph `id`. +fn label_node(s: &Session, id: NodeId, grp: &str, text: &str) -> vectorcraft_doc::Node { + s.doc().unwrap().doc.node(text_id(s, id, grp, text)).unwrap().clone() +} + +/// An edit that leaves a label showing what it showed still sets what its kind keeps. +#[test] +fn a_label_already_showing_a_row_still_sets_what_its_kind_keeps() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let c = text_id(&s, id, "Axes", "Q2"); + s.execute("appearance.addFill", &json!({"ids": [c.0]})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [c.0], "index": 0, "color": "#ff0000"})).unwrap(); + redrawn_label_rows(&mut s, id); + let a = text_id(&s, id, "Axes", "Q1"); + s.execute("appearance.setItem", &json!({"ids": [a.0], "index": 0, "color": "#0000ff"})).unwrap(); + // Q3 shows the red the graph drew it with. + let b = text_id(&s, id, "Axes", "Q3"); + s.execute("appearance.setItem", &json!({"ids": [b.0], "index": 0, "color": "#ff0000"})).unwrap(); + let looks = redrawn_label_rows(&mut s, id); + assert_eq!(rows_of(&looks, "Q1").0, vec![(true, "#ff0000".into(), 1.0, 0.0)], "{looks:?}"); +} + +/// Paint given to a label's characters leaves the rows its kind keeps as they are. +#[test] +fn paint_on_a_labels_characters_leaves_the_rows_its_kind_keeps() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let c = text_id(&s, id, "Axes", "Q2"); + s.execute("appearance.addFill", &json!({"ids": [c.0]})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [c.0], "index": 0, "color": "#ff0000"})).unwrap(); + redrawn_label_rows(&mut s, id); + let a = text_id(&s, id, "Axes", "Q1"); + s.execute("appearance.setItem", &json!({"ids": [a.0], "index": 0, "color": "#0000ff"})).unwrap(); + // Q3 still shows red; its characters take green. + let b = text_id(&s, id, "Axes", "Q3"); + s.execute("paint.setFill", &json!({"ids": [b.0], "color": "#00ff00"})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [b.0], "color": "#00ff00"})).unwrap(); + // The Eyedropper from type, transparency included, onto its characters. + let src = NodeId(s.execute("text.create", &json!({"x": 10, "y": 10, "text": "x", "size": 14})).unwrap()["id"].as_u64().unwrap()); + s.execute("appearance.copyFrom", &json!({"source": src.0, "ids": [b.0]})).unwrap(); + let looks = redrawn_label_rows(&mut s, id); + assert_eq!(rows_of(&looks, "Q1").0, vec![(true, "#0000ff".into(), 1.0, 0.0)], "{looks:?}"); +} + +/// When labels of one kind are edited together, the last one in the order of `ids` sets what the kind keeps. +#[test] +fn the_last_label_of_a_kind_edited_together_sets_what_it_keeps() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let (a, b) = (text_id(&s, id, "Axes", "Q1"), text_id(&s, id, "Axes", "Q2")); + for (l, color) in [(a, "#ff0000"), (b, "#0000ff")] { + s.execute("appearance.addFill", &json!({"ids": [l.0]})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [l.0], "index": 0, "color": color})).unwrap(); + } + let kept = |s: &Session| spec(s, id).label_styles.categories_rows.unwrap().paint.fill.unwrap().color().unwrap().to_hex(); + s.execute("appearance.addFill", &json!({"ids": [a.0, b.0]})).unwrap(); + assert_eq!(kept(&s), "#0000ff"); + s.execute("appearance.addFill", &json!({"ids": [b.0, a.0]})).unwrap(); + assert_eq!(kept(&s), "#ff0000"); +} + +/// A label's object opacity and blend mode set without a row (`transparency.set` without `item`) are kept for its kind. +#[test] +fn a_labels_object_opacity_and_blend_mode_are_kept_for_its_kind() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + // Another value axis number sets the kind's fill row; the label below has none, and the kind keeps the row. + let u = text_id(&s, id, "Axes", "2"); + s.execute("appearance.addFill", &json!({"ids": [u.0]})).unwrap(); + s.execute("appearance.setItem", &json!({"ids": [u.0], "index": 0, "color": "#0000ff"})).unwrap(); + let v = text_id(&s, id, "Axes", "4"); + s.execute("transparency.set", &json!({"ids": [v.0], "opacity": 50, "blend": "multiply"})).unwrap(); + let looks = redrawn_label_rows(&mut s, id); + assert_eq!(rows_of(&looks, "0"), (vec![(true, "#0000ff".into(), 1.0, 0.0)], 0.5), "{looks:?}"); + assert_eq!(label_node(&s, id, "Axes", "0").blend, BlendMode::Multiply); + assert_eq!(label_node(&s, id, "Axes", "Q1").blend, BlendMode::Normal); + // Another label of the kind sets them again. + let w = text_id(&s, id, "Axes", "2"); + s.execute("transparency.set", &json!({"ids": [w.0], "opacity": 50, "blend": "screen"})).unwrap(); + assert_eq!(label_node(&s, id, "Axes", "0").blend, BlendMode::Multiply); + let looks = redrawn_label_rows(&mut s, id); + assert_eq!(rows_of(&looks, "0").1, 0.5, "{looks:?}"); + assert_eq!(label_node(&s, id, "Axes", "0").blend, BlendMode::Screen); +} + +/// Stroke options given to a label's stroke row, a fill row painted as the Appearance panel's active row, and a row's +/// blend mode are kept for the label's kind. +#[test] +fn stroke_options_and_the_active_row_on_a_label_are_kept_for_its_kind() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let v = text_id(&s, id, "Axes", "4"); + s.execute("appearance.addStroke", &json!({"ids": [v.0]})).unwrap(); + s.execute("stroke.set", &json!({"ids": [v.0], "item": 0, "weight": 3})).unwrap(); + let c = text_id(&s, id, "Axes", "Q2"); + s.execute("appearance.addFill", &json!({"ids": [c.0]})).unwrap(); + s.execute("select.set", &json!({"ids": [c.0]})).unwrap(); + s.execute("appearance.setActiveItem", &json!({"index": 0})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#00ffff"})).unwrap(); + s.execute("transparency.set", &json!({"item": 0, "blend": "multiply"})).unwrap(); + let looks = redrawn_label_rows(&mut s, id); + assert_eq!(rows_of(&looks, "0").0.first().map(|r| r.3), Some(3.0), "{looks:?}"); + assert_eq!(rows_of(&looks, "Q1").0.first().map(|r| r.1.clone()), Some("#00ffff".into()), "{looks:?}"); + let q1 = label_node(&s, id, "Axes", "Q1"); + assert_eq!(q1.appearance.items.first().map(|it| it.blend()), Some(BlendMode::Multiply)); + let NodeKind::Text(t) = &q1.kind else { panic!("not type") }; + assert_eq!(t.runs[0].style.fill.color().map(|c| c.to_hex()), Some("#000000".into()), "the characters keep their fill"); +} + +/// A row below the Characters row stays below the characters when the graph is drawn again. +#[test] +fn a_kept_row_keeps_its_place_below_the_characters() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let c = text_id(&s, id, "Axes", "Q2"); + s.execute("appearance.addStroke", &json!({"ids": [c.0]})).unwrap(); + s.execute("appearance.addFill", &json!({"ids": [c.0]})).unwrap(); + // The stroke below the characters, the fill above them. + s.execute("appearance.moveItem", &json!({"ids": [c.0], "from": "contents", "to": 1})).unwrap(); + let look = |ap: &vectorcraft_doc::Appearance| (ap.contents_at(), ap.items.iter().map(|it| it.is_fill()).collect::>()); + assert_eq!(look(&label_node(&s, id, "Axes", "Q2").appearance), (1, vec![false, true])); + redrawn_label_rows(&mut s, id); + assert_eq!(look(&label_node(&s, id, "Axes", "Q1").appearance), (1, vec![false, true])); +} + +/// The Eyedropper keeps for a label's kind only the attributes it applied: a fill the label had of its own stays out. +#[test] +fn the_eyedropper_keeps_only_what_it_applied_for_a_labels_kind() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let src = NodeId(s.execute("text.create", &json!({"x": 10, "y": 10, "text": "x", "size": 14})).unwrap()["id"].as_u64().unwrap()); + s.execute("paint.setFill", &json!({"ids": [src.0], "color": "#00ff00"})).unwrap(); + let v = text_id(&s, id, "Axes", "4"); + // A fill of the label's own, as the generator gives labels on dark pie wedges. + s.edit("own fill", |d, _| { + if let NodeKind::Text(t) = &mut d.node_mut(v).unwrap().kind { + t.runs.iter_mut().for_each(|r| r.style.fill = Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 1.0))); + } + Ok(()) + }) + .unwrap(); + // A pick of transparency alone leaves the kind's character style unset. + let transparency = json!({"character": false, "paragraph": false, "appearance": {"fill": false, "stroke": false}}); + s.execute("appearance.copyFrom", &json!({"source": src.0, "ids": [v.0], "apply": transparency})).unwrap(); + assert_eq!(spec(&s, id).label_styles.values, None); + s.execute("appearance.copyFrom", &json!({"source": src.0, "ids": [v.0], "apply": {"appearance": false}})).unwrap(); + let looks = label_looks(&mut s, id); + assert!(looks.iter().any(|(t, size, fill)| t == "0" && *size == 14.0 && fill == "#000000"), "{looks:?}"); +} + +/// Paint given to all of a label's text highlighted with the Type tool is kept for its kind, as for the whole label. +#[test] +fn paint_on_a_labels_highlighted_text_is_kept_for_its_kind() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let v = text_id(&s, id, "Axes", "4"); + s.select_tool("type", crate::ViewInfo::default()).unwrap(); + s.set_tool_option("textSelection", &json!({"id": v.0, "start": 0, "end": 1})); + s.execute("paint.setFill", &json!({"color": "#ff8800"})).unwrap(); + s.select_tool("selection", crate::ViewInfo::default()).unwrap(); + let looks = label_looks(&mut s, id); + assert!(looks.iter().any(|(t, _, fill)| t == "0" && fill == "#ff8800"), "{looks:?}"); +} + +/// Paint given to the whole graph without a row is kept for the characters of every kind of label. +#[test] +fn paint_on_the_whole_graph_is_kept_for_every_kind_of_label() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#ff00ff"})).unwrap(); + let looks = label_looks(&mut s, id); + for t in ["0", "Q1", "2024"] { + assert!(looks.iter().any(|(x, _, fill)| x == t && fill == "#ff00ff"), "{t}: {looks:?}"); + } +} + +/// A gradient placed on one label's characters or on its row is kept without that placement, so it fits each label of +/// the kind when the graph is drawn again. +#[test] +fn a_placed_gradient_on_a_label_is_kept_to_fit_each_label() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let gradient = json!({"start": [30, 60], "end": [90, 60]}); + let v = text_id(&s, id, "Axes", "4"); + s.execute("paint.setFill", &json!({"ids": [v.0], "gradient": gradient})).unwrap(); + let c = text_id(&s, id, "Axes", "Q2"); + s.execute("appearance.addFill", &json!({"ids": [c.0]})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [c.0], "item": 0, "gradient": gradient})).unwrap(); + let placed = |p: &Paint| matches!(p, Paint::Gradient(g) if g.geom.is_some()); + let g = spec(&s, id); + assert!(g.label_styles.values.is_some_and(|st| matches!(st.fill, Paint::Gradient(_)) && !placed(&st.fill))); + assert!(g.label_styles.categories_rows.and_then(|r| r.paint.fill).is_some_and(|f| matches!(f, Paint::Gradient(_)) && !placed(&f))); +} + +/// A graphic style applied to a label is kept with the label's rows for its kind. +#[test] +fn a_graphic_style_on_a_label_is_kept_for_its_kind() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let r = NodeId(s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 30, "height": 40})).unwrap()["id"].as_u64().unwrap()); + s.execute("paint.setFill", &json!({"ids": [r.0], "color": "#ff8000"})).unwrap(); + s.execute("graphicStyle.new", &json!({"id": r.0, "name": "Orange"})).unwrap(); + let v = text_id(&s, id, "Axes", "4"); + s.execute("graphicStyle.apply", &json!({"name": "Orange", "ids": [v.0]})).unwrap(); + let looks = redrawn_label_rows(&mut s, id); + assert!(rows_of(&looks, "0").0.iter().any(|(fill, hex, _, _)| *fill && hex == "#ff8000"), "{looks:?}"); + // A label still linked to the style follows a redefinition, and its kind keeps the new look. + let v = text_id(&s, id, "Axes", "4"); + s.execute("graphicStyle.apply", &json!({"name": "Orange", "ids": [v.0]})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [r.0], "color": "#0080ff"})).unwrap(); + s.execute("graphicStyle.redefine", &json!({"name": "Orange", "id": r.0})).unwrap(); + let looks = redrawn_label_rows(&mut s, id); + assert!(rows_of(&looks, "0").0.iter().any(|(fill, hex, _, _)| *fill && hex == "#0080ff"), "{looks:?}"); +} + #[test] fn pie_names_and_wedge_labels_keep_their_style() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/object/commands.rs b/crates/engine/src/cmd/object/commands.rs index 21d6530e53..6ffdb04a5c 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)}. On a path directly in a graph series, the graph also stores the object's opacity and blend mode (exceptions: docs/mcp.md)", + "{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); on a graph label, the graph stores the label's opacity and blend mode for every label of its kind", has_doc, set_props ), diff --git a/crates/engine/src/cmd/object/props.rs b/crates/engine/src/cmd/object/props.rs index 1fb2faf769..bd00b6b323 100644 --- a/crates/engine/src/cmd/object/props.rs +++ b/crates/engine/src/cmd/object/props.rs @@ -65,10 +65,11 @@ pub(super) fn set_props(s: &mut Session, p: &Value) -> Result { }) }; let d = &s.doc()?.doc; - // 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. + // A graph keeps the object opacity and blend mode set on a mark of a series for the whole series, and set on a label + // for its kind of label, so a value a mark or label already has still replaces the one another left on the graph. let transparency = opacity.is_some() || blend.is_some(); - let stores = || transparency && crate::cmd::graph::series_object_opacity_differs(d, &ids); + let stores = + || transparency && (crate::cmd::graph::series_object_opacity_differs(d, &ids) || crate::cmd::graph::label_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 { @@ -124,6 +125,7 @@ pub(super) fn set_props(s: &mut Session, p: &Value) -> Result { } if transparency { crate::cmd::graph::capture_series_object_opacity(d, &ids); + crate::cmd::graph::capture_label_object_opacity(d, &ids); } // Hidden or locked objects can't stay selected. if hides { diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index c0f23100af..c7159d563a 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -21,7 +21,7 @@ pub fn specs() -> Vec { "Fill", [], None, - "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical: the stops sweep once around the center `start`, clockwise from the vector; diamond: the stops run from the center `start` out to a square with a corner at `end`), method?: classic|perceptual|hue-clockwise|hue-counterclockwise (as paint.editGradient; default classic), spread?: pad|repeat|reflect (as paint.editGradient; default pad), repeats?: 1..100 (as paint.editGradient; default 1), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep, counterclockwise, and is an error for the other kinds; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits; an error with a conic or diamond), dither?: bool (linear, radial, conic and diamond fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear, radial, conic or diamond `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white; a colorized image whose link is missing takes a solid color as its new colorize color, and refuses a process color while its color is a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern). On a path directly in a graph series, the graph also stores the paint of the topmost visible fill when the command wrote that row or changed it (exceptions: docs/mcp.md) → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}", + "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical: the stops sweep once around the center `start`, clockwise from the vector; diamond: the stops run from the center `start` out to a square with a corner at `end`), method?: classic|perceptual|hue-clockwise|hue-counterclockwise (as paint.editGradient; default classic), spread?: pad|repeat|reflect (as paint.editGradient; default pad), repeats?: 1..100 (as paint.editGradient; default 1), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep, counterclockwise, and is an error for the other kinds; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits; an error with a conic or diamond), dither?: bool (linear, radial, conic and diamond fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear, radial, conic or diamond `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white; a colorized image whose link is missing takes a solid color as its new colorize color, and refuses a process color while its color is a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern). On a path directly in a graph series, the graph also stores the paint of the topmost visible fill when the command wrote that row or changed it (exceptions: docs/mcp.md); on a graph label, the graph stores the paint for every label of its kind, as a character fill when the characters took it and with the label's rows when a row did → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}", has_doc, |s, p| set_paint(s, p, true) ), @@ -30,7 +30,7 @@ pub fn specs() -> Vec { "Stroke", [], None, - "{…as paint.setFill, item?: the stroke item to set} same as paint.setFill, for the stroke; with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's stroke changes for this instance only (solid, none, gradient or pattern). On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible stroke when the command wrote that row or changed it (exceptions: docs/mcp.md)", + "{…as paint.setFill, item?: the stroke item to set} same as paint.setFill, for the stroke; with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's stroke changes for this instance only (solid, none, gradient or pattern). On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible stroke when the command wrote that row or changed it (exceptions: docs/mcp.md); on a graph label, as paint.setFill", has_doc, |s, p| { set_paint(s, p, false) } ), @@ -81,7 +81,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). 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)", + "{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); on a graph label, the graph stores the label's object opacity and blend mode for every label of its kind, and with `item` its topmost visible fill and stroke rows too", has_doc, transparency ), @@ -475,6 +475,14 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) let ids: Vec = ranges.iter().map(|(f, _, _)| *f).collect(); // When the type comes out as it was, the document and its undo history stay as they are. let before = s.doc()?.doc.clone(); + // The graph labels whose whole text takes the paint: a graph keeps it for their kind of label. + let whole: Vec = ranges + .iter() + .filter(|(id, a, b)| { + *a == 0 && matches!(before.node(*id).map(|n| &n.kind), Some(NodeKind::Text(t)) if *b >= vectorcraft_text::edit::runs_len(&t.runs)) + }) + .map(|(id, _, _)| *id) + .collect(); super::overprint::edit_counted(s, if fill { "Fill Color" } else { "Stroke Color" }, |d| { for &(id, a, b) in &ranges { let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) }; @@ -491,7 +499,17 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) }); super::typecmd::refresh_bounds(t); } - Ok(super::appearance::count_changed(d, &before, &ids)) + let labels = super::graph::capture_label_styles(d, &whole, &|st| { + if fill { + st.fill = unplaced(&paint); + } else { + if !paint.is_none() { + run_stroke_weight(st); + } + st.stroke = unplaced(&paint); + } + }); + Ok(super::appearance::count_changed(d, &before, &ids) + usize::from(labels)) })?; if focus { s.fill_active = fill; @@ -534,6 +552,9 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) // Each object whose appearance took the paint, with the row it went to, for the paint a graph stores for its series // (a series mark is a path, so it always has a row here). let written = std::cell::RefCell::new(Vec::new()); + // The type objects whose characters took the paint (no row targeted): only those change what a graph's labels keep + // for their characters; a row edit is kept with the label's rows. + let characters = std::cell::RefCell::new(Vec::new()); edit_items_then( s, &ids, @@ -542,6 +563,9 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) label, fill, |n, index| { + if index.is_none() && matches!(n.kind, NodeKind::Text(_)) { + characters.borrow_mut().push(n.id); + } if index.is_none() && colorized.contains(&n.id) { n.extras_mut().colorize = paint.color().map(|_| Box::new(paint.clone())); return Ok(()); @@ -601,14 +625,14 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) }, |before, d, changed| { let series = super::appearance::capture_series_paint_writes(before, d, changed, &written.borrow()); - let labels = super::graph::capture_label_styles(d, changed, &|st| { + let labels = super::graph::capture_label_styles(d, &characters.borrow(), &|st| { if fill { - st.fill = paint.clone(); + st.fill = unplaced(&paint); } else { if !paint.is_none() { run_stroke_weight(st); } - st.stroke = paint.clone(); + st.stroke = unplaced(&paint); } }); Ok(series || labels) diff --git a/crates/engine/src/cmd/stroke.rs b/crates/engine/src/cmd/stroke.rs index 14c86c06ed..c5e5ba2a5c 100644 --- a/crates/engine/src/cmd/stroke.rs +++ b/crates/engine/src/cmd/stroke.rs @@ -22,7 +22,7 @@ pub fn specs() -> Vec { "Stroke Options", ["Window", "Stroke"], None, - "{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|LineArrow|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null|{custom: name} (a custom arrowhead of the arrowheads file, stroke.arrowheads.list)|{Custom: {name, path}} (a custom head with its art, as document.inspect reports one; refused when the path fills no area, parts thinner than a millionth of a head unit counting as none, or has more than 4096 anchors, more than 4096 subpaths or a point more than 64 head units from the tip) (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, 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)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does). On a path directly in a graph series, the graph also stores a weight it changes on the topmost visible stroke (docs/mcp.md) → {objects: how many objects took the stroke options}", + "{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|LineArrow|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null|{custom: name} (a custom arrowhead of the arrowheads file, stroke.arrowheads.list)|{Custom: {name, path}} (a custom head with its art, as document.inspect reports one; refused when the path fills no area, parts thinner than a millionth of a head unit counting as none, or has more than 4096 anchors, more than 4096 subpaths or a point more than 64 head units from the tip) (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, 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)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does). On a path directly in a graph series, the graph also stores a weight it changes on the topmost visible stroke (docs/mcp.md); on a graph label's stroke row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind → {objects: how many objects took the stroke options}", has_doc, stroke_set ), diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 65fe18b282..1c41006b3f 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -31,7 +31,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 (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}", + "{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); on a graph label, the graph stores the label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind. With nothing selected (and no ids) the next object drawn takes the style instead (see appearance.newArt) → {newArt: true}", has_doc, style_apply ), @@ -68,7 +68,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 (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}", + "{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); on a graph label, the graph stores the label's topmost visible fill and stroke rows and object opacity and blend mode for every label of its kind → {name}", has_doc, style_redefine ), @@ -374,6 +374,8 @@ fn style_apply(s: &mut Session, p: &Value) -> Result { super::appearance::clear_stood_for(d, &groups); super::appearance::capture_series_writes(&before, d, &ids, (true, true), (true, true), false, true); } + // A label keeps its new rows for its kind of label. + super::graph::capture_label_rows(d, &ids); Ok(()) })?; ok() @@ -506,6 +508,7 @@ fn style_redefine(s: &mut Session, p: &Value) -> Result { apply_style(d, *nid, &g, &mut shared, chars); } super::appearance::capture_series_writes(&before, d, &follow_marks, (true, true), (true, true), false, true); + super::graph::capture_label_rows(d, &follow_marks); set_link(d, &[src], Some(id)); Ok(()) })?; diff --git a/crates/engine/src/tests/swatches.rs b/crates/engine/src/tests/swatches.rs index c0145d20d4..a80769dd98 100644 --- a/crates/engine/src/tests/swatches.rs +++ b/crates/engine/src/tests/swatches.rs @@ -282,20 +282,24 @@ fn document_color_mode_converts_the_paints_a_graph_keeps() { for target in [series, label] { run(&mut s, "paint.setFill", json!({"color": "#ff0000", "ids": [target.0]})); } + // A fill row on the label, which the graph keeps with the label's rows. + run(&mut s, "appearance.addFill", json!({"ids": [label.0]})); let kept = |s: &Session| -> Vec { let g = doc(s).node(id).unwrap().extras().graph.as_deref().unwrap().clone(); + let rows = [&g.label_styles.values_rows, &g.label_styles.categories_rows, &g.label_styles.legend_rows]; + let rows: Vec = rows.into_iter().flatten().filter_map(|r| r.paint.fill.as_ref()?.color()).collect(); let labels = [g.label_styles.values, g.label_styles.categories, g.label_styles.legend]; let labels = labels.into_iter().flatten().map(|st| st.fill.color().unwrap()); - g.series_paints.iter().filter_map(|p| p.fill.as_ref()?.color()).chain(labels).collect() + g.series_paints.iter().filter_map(|p| p.fill.as_ref()?.color()).chain(labels).chain(rows).collect() }; let before = kept(&s); - assert_eq!(before, [Color::rgb(1.0, 0.0, 0.0); 2]); + assert_eq!(before, [Color::rgb(1.0, 0.0, 0.0); 3]); let undo = s.doc().unwrap().history.undo.len(); run(&mut s, "file.documentColorMode", json!({"mode": "cmyk"})); let cmyk = first_fill(doc(&s).node(red).unwrap()); assert!(matches!(cmyk, Color::Cmyk { .. }), "{cmyk:?}"); - assert_eq!(kept(&s), [cmyk; 2]); + assert_eq!(kept(&s), [cmyk; 3]); assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); // Drawn again from new data, the series keeps the converted color. run(&mut s, "graph.setData", json!({"id": id.0, "rows": [[2, 3], [4, 5]]})); diff --git a/docs/mcp.md b/docs/mcp.md index 2bfc83e64f..e4b129d75f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -812,6 +812,22 @@ kind takes them when the graph is drawn again. The graph saves the paragraph att `categoriesPara` and `legendPara` in its label styles, and still sets each label's alignment for its place. The legend leaves room for a legend label's stored indents, spacing and leading. +The `appearance.*` commands, `graphicStyle.apply` and `graphicStyle.redefine`, and the paint, stroke, gradient and +transparency commands given an `item` (or the Appearance panel's active row) store a graph label's object rows for its +kind of label: the paint, weight, opacity and blend mode of its topmost visible fill and stroke row, their order and +their place relative to the Characters row, and the label's object opacity and blend mode. They store them even when the +label already showed them. `transparency.set` without an `item`, `object.setProps` with an opacity or blend mode, and +an `appearance.copyFrom` (Eyedropper) pick of transparency store only the label's object opacity and blend mode; +`appearance.copyFrom` with `append` stores the rows. When the graph is drawn again, every label of that kind gets what +is stored, with a placed gradient fitted to each label, and the graph saves it as `valuesRows`, `categoriesRows` and +`legendRows` in its label styles. Rows below the topmost one of each kind, a row's effects, other stroke options (caps, +joins, dashes) and effects applied with `effect.*` are not stored. When labels of one kind are edited together, the +last one in the order of the targets (`ids` as given, otherwise paint order) sets what the kind keeps. +`appearance.copyFrom` onto the whole text of a label also stores the character attributes it applies and, from type, +the paragraph attributes. `paint.setFill` and `paint.setStroke` store a character fill or stroke when the paint goes to +the label's characters (the whole label, or all of its text highlighted with the Type tool), and the row when it goes to +a row. A stored character gradient also fits each label. + `effect.apply {"effect": "path.outlineObject"}` (Effect › Path › Outline Object) takes no parameters. Type that carries it is drawn, exported and expanded as its glyph outlines and stays editable type, and the effects after it reshape those outlines. On paths, compound paths and images it does not change what is drawn. On an image's own fill or stroke diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e2d64b6c0f..df6e7481fe 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1084,7 +1084,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 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, the paint or stroke weight a stroke weight, gradient or recolor command changes there, 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. A series that draws Filled Lines bands keeps no stroke paint or weight: its bands are drawn again with their 0.25 pt black outline. 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, the paint or stroke weight a stroke weight, gradient or recolor command changes there, 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. A series that draws Filled Lines bands keeps no stroke paint or weight: its bands are drawn again with their 0.25 pt black outline. 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, Appearance, Transparency or Graphic Styles controls or the Eyedropper, 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, the paint, weight, opacity and blend mode of a label's topmost visible fill and stroke rows and its object opacity and blend mode included. 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.