Merge pull request #7121 from dotCooCoo/fix/graph-series-paint-other-commands

Graphs: stroke, gradient and recolor commands update a series' stored paint
This commit is contained in:
BFlat
2026-11-14 03:21:58 +07:00
committed by GitHub
14 changed files with 301 additions and 34 deletions
+3 -3
View File
@@ -43,7 +43,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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 → {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 (docs/mcp.md) → {changed}",
has_selection,
filter
),
@@ -52,7 +52,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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) → {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 (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<CommandSpec> {
"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 → {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 (docs/mcp.md) → {changed: paints changed, swatches: [the document swatches the colours took], added: [swatches added to the document]}",
has_selection,
nearest::apply
),
@@ -11,7 +11,7 @@ use vectorcraft_color::swatch::REGISTRATION;
use vectorcraft_doc::overprint::{OverprintBlack, is_white};
use vectorcraft_doc::{ColorMode, Node, NodeKind};
use super::super::super::colorcmds::{Recolor, Scope};
use super::super::super::colorcmds::{Recolor, Scope, keeping_series_paint};
use super::super::super::overprint::edit_counted;
use super::super::super::{bad, checked_ids_or_selection, number_or, switch_or, text_param};
use crate::{Result, Session};
@@ -149,7 +149,7 @@ pub(super) fn run(s: &mut Session, p: &Value) -> Result<Value> {
true
};
let scope = Scope { fill: fixes.fill, stroke: fixes.stroke, images: false, patterns: false };
ink = Recolor::with_links(scope, &|c| fixes.limit(c), &links).run(d, &ids);
ink = keeping_series_paint(d, &ids, |d| Ok(Recolor::with_links(scope, &|c| fixes.limit(c), &links).run(d, &ids)))?;
}
if !fixes.remove_white_overprint && fixes.overprint_black <= 0.0 {
return Ok(ink);
+23 -8
View File
@@ -508,8 +508,9 @@ fn require_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<NodeId>> {
/// 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).
/// 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 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.
pub(crate) fn edit_items(
s: &mut Session,
ids: &[NodeId],
@@ -519,12 +520,13 @@ pub(crate) fn edit_items(
fill: bool,
f: impl FnMut(&mut Node, Option<usize>) -> Result<()>,
) -> Result<()> {
edit_items_then(s, ids, item, cmd, label, fill, f, |_, _| Ok(false))
edit_items_then(s, ids, item, cmd, label, fill, f, |before, d, edited| Ok(capture_series_changes(before, d, edited)))
}
/// [`edit_items`], then `after` on the ids `f` ran on, inside the same undo step. `after` returns
/// whether it changed the document; when it did not and `f` left every target as it was, the
/// document and its undo history stay as they are.
/// [`edit_items`], then `after` on the document as it was before the edit, the edited document and
/// the ids `f` ran on, inside the same undo step. `after` returns whether it changed the document;
/// when it did not and `f` left every target as it was, the document and its undo history stay as
/// they are.
#[allow(clippy::too_many_arguments)]
pub(crate) fn edit_items_then(
s: &mut Session,
@@ -534,7 +536,7 @@ pub(crate) fn edit_items_then(
label: &str,
fill: bool,
mut f: impl FnMut(&mut Node, Option<usize>) -> Result<()>,
mut after: impl FnMut(&mut vectorcraft_doc::Document, &[NodeId]) -> Result<bool>,
mut after: impl FnMut(&vectorcraft_doc::Document, &mut vectorcraft_doc::Document, &[NodeId]) -> Result<bool>,
) -> Result<()> {
if ids.is_empty() {
return Ok(());
@@ -550,7 +552,7 @@ pub(crate) fn edit_items_then(
f(n, index)?;
edited.push(*id);
}
let more = after(d, &edited)?;
let more = after(&before, d, &edited)?;
Ok(count_changed(d, &before, &edited) + usize::from(more))
})?;
Ok(())
@@ -791,6 +793,19 @@ pub(super) fn capture_series_writes(
kept || painted || stored
}
/// Store on the graph what an edit of `ids` (from `before` to `d`) changed on the series marks among them: the paint,
/// weight, opacity or blend mode of the topmost visible fill and stroke, and the object opacity and blend mode. Rows
/// and objects the edit left as they were keep what the graph stores. When the edit changes several marks of one series,
/// the frontmost one sets what the graph stores, whatever order `ids` are in. Returns whether the stored paint changed.
pub(crate) fn capture_series_changes(before: &vectorcraft_doc::Document, d: &mut vectorcraft_doc::Document, ids: &[NodeId]) -> bool {
// Only objects directly in a series group can be marks: other edits skip the search of the document for graphs.
let in_series = |id: &NodeId| d.parent_of(*id).and_then(|p| d.node(p)).is_some_and(|g| g.extras().series_index.is_some());
let mut marks: Vec<NodeId> = ids.iter().copied().filter(in_series).collect();
// A later mark's capture replaces an earlier one's: take them in paint order.
marks.sort_by_cached_key(|id| d.index_path(*id));
!marks.is_empty() && capture_series_writes(before, d, &marks, (false, false), (false, false), false, false)
}
/// The kind of the row at `at` (fill `true`, stroke `false`) when it is the topmost visible row of that kind in `ap`.
fn top_row_kind(ap: &Appearance, at: usize) -> Option<bool> {
let fill = ap.items.get(at)?.is_fill();
+23 -6
View File
@@ -36,7 +36,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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 → {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 (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)
),
@@ -104,7 +104,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Blend Front to Back",
["Edit", "Edit Colors"],
None,
"{} ≥3 filled objects (solid fills or gradient meshes, which keep their shading): intermediate fills graded between the frontmost and backmost fill → {changed}",
"{} ≥3 filled objects (solid fills or gradient meshes, which keep their shading): intermediate fills graded between the frontmost and backmost fill. On a path directly in a graph series, the graph also stores the new fill, as for the other Blend commands (docs/mcp.md) → {changed}",
has_selection,
|s, _| blend(s, BlendOrder::Stack)
),
@@ -473,8 +473,11 @@ fn run_pass(s: &mut Session, p: &Value, cmd: &str, label: &str, pass: Recolor) -
let colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok();
let (n, not_changed) = s.edit(label, |d, _| {
let not_changed = NotChanged::of(pass.again().would_change(d, &left).into_iter().map(|(_, hidden)| hidden));
let n = pass.run(d, &ids);
super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(cmd, e))?;
let n = keeping_series_paint(d, &ids, |d| {
let n = pass.run(d, &ids);
super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(cmd, e))?;
Ok(n)
})?;
Ok((n, not_changed))
})?;
let mut out = json!({ "changed": n });
@@ -482,6 +485,19 @@ fn run_pass(s: &mut Session, p: &Value, cmd: &str, label: &str, pass: Recolor) -
Ok(out)
}
/// 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.
pub(crate) fn keeping_series_paint(d: &mut Document, roots: &[NodeId], recolor: impl FnOnce(&mut Document) -> Result<usize>) -> Result<usize> {
let marks = super::graph::series_marks_in(d, roots);
let before = (!marks.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);
}
Ok(n)
}
/// The objects a recolor of `roots` leaves out of the art the user meant to recolor, each with
/// whether it is hidden (otherwise it is locked). When `roots` are exactly the objects Select All
/// takes (in isolation mode, those of the isolated group), these are the objects beside them that
@@ -887,7 +903,7 @@ fn blend(s: &mut Session, order: BlendOrder) -> Result<Value> {
};
let fills = Scope { fill: true, stroke: false, images: false, patterns: false };
let colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok();
let changed = s.edit(label, |d, _| {
let run = |d: &mut Document| -> Result<usize> {
let mut changed = 0;
for (i, id) in ids.iter().enumerate().take(n - 1).skip(1) {
let c = lerp_color(&first, &last, i as f32 / (n - 1) as f32).in_model(model);
@@ -906,7 +922,8 @@ fn blend(s: &mut Session, order: BlendOrder) -> Result<Value> {
}
super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(label, e))?;
Ok(changed)
})?;
};
let changed = s.edit(label, |d, _| keeping_series_paint(d, &ids, run))?;
Ok(json!({ "changed": changed }))
}
+1 -1
View File
@@ -19,7 +19,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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 → {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) → {index}",
has_doc,
add_point
),
+2 -2
View File
@@ -24,7 +24,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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",
"{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)",
has_doc,
edit_gradient
),
@@ -33,7 +33,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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)",
"{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)",
has_doc,
set_gradient_geom
),
+2 -2
View File
@@ -46,8 +46,8 @@ use generate::generate;
use series::default_series_color;
pub(crate) use series::{
capture_label_anti_alias, capture_label_paragraphs, capture_label_styles, capture_series_object_opacity, capture_series_opacity,
capture_series_paints, capture_series_top_paints, paint_targets, series_group_marks, series_group_marks_where, series_object_opacity_differs,
shown_paint,
capture_series_paints, capture_series_top_paints, paint_targets, series_group_marks, series_group_marks_where, series_marks_in,
series_object_opacity_differs, shown_paint,
};
#[cfg(test)]
+19
View File
@@ -103,6 +103,25 @@ fn series_members(doc: &Document) -> HashMap<NodeId, SeriesRef> {
out
}
/// The objects directly in a series group among `roots` and inside them, for an edit that reaches graph series marks
/// without naming them. They may include objects of groups that only look like series groups; the capture functions
/// check each one.
pub(crate) fn series_marks_in(doc: &Document, roots: &[NodeId]) -> Vec<NodeId> {
let mut out = Vec::new();
for id in roots {
if doc.parent_of(*id).and_then(|p| doc.node(p)).is_some_and(|g| g.extras().series_index.is_some()) {
out.push(*id);
}
let Some(root) = doc.node(*id) else { continue };
root.walk(&mut |n| {
if n.extras().series_index.is_some() {
out.extend(n.children().into_iter().flatten().map(|c| c.id));
}
});
}
out
}
/// Group Selection on a graph series targets its constituent marks and legend swatch, not a
/// group-level appearance which the generated art does not inherit. Other ids pass through.
pub(crate) fn paint_targets(doc: &Document, ids: &[NodeId]) -> Vec<NodeId> {
+202
View File
@@ -2209,6 +2209,208 @@ fn swapping_onto_a_hidden_top_fill_leaves_the_stored_fill() {
assert_eq!(spec(&s, id).series_paints[0].fill, red);
}
/// Run `case` on each of `cases`, and fail naming every case that failed, not only the first one.
fn every_case<T: std::fmt::Debug>(cases: impl IntoIterator<Item = T>, case: impl Fn(&T)) {
let failed: Vec<T> = cases.into_iter().filter(|c| std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| case(c))).is_err()).collect();
assert!(failed.is_empty(), "failed cases: {failed:?}");
}
/// The top fill paint of `mark`.
fn fill_of(s: &Session, mark: NodeId) -> Paint {
s.doc().unwrap().doc.node(mark).unwrap().appearance.fill_paint()
}
/// A stroke weight set with `stroke.set`, or stepped with `stroke.weightUp` or `stroke.weightDown`, on a series mark is
/// stored on the graph with the mark's stroke paint, and undo takes it back with its edit.
#[test]
fn a_stroke_weight_set_on_a_series_mark_is_kept() {
let cases = [("stroke.set", json!({"weight": 7}), 7.0), ("stroke.weightUp", json!({}), 3.0), ("stroke.weightDown", json!({}), 1.0)];
every_case(cases, |(cmd, params, weight)| {
let (mut s, id, marks) = stored_red_stroke();
let was = spec(&s, id).series_paints;
let mut p = params.clone();
p["ids"] = json!([marks[1].0]);
s.execute(cmd, &p).unwrap();
let mark = s.doc().unwrap().doc.node(marks[1]).unwrap().clone();
let (fill, stroke) = (mark.appearance.fill_paint(), mark.appearance.stroke_paint());
let kept = spec(&s, id).series_paints[0].clone();
assert_eq!((kept.stroke.clone(), kept.stroke_width), (Some(stroke.clone()), Some(*weight)), "{cmd}");
series_0_is(&mut s, id, (&fill, 0.4, &stroke, *weight), &was);
});
}
/// A gradient made on a series mark's fill with `paint.editGradient` is stored on the graph, every mark of the series has
/// that fill after the graph is drawn again, and undo takes it back with its edit.
#[test]
fn a_gradient_edit_on_a_series_mark_is_kept() {
let (mut s, id, marks) = stored_red_stroke();
let was = spec(&s, id).series_paints;
s.execute("paint.editGradient", &json!({"ids": [marks[0].0], "kind": "linear"})).unwrap();
let fill = fill_of(&s, marks[0]);
assert!(matches!(fill, Paint::Gradient(_)), "{fill:?}");
assert_eq!(spec(&s, id).series_paints[0].fill, Some(fill.clone()));
let red = was[0].stroke.clone().unwrap();
series_0_is(&mut s, id, (&fill, 0.4, &red, 2.0), &was);
}
/// A gradient placed on a series mark's fill with `paint.setGradientGeom` is stored on the graph as it is placed, and
/// every mark of the series has that fill, placement included, after the graph is drawn again.
#[test]
fn a_gradient_placed_on_a_series_mark_is_kept_with_its_placement() {
let (mut s, id, _, marks) = column_marks();
// `paint.setFill` stores the gradient before it is placed.
s.execute("paint.setFill", &json!({"ids": [marks[0].0], "gradient": {"kind": "linear"}})).unwrap();
let fill = fill_of(&s, marks[0]);
assert_eq!(spec(&s, id).series_paints[0].fill, Some(fill.clone()));
s.execute("paint.setGradientGeom", &json!({"ids": [marks[0].0], "start": [0, 0], "end": [10, 40]})).unwrap();
let placed = fill_of(&s, marks[0]);
assert_ne!(placed, fill, "the command places the gradient");
assert_eq!(spec(&s, id).series_paints[0].fill, Some(placed.clone()));
s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap();
assert!(marks_of(&s, id, 0).iter().all(|m| m.0 == placed), "{:?}", marks_of(&s, id, 0));
}
/// A point recolored with `paint.freeform.setPoint` in a freeform gradient on a series mark's fill is stored on the graph.
#[test]
fn a_freeform_gradient_edit_on_a_series_mark_is_kept() {
let (mut s, id, _, marks) = column_marks();
let points = json!([{"at": [20, 20], "color": "#53aadf"}, {"at": [50, 50], "color": "#ff8000"}]);
// `paint.setFill` stores the gradient before its point is recolored.
s.execute("paint.setFill", &json!({"ids": [marks[0].0], "gradient": {"kind": "freeform", "freeform": {"points": points}}})).unwrap();
let fill = fill_of(&s, marks[0]);
assert_eq!(spec(&s, id).series_paints[0].fill, Some(fill.clone()));
s.execute("paint.freeform.setPoint", &json!({"ids": [marks[0].0], "index": 1, "color": "#00ff00"})).unwrap();
let edited = fill_of(&s, marks[0]);
assert_ne!(edited, fill, "the command recolors the point");
assert_eq!(spec(&s, id).series_paints[0].fill, Some(edited.clone()));
s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap();
assert!(marks_of(&s, id, 0).iter().all(|m| m.0 == edited), "{:?}", marks_of(&s, id, 0));
}
/// A series mark's fill recolored with Invert or Complement (the proxy, or one appearance row), an Edit Colors command,
/// a color filter or Recolor Artwork is stored on the graph, and undo takes it back with its edit.
#[test]
fn a_recolored_series_mark_is_kept() {
let cases = [
("paint.invert", json!({}), false),
("paint.complement", json!({}), false),
("paint.invert", json!({}), true),
("paint.complement", json!({}), true),
("edit.colors.invert", json!({}), false),
("edit.colors.toGrayscale", json!({}), false),
("addon.color.filter", json!({"effect": "addon.color.invert"}), false),
("recolor.apply", json!({"map": {"#3366cc": "#00ff00"}}), false),
];
every_case(cases, |(cmd, params, item)| {
let (mut s, id, marks) = stored_red_stroke();
// Not the gray the series starts in: Complement leaves a gray as it is.
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": top_row(&s, marks[0], true), "color": "#3366cc"})).unwrap();
let was = spec(&s, id).series_paints;
let mut p = params.clone();
if *cmd == "recolor.apply" {
// Recolor Artwork works on the selection.
s.execute("select.set", &json!({"ids": [marks[0].0]})).unwrap();
} else {
p["ids"] = json!([marks[0].0]);
}
if *item {
p["item"] = json!(top_row(&s, marks[0], true));
}
let was_fill = fill_of(&s, marks[0]);
s.execute(cmd, &p).unwrap();
let fill = fill_of(&s, marks[0]);
assert_ne!(fill, was_fill, "{cmd} recolors the fill");
assert_eq!(spec(&s, id).series_paints[0].fill, Some(fill.clone()), "{cmd} item {item}");
let red = was[0].stroke.clone().unwrap();
series_0_is(&mut s, id, (&fill, 0.4, &red, 2.0), &was);
});
}
/// The ink limit of Print Ink Fixes on a series mark's fill is stored on the graph.
#[test]
fn an_ink_limit_on_a_series_mark_is_kept() {
let mut s = Session::new();
s.execute("file.new", &json!({"colorMode": "cmyk"})).unwrap();
let id = graph(&mut s, "column");
let mark = series(&s.doc().unwrap().doc.node(id).unwrap().clone(), 0).children().unwrap()[0].id;
// `paint.setFill` stores the heavy color before the limit lowers it.
s.execute("paint.setFill", &json!({"ids": [mark.0], "color": {"c": 100, "m": 100, "y": 100, "k": 100}})).unwrap();
let heavy = fill_of(&s, mark);
assert_eq!(spec(&s, id).series_paints[0].fill, Some(heavy.clone()));
s.execute("addon.color.printFixes", &json!({"ids": [mark.0], "inkLimit": 300})).unwrap();
let fill = fill_of(&s, mark);
assert_ne!(fill, heavy, "the limit lowers the ink");
assert_eq!(spec(&s, id).series_paints[0].fill, Some(fill.clone()));
s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap();
assert!(marks_of(&s, id, 0).iter().all(|m| m.0 == fill), "{:?}", marks_of(&s, id, 0));
}
/// A recolor of a whole graph, or of one series group, stores the new fill of each series it reaches, and the marks have
/// it after the graph is drawn again.
#[test]
fn recoloring_a_graph_or_a_series_group_stores_its_marks_fills() {
let cases = [("edit.colors.invert", true), ("edit.colors.invert", false), ("paint.invert", true), ("paint.invert", false)];
every_case(cases, |(cmd, whole)| {
let (mut s, id, gray, _) = column_marks();
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let (group, other) = (series(&n, 0).id, series(&n, 1).children().unwrap()[0].clone());
let target = if *whole { id } else { group };
s.execute(cmd, &json!({"ids": [target.0]})).unwrap();
let fill = marks_of(&s, id, 0).last().unwrap().0.clone();
assert_ne!(fill, gray, "{cmd} recolors the series");
let kept = spec(&s, id).series_paints;
assert_eq!(kept[0].fill, Some(fill.clone()), "{cmd} whole {whole}");
let other_now = fill_of(&s, other.id);
if *whole {
assert_ne!(other_now, other.appearance.fill_paint());
assert_eq!(kept[1].fill, Some(other_now.clone()));
} else {
assert_eq!(other_now, other.appearance.fill_paint(), "the other series is left as it was");
assert!(kept.get(1).is_none_or(|p| p.fill.is_none()), "{kept:?}");
}
s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap();
assert!(marks_of(&s, id, 0).iter().all(|m| m.0 == fill), "{:?}", marks_of(&s, id, 0));
assert!(marks_of(&s, id, 1).iter().all(|m| m.0 == other_now), "{:?}", marks_of(&s, id, 1));
});
}
/// A recolor that changes several marks of one series to different fills stores the frontmost mark's fill, whatever
/// order the ids are given in.
#[test]
fn recoloring_several_marks_of_a_series_stores_the_frontmost_ones_fill() {
let cases =
[("edit.colors.invert", false), ("paint.invert", true)].into_iter().flat_map(|c| [[0, 1, 2], [2, 1, 0], [1, 2, 0]].map(|o| (c.0, c.1, o)));
every_case(cases, |(cmd, item, order)| {
let (mut s, id, _, marks) = column_marks();
for (mark, hex) in [(marks[0], "#ff0000"), (marks[1], "#00ff00"), (marks[2], "#0000ff")] {
s.execute("appearance.setItem", &json!({"ids": [mark.0], "index": top_row(&s, mark, true), "color": hex})).unwrap();
}
let mut p = json!({"ids": order.map(|i| marks[i].0)});
if *item {
p["item"] = json!(top_row(&s, marks[0], true));
}
s.execute(cmd, &p).unwrap();
assert_eq!(spec(&s, id).series_paints[0].fill, Some(fill_of(&s, marks[2])), "{cmd} {order:?}");
});
}
/// Blend Horizontally across a series' three marks stores the fill it gives the middle one on the graph.
#[test]
fn blending_a_series_marks_fills_is_kept() {
let (mut s, id, marks) = stored_red_stroke();
for (mark, hex) in [(marks[0], "#ff0000"), (marks[2], "#0000ff")] {
s.execute("appearance.setItem", &json!({"ids": [mark.0], "index": top_row(&s, mark, true), "color": hex})).unwrap();
}
let was = spec(&s, id).series_paints;
s.execute("select.set", &json!({"ids": [marks[0].0, marks[1].0, marks[2].0]})).unwrap();
s.execute("edit.colors.blendHorizontally", &json!({})).unwrap();
let mid = s.doc().unwrap().doc.node(marks[1]).unwrap().appearance.fill_paint();
assert_ne!(Some(&mid), was[0].fill.as_ref());
assert_eq!(spec(&s, id).series_paints[0].fill, Some(mid.clone()));
let red = was[0].stroke.clone().unwrap();
series_0_is(&mut s, id, (&mid, 0.4, &red, 2.0), &was);
}
/// A kept opacity read from a file outside 0..1 is held to that range, and one that is not a number is left out.
#[test]
fn a_kept_series_opacity_from_a_file_is_held_to_its_range() {
+2 -2
View File
@@ -88,7 +88,7 @@ pub fn proxy_specs() -> Vec<CommandSpec> {
"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 → {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 (docs/mcp.md) → {changed}",
has_doc,
|s, p| proxy_recolor(s, p, "paint.invert", "Invert", &super::colorcmds::invert)
),
@@ -583,7 +583,7 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool)
}
Ok(())
},
|d, changed| {
|_, d, changed| {
let series = super::graph::capture_series_paints(d, changed, fill);
let labels = super::graph::capture_label_styles(d, changed, &|st| {
if fill {
+3 -3
View File
@@ -15,7 +15,7 @@ use vectorcraft_doc::swatches::{node_colors, node_paints, override_paints};
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
use vectorcraft_geom::{Point, Rect};
use super::colorcmds::{NotChanged, Recolor, Scope, left_out};
use super::colorcmds::{NotChanged, Recolor, Scope, keeping_series_paint, left_out};
use super::{CommandSpec, Result, Session, always, bad, checked_ids_param, cmd, color_value, has_doc, index, str_param, switch_or, text_param};
pub fn specs() -> Vec<CommandSpec> {
@@ -34,7 +34,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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 → {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 (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
),
@@ -1707,7 +1707,7 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
.filter_map(|(id, hidden)| Some((id, shown_colors(d, d.node(id)?), hidden)))
.collect();
map.converted.set(false);
let changed = Recolor::with_links(scope, &f, &lf).run(d, &ids);
let changed = keeping_series_paint(d, &ids, |d| Ok(Recolor::with_links(scope, &f, &lf).run(d, &ids)))?;
let edit = group.map(|g| super::swatch::rewrite_group(d, g, &new, rename, C)).transpose()?;
super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(C, e))?;
// Rewriting the group's swatches can change the colors of art linked to them, left out or
+2 -2
View File
@@ -22,7 +22,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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) → {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) → {objects: how many objects took the stroke options}",
has_doc,
stroke_set
),
@@ -31,7 +31,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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 → {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 (docs/mcp.md) → {objects}",
has_doc,
|s, p| stroke_step(s, p, true)
),
+16 -2
View File
@@ -762,8 +762,7 @@ outline, so these commands do not store a stroke paint or weight for a series th
designs keep their own paint. A row that had no
paint before the command and has none after it does not change the paint the graph stores. `paint.setFill`,
`paint.setStroke`, `paint.none` and the commands that apply one paint kind to the other store the topmost row of the kind
even when it is hidden, and store a stroke set on a band. `stroke.set`, `paint.editGradient` and the recolor commands do
not store a series' paint.
even when it is hidden, and store a stroke set on a band.
`appearance.addFill`, `appearance.addStroke`, `appearance.setItem`, `appearance.removeItem`,
`appearance.duplicateItem`, `appearance.moveItem`, `appearance.clear`, `appearance.reduceToBasic`,
@@ -789,6 +788,21 @@ The other commands given a series group's id edit the group itself, and the grap
`appearance.addEffect`, and the paint, stroke and gradient commands with a fill or stroke `item`, which is a row of the
group's own stack. The Appearance panel and `appearance.setActiveItem` read the selected group's own rows.
The graph also stores a paint or stroke weight that one of these commands changes on the topmost visible fill or stroke
of a path directly in a series: a weight set with `stroke.set`, `stroke.weightUp` or `stroke.weightDown`; a gradient
made, placed or edited with `paint.editGradient`, `paint.setGradientGeom`, `paint.freeform.addPoint`,
`paint.freeform.setPoint`, `paint.freeform.deletePoint`, `paint.freeform.addLine` or `paint.freeform.splitLine`; and a
color changed by `paint.invert`, `paint.complement`, `edit.colors.invert`, `edit.colors.adjustBalance`,
`edit.colors.saturate`, `edit.colors.toCMYK`, `edit.colors.toGrayscale`, `edit.colors.toRGB`,
`edit.colors.blendFrontToBack`, `edit.colors.blendHorizontally`, `edit.colors.blendVertically`, `recolor.apply`,
`addon.color.filter`, `addon.color.applyNearestSwatch` or the ink limit of `addon.color.printFixes`. The recolor
commands store it too when they are given a series group, the graph or a group that holds the graph. A row a command
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.
`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
kind takes them when the graph is drawn again. The graph saves the paragraph attributes as `valuesPara`,
+1 -1
View File
@@ -1086,7 +1086,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, and a mark's object opacity and blend mode, are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character, Paragraph or Appearance controls, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485).
- **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke, the paint, weight, opacity and blend mode of a mark's top visible fill or stroke row after an Appearance command or a graphic style (each given a mark or its series picked with Group Selection), the Eyedropper, Default Fill and Stroke or Swap Fill and Stroke, 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. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character, Paragraph or Appearance controls, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485).
An Eyedropper pick that includes transparency and leaves a mark's top fill or stroke row without paint, or removes that row when the source has no row of that kind, where that row had paint before, also replaces the opacity and blend mode the graph keeps for that kind of row in the series; a row that had no paint before the pick leaves them as they are.
- **Graph column designs:** Object › Graph › Column (`graph.column`) draws the columns, bars or stacked segments and the legend swatch of the series picked with Group Selection with a graph design: Vertically Scaled stretches it to each column's length, Uniformly Scaled scales it both ways and centers it, Repeating stacks a copy for each Each Design Represents value and chops or scales the last one (For Fractions), and Sliding keeps the part above the design's guide line in proportion and stretches the part below. The dialog lists the designs with a preview; the choice is saved with the graph and follows the design's rename and delete.
Type in a column design that holds a `%` followed by two digits shows each column's value in place of the `%` and the digits: the first digit is the least number of places before the decimal point (zeros are added in front; 0 gives as many as the value needs), and the second digit sets the places after it, rounded. Unless it is inside a group that clips or has its own opacity or appearance, or inside a blend, compound shape, envelope, repeat or intertwined objects, the type keeps its size and is drawn once per column in a repeating design. The legend swatch leaves it out.