From 4129587abee1c7a80162129211e5f8e9c04570a2 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 15:22:55 -0800 Subject: [PATCH 1/2] Graphs: recolor, stroke and gradient edits on labels are kept for their kind A recolor of a graph (Invert and Complement on the proxy, the Edit Colors commands, Recolor Artwork, the color filters, Apply Nearest Swatch and the ink limit of Print Ink Fixes) stored the new paint of the series marks but not of the labels. stroke.set, the stroke weight steps, the gradient edits, Swap Fill and Stroke and Default Fill and Stroke stored nothing when they changed a label's characters. When the graph was drawn again, the labels went back to their stored style. - These commands now store, for every label of the kind they reach, the label's object rows when they change, and the fill, stroke, stroke weight, caps, joins, miter limit, dashes and stroke alignment of its characters when all of them show the change alike. - A gradient is stored without its placement, and each label fits it to its own box. A stroke weight and dashes are stored as a scaled label shows them. - When labels of one kind change differently, the frontmost one sets what the kind keeps. - On a pie with Legends in Wedges, the legend labels' fill is stored only when every legend label reached ends up with the same fill, so a recolor of the whole pie leaves them black or white by their wedge. - Axis lines, ticks and radar rings are not stored and are drawn again with their default paint. Docs: docs/mcp.md, the commands' params text, docs/target-app-parity.md. --- crates/engine/src/cmd/addons/color.rs | 6 +- crates/engine/src/cmd/appearance.rs | 15 +- crates/engine/src/cmd/colorcmds.rs | 10 +- crates/engine/src/cmd/freeform.rs | 2 +- crates/engine/src/cmd/gradient.rs | 4 +- crates/engine/src/cmd/graph.rs | 7 +- crates/engine/src/cmd/graph/generate.rs | 5 +- crates/engine/src/cmd/graph/series.rs | 165 ++++++++++++++++- crates/engine/src/cmd/graph/tests.rs | 237 ++++++++++++++++++++++++ crates/engine/src/cmd/paint.rs | 12 +- crates/engine/src/cmd/recolor.rs | 2 +- crates/engine/src/cmd/stroke.rs | 4 +- docs/mcp.md | 13 +- docs/target-app-parity.md | 2 +- 14 files changed, 443 insertions(+), 41 deletions(-) diff --git a/crates/engine/src/cmd/addons/color.rs b/crates/engine/src/cmd/addons/color.rs index fa8182c4c2..8701d379f7 100644 --- a/crates/engine/src/cmd/addons/color.rs +++ b/crates/engine/src/cmd/addons/color.rs @@ -43,7 +43,7 @@ pub fn specs() -> Vec { "Apply as Filter", ["Object", "Plug-ins", "Color Effects"], None, - "{effect: a colour adjustment's effect id (adjust.brightnessContrast, adjust.curves, adjust.hueSaturation, adjust.levels, adjust.shiftToColor, adjust.temperatureTint, addon.color.exposure, addon.color.desaturate, addon.color.duotone or addon.color.invert), params?: {…} (as effect.apply; missing keys take the dialog defaults), fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?: [id…] (default: the selection)} recolour the objects and everything inside them as the live effect would draw them, but in their colours, as edit.colors.invert does (fills, strokes, type, gradient meshes, embedded images and pattern tiles), as one undo step; their effects stay. Duotone links a colour to its one tone's swatch as the live effect does. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {changed}", + "{effect: a colour adjustment's effect id (adjust.brightnessContrast, adjust.curves, adjust.hueSaturation, adjust.levels, adjust.shiftToColor, adjust.temperatureTint, addon.color.exposure, addon.color.desaturate, addon.color.duotone or addon.color.invert), params?: {…} (as effect.apply; missing keys take the dialog defaults), fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?: [id…] (default: the selection)} recolour the objects and everything inside them as the live effect would draw them, but in their colours, as edit.colors.invert does (fills, strokes, type, gradient meshes, embedded images and pattern tiles), as one undo step; their effects stay. Duotone links a colour to its one tone's swatch as the live effect does. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke, and on a graph label the new paint of its characters and rows for every label of its kind (docs/mcp.md) → {changed}", has_selection, filter ), @@ -52,7 +52,7 @@ pub fn specs() -> Vec { "Print Ink Fixes", ["Object", "Plug-ins", "Color Effects"], None, - "{scope?: \"selection\" (the selection, or `ids`; by default, the document while nothing is selected) | \"document\" (every layer), inkLimit?: total ink % 1..400 (0 or left out: off), excludePureBlack?: true (black-only and grey colours keep their ink), removeWhiteOverprint?: false (white fills and strokes stop overprinting), overprintBlack?: black % 1..100 (black-only and grey fills and strokes this dark or darker overprint, as edit.colors.overprintBlack; 0 or left out: off), textOnly?: false (Overprint Black on type only), fill?: true, stroke?: true, ids?: [id…]} prepare a CMYK document's inks for print as one undo step: CMYK colours (solid, gradient stops, gradient meshes) with more total ink than inkLimit are scaled down to it (colours of spot swatches and Registration stay), white overprint is removed and black overprint set. Refused in RGB documents (File › Document Color Mode › CMYK Color first). On a path directly in a graph series, the graph also stores a paint the ink limit changes on the topmost visible fill or stroke (docs/mcp.md) → {changed, inkLimited: paints and meshes scaled, whiteOverprintRemoved: objects, blackOverprinted: objects}", + "{scope?: \"selection\" (the selection, or `ids`; by default, the document while nothing is selected) | \"document\" (every layer), inkLimit?: total ink % 1..400 (0 or left out: off), excludePureBlack?: true (black-only and grey colours keep their ink), removeWhiteOverprint?: false (white fills and strokes stop overprinting), overprintBlack?: black % 1..100 (black-only and grey fills and strokes this dark or darker overprint, as edit.colors.overprintBlack; 0 or left out: off), textOnly?: false (Overprint Black on type only), fill?: true, stroke?: true, ids?: [id…]} prepare a CMYK document's inks for print as one undo step: CMYK colours (solid, gradient stops, gradient meshes) with more total ink than inkLimit are scaled down to it (colours of spot swatches and Registration stay), white overprint is removed and black overprint set. Refused in RGB documents (File › Document Color Mode › CMYK Color first). On a path directly in a graph series, the graph also stores a paint the ink limit changes on the topmost visible fill or stroke, and on a graph label the new paint of its characters and rows for every label of its kind (docs/mcp.md) → {changed, inkLimited: paints and meshes scaled, whiteOverprintRemoved: objects, blackOverprinted: objects}", has_doc, print_fixes::run ), @@ -70,7 +70,7 @@ pub fn specs() -> Vec { "Apply Nearest Swatch", ["Object", "Plug-ins", "Color Effects"], None, - "{library?: as addon.color.nearestSwatch (default: \"document\"), fill?: true, stroke?: true, ids?: [id…] (default: the selection)} give each solid colour and gradient stop of the selected objects' fills and strokes (their type's and those of the objects inside them; gradient meshes too) the colour of its nearest swatch in the library, as addon.color.nearestSwatch finds it, as one undo step: a global or spot swatch's colour links to it. A library's swatches the colours take are added to the document's first, as swatch.library.add adds them (a spot colour stays spot); images and patterns keep their colours. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {changed: paints changed, swatches: [the document swatches the colours took], added: [swatches added to the document]}", + "{library?: as addon.color.nearestSwatch (default: \"document\"), fill?: true, stroke?: true, ids?: [id…] (default: the selection)} give each solid colour and gradient stop of the selected objects' fills and strokes (their type's and those of the objects inside them; gradient meshes too) the colour of its nearest swatch in the library, as addon.color.nearestSwatch finds it, as one undo step: a global or spot swatch's colour links to it. A library's swatches the colours take are added to the document's first, as swatch.library.add adds them (a spot colour stays spot); images and patterns keep their colours. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke, and on a graph label the new paint of its characters and rows for every label of its kind (docs/mcp.md) → {changed: paints changed, swatches: [the document swatches the colours took], added: [swatches added to the document]}", has_selection, nearest::apply ), diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index e30ba811d4..7395db5cea 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -508,9 +508,11 @@ fn require_targets(s: &Session, p: &Value, cmd: &str) -> Result> { /// One undo step (`label`) running `f` on each of `ids` with the item a fill (`fill`) or stroke /// edit aimed at `item` changes there (`None`: the topmost one; text then edits its characters). -/// What the edit changes on the topmost visible fill and stroke of graph series marks is stored on -/// their graphs in the same step ([`capture_series_changes`]). Nothing targeted is not an edit, and -/// when `f` leaves every target as it was, the document and its undo history stay as they are. +/// What the edit changes on the topmost visible fill and stroke of graph series marks, and on the +/// rows and characters of graph labels, is stored on their graphs in the same step +/// ([`capture_series_changes`], [`super::graph::capture_label_changes`]). Nothing targeted is not +/// an edit, and when `f` leaves every target as it was, the document and its undo history stay as +/// they are. pub(crate) fn edit_items( s: &mut Session, ids: &[NodeId], @@ -520,7 +522,12 @@ pub(crate) fn edit_items( fill: bool, f: impl FnMut(&mut Node, Option) -> Result<()>, ) -> Result<()> { - edit_items_then(s, ids, item, cmd, label, fill, f, |before, d, edited| Ok(capture_series_changes(before, d, edited))) + edit_items_then(s, ids, item, cmd, label, fill, f, |before, d, edited| { + let series = capture_series_changes(before, d, edited); + // The rows `f` edited are kept by `edit_items_then`. + let labels = super::graph::capture_label_changes(before, d, edited, false); + Ok(series || labels) + }) } /// [`edit_items`], then `after` on the document as it was before the edit, the edited document and diff --git a/crates/engine/src/cmd/colorcmds.rs b/crates/engine/src/cmd/colorcmds.rs index 6de2fbf9a2..7ad53d5bf5 100644 --- a/crates/engine/src/cmd/colorcmds.rs +++ b/crates/engine/src/cmd/colorcmds.rs @@ -36,7 +36,7 @@ pub fn specs() -> Vec { "Invert Colors", ["Edit", "Edit Colors"], None, - "{fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?} replace each colour by its RGB inverse (keeps the colour model) in fills, strokes, text, gradient meshes (with fills), colorized image fills (their pixels stay unchanged), other embedded images (a recoloured copy) and pattern tiles (a new pattern swatch; the original stays), as one undo step. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {changed, notChanged: {locked, hidden} (when the selection is everything select.all takes: the locked and hidden objects it left out whose colors would have changed; an object both hidden and locked counts as hidden), warning?: set when notChanged counts any object}", + "{fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?} replace each colour by its RGB inverse (keeps the colour model) in fills, strokes, text, gradient meshes (with fills), colorized image fills (their pixels stay unchanged), other embedded images (a recoloured copy) and pattern tiles (a new pattern swatch; the original stays), as one undo step. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke, and on a graph label the new paint of its characters and rows for every label of its kind (docs/mcp.md) → {changed, notChanged: {locked, hidden} (when the selection is everything select.all takes: the locked and hidden objects it left out whose colors would have changed; an object both hidden and locked counts as hidden), warning?: set when notChanged counts any object}", has_selection, |s, p| recolor(s, p, "edit.colors.invert", "Invert Colors", &invert) ), @@ -486,14 +486,16 @@ fn run_pass(s: &mut Session, p: &Value, cmd: &str, label: &str, pass: Recolor) - } /// Run `recolor` on `d` (it returns how many objects it changed) over `roots`, then store on their graphs what it changed -/// on the graph series marks among `roots` and inside them, so the marks keep their new paint when the graph is drawn -/// again. +/// on the graph series marks and graph labels among `roots` and inside them, so the marks and every label of a kind keep +/// their new paint when the graph is drawn again. pub(crate) fn keeping_series_paint(d: &mut Document, roots: &[NodeId], recolor: impl FnOnce(&mut Document) -> Result) -> Result { let marks = super::graph::series_marks_in(d, roots); - let before = (!marks.is_empty()).then(|| d.clone()); + let labels = super::graph::labels_in(d, roots); + let before = (!marks.is_empty() || !labels.is_empty()).then(|| d.clone()); let n = recolor(d)?; if let Some(before) = before.filter(|_| n > 0) { super::appearance::capture_series_changes(&before, d, &marks); + super::graph::capture_label_changes(&before, d, &labels, true); } Ok(n) } diff --git a/crates/engine/src/cmd/freeform.rs b/crates/engine/src/cmd/freeform.rs index bdba729211..5f2a5e4070 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); 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}", + "{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; the points on a label's characters are not stored → {index}", has_doc, add_point ), diff --git a/crates/engine/src/cmd/gradient.rs b/crates/engine/src/cmd/gradient.rs index 54ca07da7e..8062d6110a 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); on a graph label's row, the graph stores the label's topmost visible fill and stroke rows for every label of its kind", + "{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, and on its characters their gradient without its placement, fitted to each label", 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); 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", + "{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, and on its characters their gradient without the placement this command sets, 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 e283afd752..0413a55dbb 100644 --- a/crates/engine/src/cmd/graph.rs +++ b/crates/engine/src/cmd/graph.rs @@ -45,9 +45,10 @@ use generate::generate; #[cfg(test)] use series::default_series_color; pub(crate) use series::{ - 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, + LabelTextChange, capture_label_anti_alias, capture_label_changes, 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, labels_in, 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/generate.rs b/crates/engine/src/cmd/graph/generate.rs index a789304b24..60217105d8 100644 --- a/crates/engine/src/cmd/graph/generate.rs +++ b/crates/engine/src/cmd/graph/generate.rs @@ -6,7 +6,7 @@ use super::labels::{ recolour_text, runs, shadow_of, }; use super::markers::{MARKER_BUDGET, marker_cost, marker_designs}; -use super::series::{series_marks, with_series_opacity}; +use super::series::{picks_legend_fill, series_marks, with_series_opacity}; use super::{ Arc, BezPath, Color, Document, GraphKind, GraphSpec, Justify, Node, NodeKind, Paint, PathData, Point, Rect, TickLength, ValueAxisSide, shapes, }; @@ -397,8 +397,7 @@ impl<'a> Plot<'a> { let at = ring.centre + dir(mid) * (ring.hole + (ring.rad - ring.hole) * 0.62); let t = self.b.label(Role::Legend, Point::new(at.x, at.y + self.ls * 0.35), &label, Justify::Center, true); // White on a dark wedge, unless the legend labels were given a colour of their own. - let own_colour = self.g.label_styles.legend.as_ref().is_some_and(|st| st.fill != Paint::solid(Color::BLACK)); - self.wedge_labels.push(if dark && !own_colour { recolour_text(&t, Color::WHITE) } else { t }); + self.wedge_labels.push(if dark && picks_legend_fill(self.g) { recolour_text(&t, Color::WHITE) } else { t }); } /// The pie's category name. diff --git a/crates/engine/src/cmd/graph/series.rs b/crates/engine/src/cmd/graph/series.rs index bfde9e881a..806d95047f 100644 --- a/crates/engine/src/cmd/graph/series.rs +++ b/crates/engine/src/cmd/graph/series.rs @@ -487,20 +487,27 @@ pub(crate) fn capture_label_anti_alias(doc: &mut Document, ids: &[NodeId], metho pub(crate) fn capture_label_styles(doc: &mut Document, ids: &[NodeId], change: &dyn Fn(&mut CharStyle)) -> bool { let mut changed = false; for (graph, role) in label_kinds(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 mut style = label_style(&spec.label_styles, role); - change(&mut style); - let slot = match role { - Role::Value => &mut spec.label_styles.values, - Role::Category => &mut spec.label_styles.categories, - Role::Legend => &mut spec.label_styles.legend, - }; - changed |= slot.as_ref() != Some(&style); - *slot = Some(style); + changed |= store_label_style(doc, graph, role, change); } changed } +/// Apply `change` to the style graph `graph` keeps for its labels of kind `role` (or the default one). Returns whether +/// the kept style changed. +fn store_label_style(doc: &mut Document, graph: NodeId, role: Role, change: &dyn Fn(&mut CharStyle)) -> bool { + let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { return false }; + let mut style = label_style(&spec.label_styles, role); + change(&mut style); + let slot = match role { + Role::Value => &mut spec.label_styles.values, + Role::Category => &mut spec.label_styles.categories, + Role::Legend => &mut spec.label_styles.legend, + }; + let changed = slot.as_ref() != Some(&style); + *slot = Some(style); + changed +} + /// Keep a paragraph change made to all of the text of graph labels `ids` (Paragraph, on the whole graph or on labels /// picked with Group Selection) for every label of their kind, as [`capture_label_styles`] does for character /// formats. Returns whether kept attributes changed, or the error `change` returns for the kept attributes. @@ -668,6 +675,144 @@ pub(crate) fn capture_label_text( Ok(changed) } +/// The character fill and stroke attributes a kind of graph label keeps when a recolor, or a stroke or gradient edit +/// aimed at the characters, changes them on its labels ([`capture_label_changes`]). Each entry copies one attribute +/// from the first style to the second and returns whether they differed. +const RUN_PAINT: [fn(&CharStyle, &mut CharStyle) -> bool; 8] = [ + |a, b| keep(&a.fill, &mut b.fill), + |a, b| keep(&a.stroke, &mut b.stroke), + |a, b| keep(&a.stroke_width, &mut b.stroke_width), + |a, b| keep(&a.stroke_cap, &mut b.stroke_cap), + |a, b| keep(&a.stroke_join, &mut b.stroke_join), + |a, b| keep(&a.stroke_miter_limit, &mut b.stroke_miter_limit), + |a, b| keep(&a.stroke_dash, &mut b.stroke_dash), + |a, b| keep(&a.stroke_align, &mut b.stroke_align), +]; + +/// Copy `from` to `to`. Returns whether they differed. +fn keep(from: &T, to: &mut T) -> bool { + let differs = from != to; + if differs { + to.clone_from(from); + } + differs +} + +/// The [`RUN_PAINT`] attributes an edit changed on the characters of graph label `now` (`was`: the label before the +/// edit), as the label's style and the indexes of the changed entries. An attribute counts only when every character +/// of the label shows it alike, and the fill only when `fill` is set. A placed gradient is kept without its placement, +/// so it fits each label it lands on. The stroke weight and dashes are kept as the label shows them: the graph draws its +/// labels again without the label's own scale. +fn run_paint_change(was: &Node, now: &Node, fill: bool) -> Option<(CharStyle, Vec)> { + let (NodeKind::Text(was), NodeKind::Text(now)) = (&was.kind, &now.kind) else { return None }; + let first_now = &now.runs.first()?.style; + // Changed: some character showed something else before the edit, whichever run it is in. + let changed: Vec = RUN_PAINT + .iter() + .enumerate() + .filter(|(i, copy)| { + (fill || *i != 0) + && now.runs.iter().all(|r| !copy(first_now, &mut r.style.clone())) + && was.runs.iter().any(|r| copy(first_now, &mut r.style.clone())) + }) + .map(|(i, _)| i) + .collect(); + if changed.is_empty() { + return None; + } + let unplaced = crate::cmd::gradient::unplaced; + let mut style = first_now.clone(); + (style.fill, style.stroke) = (unplaced(&style.fill), unplaced(&style.stroke)); + // Character strokes are drawn in text space, scaled by the type's transform. + if let Some(k) = vectorcraft_doc::Scaling::factor(now.xf).filter(|k| k.is_finite() && *k > 1e-12) { + let mut st = style.stroke_layer(); + crate::inspect::scale_layer(&mut st, k); + style.set_stroke_layer(&st); + } + Some((style, changed)) +} + +/// Does graph `g` pick the fill of its legend labels itself, white on a dark wedge and black on the others: a pie with +/// Legends in Wedges whose legend labels keep no fill of their own? +pub(super) fn picks_legend_fill(g: &GraphSpec) -> bool { + g.kind == GraphKind::Pie + && g.legend + && g.pie_legend_in_wedges + && !g.label_styles.legend.as_ref().is_some_and(|st| st.fill != Paint::solid(Color::BLACK)) +} + +/// The graph labels among `roots` and inside them, for an edit that reaches graph labels without naming them. +pub(crate) fn labels_in(doc: &Document, roots: &[NodeId]) -> Vec { + let mut texts = Vec::new(); + for id in roots { + let Some(root) = doc.node(*id) else { continue }; + root.walk(&mut |n| { + if matches!(n.kind, NodeKind::Text(_)) { + texts.push(n.id); + } + }); + } + // Graph labels are type: without type among `roots`, the graph's labels are not looked up. + if texts.is_empty() { + return texts; + } + let labels = graph_labels(doc); + texts.retain(|id| labels.contains_key(id)); + // Nested roots (a graph and one of its labels) reach a label twice. + texts.sort_unstable(); + texts.dedup(); + texts +} + +/// Keep for every label of their kind what an edit changed on graph labels `ids` (`before`: the document before the +/// edit): with `rows`, the object rows ([`capture_label_rows`]) of the labels whose rows changed, and the character fill +/// and stroke attributes ([`RUN_PAINT`]) that changed on all of a label's characters, on the kind's kept style. The +/// frontmost label of a kind sets what that kind keeps, whatever order `ids` are in. On a graph that colors its legend +/// labels itself ([`picks_legend_fill`]), their fill is kept only when the legend labels among `ids` end up with one +/// fill. Called inside the same edit, so they undo together. Returns whether what a graph keeps changed. +pub(crate) fn capture_label_changes(before: &Document, doc: &mut Document, ids: &[NodeId], rows: bool) -> bool { + if !ids.iter().any(|id| doc.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Text(_)))) { + return false; + } + let labels = graph_labels(doc); + let mut picked: Vec = ids.iter().copied().filter(|id| labels.contains_key(id)).collect(); + // A later label's capture replaces an earlier one's: take them in paint order, each once. + picked.sort_by_cached_key(|id| doc.index_path(*id)); + picked.dedup(); + let picks_fill = |graph: NodeId| doc.node(graph).and_then(|n| n.extras().graph.as_deref()).is_some_and(picks_legend_fill); + // On a pie that colors its legend labels black or white by their wedge, a recolor of the whole pie flips them both + // ways: their fill is kept only when every legend label the edit reached ends up with the same one. + let mut wedge_fills: HashMap> = HashMap::new(); + for id in &picked { + let (Some(&(graph, Role::Legend)), Some(NodeKind::Text(t))) = (labels.get(id), doc.node(*id).map(|n| &n.kind)) else { continue }; + if picks_fill(graph) { + wedge_fills.entry(graph).or_default().extend(t.runs.iter().map(|r| r.style.fill.clone())); + } + } + let one_fill = |graph: NodeId| wedge_fills.get(&graph).is_none_or(|f| f.windows(2).all(|w| w[0] == w[1])); + let mut changed_rows = Vec::new(); + let mut characters = Vec::new(); + for (id, kind) in picked.iter().filter_map(|id| Some((*id, *labels.get(id)?))) { + let (Some(was), Some(now)) = (before.node(id), doc.node(id)) else { continue }; + if rows && label_rows(was) != label_rows(now) { + changed_rows.push(id); + } + let fill = kind.1 != Role::Legend || one_fill(kind.0); + characters.extend(run_paint_change(was, now, fill).map(|change| (kind, change))); + } + let mut changed = capture_label_rows(doc, &changed_rows); + for ((graph, role), (style, entries)) in &characters { + changed |= store_label_style(doc, *graph, *role, &|st| { + for i in entries { + if let Some(copy) = RUN_PAINT.get(*i) { + copy(style, st); + } + } + }); + } + 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 bcd6f406ed..a4bcb7e637 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -5480,6 +5480,243 @@ fn a_graphic_style_on_a_label_is_kept_for_its_kind() { assert!(rows_of(&looks, "0").0.iter().any(|(fill, hex, _, _)| *fill && hex == "#0080ff"), "{looks:?}"); } +/// The character style of the first run of label `text` in group `grp`. +fn label_run_style(s: &Session, id: NodeId, grp: &str, text: &str) -> CharStyle { + let NodeKind::Text(t) = label_node(s, id, grp, text).kind else { panic!("{text} is not type") }; + t.runs[0].style.clone() +} + +/// A recolor of the whole graph (Invert or Complement on the proxy, an Edit Colors command, a color filter or Recolor +/// Artwork) is kept for the characters and the rows of every kind of label when the graph is drawn again, and undo takes +/// it back with its edit. +#[test] +fn recoloring_a_graph_keeps_its_labels_paint_for_every_kind() { + let cases = [ + ("paint.invert", json!({})), + ("paint.complement", json!({})), + ("edit.colors.invert", json!({})), + ("edit.colors.toGrayscale", json!({})), + ("addon.color.filter", json!({"effect": "addon.color.invert"})), + ("recolor.apply", json!({"map": {"#3366cc": "#00ff00", "#ff8000": "#8000ff"}})), + ]; + every_case(cases, |(cmd, params)| { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + // Characters of every label in a color that every case changes, and an orange fill row on the category labels. + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#3366cc"})).unwrap(); + 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": "#ff8000"})).unwrap(); + let was = spec(&s, id).label_styles; + let mut p = params.clone(); + if *cmd == "recolor.apply" { + // Recolor Artwork works on the selection. + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + } else { + p["ids"] = json!([id.0]); + } + s.execute(cmd, &p).unwrap(); + let hex = |p: &Paint| p.color().map(|c| c.to_hex()).unwrap_or_default(); + let characters = hex(&label_run_style(&s, id, "Axes", "4").fill); + assert_ne!(characters, "#3366cc", "{cmd} recolors the characters"); + let row = label_node(&s, id, "Axes", "Q2").appearance.items[0].paint().clone(); + assert_ne!(hex(&row), "#ff8000", "{cmd} recolors the row"); + let looks = label_looks(&mut s, id); + for t in ["0", "Q1", "2024"] { + assert!(looks.iter().any(|(x, _, fill)| x == t && *fill == characters), "{cmd}: {t}: {looks:?}"); + } + let rows = redrawn_label_rows(&mut s, id); + assert_eq!(rows_of(&rows, "Q1").0.first().map(|r| r.1.clone()), Some(hex(&row)), "{cmd}: {rows:?}"); + // Undo takes back the recolor and what the graph keeps (the two redraws are undone first). + for _ in 0..3 { + s.execute("edit.undo", &json!({})).unwrap(); + } + assert_eq!(spec(&s, id).label_styles, was, "{cmd}"); + }); +} + +/// A recolor of one label changes what the graph keeps for that kind of label only. +#[test] +fn recoloring_one_label_keeps_its_paint_for_its_kind_only() { + 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("edit.colors.invert", &json!({"ids": [v.0]})).unwrap(); + let looks = label_looks(&mut s, id); + assert!(looks.iter().any(|(t, _, fill)| t == "0" && fill == "#ffffff"), "{looks:?}"); + assert!(looks.iter().any(|(t, _, fill)| t == "Q1" && fill == "#000000"), "{looks:?}"); + assert!(looks.iter().any(|(t, _, fill)| t == "2024" && fill == "#000000"), "{looks:?}"); + let g = spec(&s, id).label_styles; + assert!(g.categories.is_none() && g.legend.is_none(), "{g:?}"); +} + +/// A recolor that leaves a label's first characters as they were and gives the rest the same fill is kept for its kind. +#[test] +fn a_recolor_that_evens_out_a_labels_characters_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 q2 = text_id(&s, id, "Axes", "Q2"); + s.select_tool("type", crate::ViewInfo::default()).unwrap(); + for (start, end, hex) in [(0, 1, "#ff0000"), (1, 2, "#0000ff")] { + s.set_tool_option("textSelection", &json!({"id": q2.0, "start": start, "end": end})); + s.execute("paint.setFill", &json!({"color": hex})).unwrap(); + } + s.select_tool("selection", crate::ViewInfo::default()).unwrap(); + assert_eq!(spec(&s, id).label_styles.categories, None, "part of a label's text is not kept"); + s.execute("select.set", &json!({"ids": [q2.0]})).unwrap(); + s.execute("recolor.apply", &json!({"map": {"#0000ff": "#ff0000"}})).unwrap(); + let looks = label_looks(&mut s, id); + assert!(looks.iter().any(|(t, _, fill)| t == "Q1" && fill == "#ff0000"), "{looks:?}"); + assert!(looks.iter().any(|(t, _, fill)| t == "0" && fill == "#000000"), "{looks:?}"); +} + +/// Stroke options set with `stroke.set` and a gradient made with `paint.editGradient` on a label's characters are kept +/// for every label of its kind, the gradient without its placement. +#[test] +fn stroke_and_gradient_edits_on_a_labels_characters_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 category = label_run_style(&s, id, "Axes", "Q1"); + let v = text_id(&s, id, "Axes", "4"); + s.execute("paint.setStroke", &json!({"ids": [v.0], "color": "#ff0000"})).unwrap(); + s.execute("stroke.set", &json!({"ids": [v.0], "weight": 3, "cap": "round"})).unwrap(); + s.execute("paint.editGradient", &json!({"ids": [v.0], "kind": "linear", "stroke": false})).unwrap(); + assert!(matches!(label_run_style(&s, id, "Axes", "4").fill, Paint::Gradient(_)), "the edit makes a gradient"); + label_looks(&mut s, id); + let st = label_run_style(&s, id, "Axes", "0"); + assert_eq!((st.stroke_width, st.stroke_cap), (3.0, vectorcraft_doc::LineCap::Round)); + assert!(matches!(&st.fill, Paint::Gradient(g) if g.geom.is_none()), "{:?}", st.fill); + assert_eq!(label_run_style(&s, id, "Axes", "Q1"), category, "the category labels are left as they were"); +} + +/// `stroke.set`, `stroke.weightUp` and `stroke.weightDown` given the whole graph are kept for every kind of label. +#[test] +fn stroke_weights_set_on_the_whole_graph_are_kept_for_its_labels() { + let cases = [("stroke.set", json!({"weight": 2}), 2.0), ("stroke.weightUp", json!({}), 2.0), ("stroke.weightDown", json!({}), 0.75)]; + every_case(cases, |(cmd, params, weight)| { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + s.execute("paint.setStroke", &json!({"ids": [id.0], "color": "#ff0000"})).unwrap(); + let mut p = params.clone(); + p["ids"] = json!([id.0]); + s.execute(cmd, &p).unwrap(); + assert_eq!(label_run_style(&s, id, "Axes", "4").stroke_width, *weight, "{cmd}"); + label_looks(&mut s, id); + for (grp, t) in [("Axes", "0"), ("Axes", "Q1"), ("Legend", "2024")] { + assert_eq!(label_run_style(&s, id, grp, t).stroke_width, *weight, "{cmd}: {t}"); + } + }); +} + +/// A stroke weight set on a scaled label is kept as the label shows it, since the graph draws its labels unscaled. +#[test] +fn a_stroke_weight_on_a_scaled_label_is_kept_as_it_shows() { + 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("select.set", &json!({"ids": [v.0]})).unwrap(); + s.execute("object.scale", &json!({"sx": 200, "sy": 200})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [v.0], "color": "#ff0000"})).unwrap(); + s.execute("stroke.set", &json!({"ids": [v.0], "weight": 3})).unwrap(); + assert_eq!(label_run_style(&s, id, "Axes", "4").stroke_width, 1.5, "drawn at twice its size"); + label_looks(&mut s, id); + assert_eq!(label_run_style(&s, id, "Axes", "0").stroke_width, 3.0); +} + +/// When the characters of two labels of one kind are recolored differently, the frontmost one sets what the kind +/// keeps, whatever order `ids` are in. +#[test] +fn the_frontmost_label_of_a_kind_sets_what_its_recolor_keeps() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + // Legend labels: the axes tell value and category labels apart by their order, so these are moved instead. + let (a, b) = (text_id(&s, id, "Legend", "2024"), text_id(&s, id, "Legend", "2025")); + s.execute("paint.setFill", &json!({"ids": [a.0], "color": "#ff0000"})).unwrap(); + s.execute("paint.setFill", &json!({"ids": [b.0], "color": "#0000ff"})).unwrap(); + // The label made first goes in front, so neither the order of `ids` nor the order the labels were made in decides. + let (front, back) = if a < b { (a, b) } else { (b, a) }; + s.execute("select.set", &json!({"ids": [front.0]})).unwrap(); + s.execute("object.arrange.bringToFront", &json!({})).unwrap(); + let path = |n: NodeId| s.doc().unwrap().doc.index_path(n); + assert!(path(front) > path(back), "{:?} {:?}", path(front), path(back)); + let front_fill = if front == a { "#00ffff" } else { "#ffff00" }; + s.execute("edit.colors.invert", &json!({"ids": [front.0, back.0]})).unwrap(); + let kept = spec(&s, id).label_styles.legend.unwrap().fill; + assert_eq!(kept.color().map(|c| c.to_hex()).as_deref(), Some(front_fill)); +} + +/// Apply Nearest Swatch and the ink limit of Print Ink Fixes on a label are kept for its kind. +#[test] +fn nearest_swatch_and_ink_limit_on_a_label_are_kept_for_its_kind() { + let cases = [ + ("addon.color.applyNearestSwatch", json!({"library": "web-safe-216"})), + ("addon.color.printFixes", json!({"scope": "selection", "inkLimit": 200})), + ]; + every_case(cases, |(cmd, params)| { + let mut s = Session::new(); + // Print Ink Fixes works on CMYK documents. + s.execute("file.new", &json!({"width": 800, "height": 800, "colorMode": "cmyk"})).unwrap(); + let id = graph(&mut s, "column"); + let v = text_id(&s, id, "Axes", "4"); + s.execute("paint.setFill", &json!({"ids": [v.0], "color": {"c": 90, "m": 80, "y": 70, "k": 60}})).unwrap(); + let was = label_run_style(&s, id, "Axes", "4").fill; + let mut p = params.clone(); + p["ids"] = json!([v.0]); + s.execute(cmd, &p).unwrap(); + let now = label_run_style(&s, id, "Axes", "4").fill; + assert_ne!(now, was, "{cmd} changes the fill"); + label_looks(&mut s, id); + assert_eq!(label_run_style(&s, id, "Axes", "0").fill, now, "{cmd}"); + }); +} + +/// Swap Fill and Stroke and Default Fill and Stroke on a label are kept for its kind. +#[test] +fn swap_and_default_fill_and_stroke_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("paint.setStroke", &json!({"ids": [v.0], "color": "#ff0000"})).unwrap(); + s.execute("paint.swap", &json!({"ids": [v.0]})).unwrap(); + let swapped = label_run_style(&s, id, "Axes", "4"); + label_looks(&mut s, id); + let st = label_run_style(&s, id, "Axes", "0"); + assert_eq!((&st.fill, &st.stroke), (&swapped.fill, &swapped.stroke)); + assert_eq!(st.fill.color().map(|c| c.to_hex()).as_deref(), Some("#ff0000")); + s.execute("paint.default", &json!({"ids": [text_id(&s, id, "Axes", "4").0]})).unwrap(); + label_looks(&mut s, id); + let st = label_run_style(&s, id, "Axes", "0"); + assert_eq!((st.fill.color().map(|c| c.to_hex()).as_deref(), st.stroke.is_none()), (Some("#000000"), true)); +} + +/// On a pie with Legends in Wedges, a recolor of the whole graph leaves the legend labels to the graph's black or white. +#[test] +fn a_recolor_leaves_wedge_legend_labels_their_contrast() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = make(&mut s, "pie", ",a,b\nP1,1,2"); + s.execute("graph.setType", &json!({"id": id.0, "pieLegend": "wedges"})).unwrap(); + s.execute("edit.colors.invert", &json!({"ids": [id.0]})).unwrap(); + assert_eq!(spec(&s, id).label_styles.legend, None); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2]]})).unwrap(); + // The first series' black wedge is white now, so its label is black. + let NodeKind::Text(t) = label_node(&s, id, "Legend", "a").kind else { panic!("a is not type") }; + assert_eq!(t.runs[0].style.fill.color().map(|c| c.to_hex()).as_deref(), Some("#000000")); + // A recolor of one legend label gives the kind that label's fill. + s.execute("edit.colors.invert", &json!({"ids": [text_id(&s, id, "Legend", "a").0]})).unwrap(); + let kept = spec(&s, id).label_styles.legend.map(|st| st.fill); + assert_eq!(kept.and_then(|f| f.color()).map(|c| c.to_hex()).as_deref(), Some("#ffffff")); +} + #[test] fn pie_names_and_wedge_labels_keep_their_style() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index c7159d563a..60c5180f40 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -39,7 +39,7 @@ pub fn specs() -> Vec { "Swap Fill and Stroke", [], Some("Shift+X"), - "{ids?|id?} swap the fill and stroke of the selection (type too) and of the defaults. On a path directly in a graph series, the graph also stores the swapped paints and the stroke weight (exceptions: docs/mcp.md)", + "{ids?|id?} swap the fill and stroke of the selection (type too) and of the defaults. On a path directly in a graph series, the graph also stores the swapped paints and the stroke weight, and on a graph label the swapped paints of its characters for every label of its kind (exceptions: docs/mcp.md)", has_doc, swap ), @@ -48,7 +48,7 @@ pub fn specs() -> Vec { "Default Fill and Stroke", [], Some("D"), - "{ids?|id?} white fill and 1 pt black stroke for the selection and the defaults; type gets black fill and no stroke. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke (exceptions: docs/mcp.md)", + "{ids?|id?} white fill and 1 pt black stroke for the selection and the defaults; type gets black fill and no stroke. On a path directly in a graph series, the graph also stores the paint, weight, opacity and blend mode of the topmost visible fill and stroke, and on a graph label the black fill and no stroke of its characters for every label of its kind (exceptions: docs/mcp.md)", has_doc, default_paint ), @@ -96,7 +96,7 @@ pub fn proxy_specs() -> Vec { "Invert", [], None, - "{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} invert the active proxy's colours of the selection (or ids; groups recolour their contents) keeping each colour's model, as edit.colors.invert does; the Appearance panel's active fill/stroke item (appearance.setActiveItem) is inverted instead when it is of that kind and no ids are given; with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's solid color changes for this instance only, and a gradient or pattern, or type whose characters differ in color, is refused; with nothing selected, invert the default. On a path directly in a graph series, the graph also stores the new color of the topmost visible fill or stroke (docs/mcp.md) → {changed}", + "{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} invert the active proxy's colours of the selection (or ids; groups recolour their contents) keeping each colour's model, as edit.colors.invert does; the Appearance panel's active fill/stroke item (appearance.setActiveItem) is inverted instead when it is of that kind and no ids are given; with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's solid color changes for this instance only, and a gradient or pattern, or type whose characters differ in color, is refused; with nothing selected, invert the default. On a path directly in a graph series, the graph also stores the new color of the topmost visible fill or stroke, and on a graph label the new color of its characters and rows for every label of its kind (docs/mcp.md) → {changed}", has_doc, |s, p| proxy_recolor(s, p, "paint.invert", "Invert", &super::colorcmds::invert) ), @@ -925,7 +925,8 @@ fn swap(s: &mut Session, p: &Value) -> Result { } // The swap writes the paint of the topmost fill and stroke, not their opacity. let kept = super::appearance::capture_series_writes(&before, d, &ids, (true, true), (false, false), true, false); - Ok(super::appearance::count_changed(d, &before, &ids) + usize::from(kept)) + let labels = super::graph::capture_label_changes(&before, d, &ids, false); + Ok(super::appearance::count_changed(d, &before, &ids) + usize::from(kept || labels)) })?; } if s.paint.stroke.is_none() && !s.paint.fill.is_none() && s.paint.stroke_width == 0.0 { @@ -954,7 +955,8 @@ fn default_paint(s: &mut Session, p: &Value) -> Result { } } let kept = super::appearance::capture_series_writes(&before, d, &ids, (true, true), (true, true), false, false); - Ok(super::appearance::count_changed(d, &before, &ids) + usize::from(kept)) + let labels = super::graph::capture_label_changes(&before, d, &ids, false); + Ok(super::appearance::count_changed(d, &before, &ids) + usize::from(kept || labels)) })?; } super::newart::reset(s); diff --git a/crates/engine/src/cmd/recolor.rs b/crates/engine/src/cmd/recolor.rs index db0c0ac367..5d7e1ab633 100644 --- a/crates/engine/src/cmd/recolor.rs +++ b/crates/engine/src/cmd/recolor.rs @@ -34,7 +34,7 @@ pub fn specs() -> Vec { "Recolor Artwork", ["Edit", "Edit Colors"], None, - "{map: rows [{from: [colour keys (recolor.colors) or \"#rrggbb\" (every colour shown as that hex)], to: colour (hex, key, {c,m,y,k} or {l,a,b}) | null (in from, to and groupColors, #rrggbbaa and #rgba are refused), exclude?: false (the row's colours are kept), tint?: 100 (percent; with limitTo, art linked to the global or spot swatch the new color is takes this share of its tint)}] | {\"key or #rrggbb\": colour, …} (one row per colour), method?: \"exact\" (default)|\"preserveTints\"|\"scaleTints\"|\"tintsShades\"|\"hueShift\" (how a row's colours take its new colour, relative to the row's darkest, average or most saturated colour), limitTo?: swatch library id or name; or \"document\" (the document's swatches): new colours snap to its nearest colour, group?: colour group to rewrite, groupColors?: [colour] (what `group` is rewritten with; default: the rows' new colours, in order, excluded rows left out), rename?: the group's new name, includeImages?: true, includePatterns?: true, globalAdjust?: {saturation?: 0, brightness?: 0, temperature?: 0, luminosity?: 0} (Global Adjust: percent from -100 to 100, 0 changes nothing; every colour a row that isn't excluded recolours, and the group's new colours, take the same change after the row's new colour: saturation scales HSB saturation by 1 + saturation/100, brightness moves HSB brightness that share of the way to full or to black, temperature adds 0.2 × temperature/100 to red and takes it from blue, luminosity moves Lab lightness that share of the way to 100 or 0 keeping a and b; with limitTo each row's new colour takes the change before it snaps to the library)} recolour the selection (gradients, text, meshes, image pixels and pattern tiles included; each new colour keeps the model of the one it replaces (model?: \"keep\" default | \"new\" (the new colour's own model, so a CMYK colour replacing RGB art gives CMYK art) | \"document\" (the document's colour mode)), except a library swatch's with limitTo: art takes the swatch itself, linked to a global or spot colour at its tints, and the swatch joins the document's swatches; art linked to a global swatch of `group` that is rewritten with its row's new color stays linked to it, at its tint, unless the row converts the color into the art's model; other recolored art, and art in a row linked to a global swatch of `group` that is rewritten with another color, keeps its row's new color and loses its swatch link) and the group, as one undo step. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {changed, swatches: [names added to the document's swatches], relinked?: with group, the other paints linked to its global swatches that took their new colors, note?: set when a colour kept a model other than its new colour's (see model), notChanged: {locked, hidden} (as edit.colors.invert), warning?: set when notChanged counts any object}", + "{map: rows [{from: [colour keys (recolor.colors) or \"#rrggbb\" (every colour shown as that hex)], to: colour (hex, key, {c,m,y,k} or {l,a,b}) | null (in from, to and groupColors, #rrggbbaa and #rgba are refused), exclude?: false (the row's colours are kept), tint?: 100 (percent; with limitTo, art linked to the global or spot swatch the new color is takes this share of its tint)}] | {\"key or #rrggbb\": colour, …} (one row per colour), method?: \"exact\" (default)|\"preserveTints\"|\"scaleTints\"|\"tintsShades\"|\"hueShift\" (how a row's colours take its new colour, relative to the row's darkest, average or most saturated colour), limitTo?: swatch library id or name; or \"document\" (the document's swatches): new colours snap to its nearest colour, group?: colour group to rewrite, groupColors?: [colour] (what `group` is rewritten with; default: the rows' new colours, in order, excluded rows left out), rename?: the group's new name, includeImages?: true, includePatterns?: true, globalAdjust?: {saturation?: 0, brightness?: 0, temperature?: 0, luminosity?: 0} (Global Adjust: percent from -100 to 100, 0 changes nothing; every colour a row that isn't excluded recolours, and the group's new colours, take the same change after the row's new colour: saturation scales HSB saturation by 1 + saturation/100, brightness moves HSB brightness that share of the way to full or to black, temperature adds 0.2 × temperature/100 to red and takes it from blue, luminosity moves Lab lightness that share of the way to 100 or 0 keeping a and b; with limitTo each row's new colour takes the change before it snaps to the library)} recolour the selection (gradients, text, meshes, image pixels and pattern tiles included; each new colour keeps the model of the one it replaces (model?: \"keep\" default | \"new\" (the new colour's own model, so a CMYK colour replacing RGB art gives CMYK art) | \"document\" (the document's colour mode)), except a library swatch's with limitTo: art takes the swatch itself, linked to a global or spot colour at its tints, and the swatch joins the document's swatches; art linked to a global swatch of `group` that is rewritten with its row's new color stays linked to it, at its tint, unless the row converts the color into the art's model; other recolored art, and art in a row linked to a global swatch of `group` that is rewritten with another color, keeps its row's new color and loses its swatch link) and the group, as one undo step. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke, and on a graph label the new paint of its characters and rows for every label of its kind (docs/mcp.md) → {changed, swatches: [names added to the document's swatches], relinked?: with group, the other paints linked to its global swatches that took their new colors, note?: set when a colour kept a model other than its new colour's (see model), notChanged: {locked, hidden} (as edit.colors.invert), warning?: set when notChanged counts any object}", has_doc, apply ), diff --git a/crates/engine/src/cmd/stroke.rs b/crates/engine/src/cmd/stroke.rs index c5e5ba2a5c..14282c5112 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); 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}", + "{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, and on its characters the stroke options they take → {objects: how many objects took the stroke options}", has_doc, stroke_set ), @@ -31,7 +31,7 @@ pub fn specs() -> Vec { "Stroke Weight Up", [], Some("Cmd+Alt+."), - "{ids?|id?} raise each selected stroke's weight by one step of Units › Stroke (a quarter unit below one unit, then whole units; up to 1000 pt), each from its own weight: the selected objects' active or top stroke, type's stroked characters; objects without a stroke are left alone. With nothing selected (and no ids), the weight of the next object drawn. On a path directly in a graph series, the graph also stores the new weight of the topmost visible stroke (docs/mcp.md) → {objects}", + "{ids?|id?} raise each selected stroke's weight by one step of Units › Stroke (a quarter unit below one unit, then whole units; up to 1000 pt), each from its own weight: the selected objects' active or top stroke, type's stroked characters; objects without a stroke are left alone. With nothing selected (and no ids), the weight of the next object drawn. On a path directly in a graph series, the graph also stores the new weight of the topmost visible stroke, and on a graph label the new weight of its characters' stroke or its stroke row for every label of its kind (docs/mcp.md) → {objects}", has_doc, |s, p| stroke_step(s, p, true) ), diff --git a/docs/mcp.md b/docs/mcp.md index 992a3078fa..f8049365ef 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -807,8 +807,17 @@ commands store it too when they are given a series group, the graph or a group t leaves as it was keeps what the graph stores. When one command changes several paths of a series, the frontmost of them sets what the graph stores. A gradient is stored with its placement, and every path of the series takes that placement when the graph is drawn again. The graph does not store a stroke's dashes, caps, joins, arrowheads, width -profile or brush. These commands do not store the colors they give a graph's axes and labels, which are drawn again -with the paint they had. +profile or brush. On a graph label, given the label or something that holds it, these commands, `paint.swap` and +`paint.default` store what they change for every label of its kind: the label's object rows, as the `appearance.*` +commands store them, and the fill, stroke, stroke weight, caps, joins, miter limit, dashes and stroke alignment of its +characters when all of its characters show them alike. A gradient on the characters is stored without its placement +(its vector, aspect ratio, focal point and freeform points), and each label fits it to its own box when the graph is +drawn again, so `paint.setGradientGeom` and the `paint.freeform.*` edits do not store the placement they set there. A +stroke weight and dashes are stored as the label shows them, also on a label that was scaled. When the characters of +labels of one kind change differently, the frontmost label sets what the kind keeps. On a pie with Legends in Wedges +whose legend labels have no fill of their own, the legend labels' fill is stored only when every legend label the +command reaches ends up with the same fill. Otherwise they are drawn again in black, or white on a dark wedge. These commands do not store the colors they give a graph's axis lines, ticks and radar rings, +which are drawn again with their default paint. `text.setFormat` without `start` and `end`, on a graph or on its labels, stores the character and paragraph attributes it sets for each kind of label it reaches (value axis numbers, category labels, legend labels), and every label of that diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 8f87c83072..8a003daee9 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1088,7 +1088,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, 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). + - **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, or recolored, given Swap Fill and Stroke or Default Fill and Stroke, or given stroke options or a gradient, 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. From c88da287e72f40a2b2f0e67701c9e7a52f358c46 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 16:34:27 -0800 Subject: [PATCH 2/2] Graphs: the label gradient test places the gradient before the redraw drops its placement The test now sets the edited label's gradient vector and aspect with paint.setGradientGeom and checks that the gradient is placed before the graph redraws, so the later check that the kept gradient has no placement fails if the redraw keeps it. --- crates/engine/src/cmd/graph/tests.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index 1f128009f8..ea47c82112 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -5692,8 +5692,9 @@ fn a_recolor_that_evens_out_a_labels_characters_is_kept_for_its_kind() { assert!(looks.iter().any(|(t, _, fill)| t == "0" && fill == "#000000"), "{looks:?}"); } -/// Stroke options set with `stroke.set` and a gradient made with `paint.editGradient` on a label's characters are kept -/// for every label of its kind, the gradient without its placement. +/// Stroke options set with `stroke.set` and a gradient made with `paint.editGradient` and placed with +/// `paint.setGradientGeom` on a label's characters are kept for every label of its kind, the gradient without its +/// placement. #[test] fn stroke_and_gradient_edits_on_a_labels_characters_are_kept_for_its_kind() { let mut s = Session::new(); @@ -5704,7 +5705,10 @@ fn stroke_and_gradient_edits_on_a_labels_characters_are_kept_for_its_kind() { s.execute("paint.setStroke", &json!({"ids": [v.0], "color": "#ff0000"})).unwrap(); s.execute("stroke.set", &json!({"ids": [v.0], "weight": 3, "cap": "round"})).unwrap(); s.execute("paint.editGradient", &json!({"ids": [v.0], "kind": "linear", "stroke": false})).unwrap(); - assert!(matches!(label_run_style(&s, id, "Axes", "4").fill, Paint::Gradient(_)), "the edit makes a gradient"); + let placement = json!({"ids": [v.0], "stroke": false, "start": [100, 100], "end": [200, 100], "aspect": 50}); + s.execute("paint.setGradientGeom", &placement).unwrap(); + let fill = label_run_style(&s, id, "Axes", "4").fill; + assert!(matches!(&fill, Paint::Gradient(g) if g.geom.is_some()), "the edited label's gradient is placed: {fill:?}"); label_looks(&mut s, id); let st = label_run_style(&s, id, "Axes", "0"); assert_eq!((st.stroke_width, st.stroke_cap), (3.0, vectorcraft_doc::LineCap::Round));