Graphs: paint commands store a series' paint only from the row a mark shows

paint.setFill, paint.setStroke, paint.none, Apply Fill to Stroke, Apply
Stroke to Fill and a library gradient or pattern applied with library.use
stored the topmost fill or stroke row of a graph series mark on the graph
even when that row was hidden, and stored a stroke set on a Filled Lines
band. When the graph was drawn again, every mark of the series took a
paint that the edited mark never showed, and a band took the stroke color
as its fill.

These commands now follow the rules of the Appearance commands:

- The graph stores the paint only when the row the command wrote is the
  topmost visible row of its kind, or when the command changed what the
  mark shows. With `item`, that is the named row.
- A series that draws Filled Lines bands stores no stroke paint or
  weight; the band keeps its own outline.
- A row without paint before and after the command keeps what the graph
  stores, so None on a marker without a stroke no longer removes the
  line's stored stroke.
- When one command writes several marks of a series, the frontmost mark
  sets what the graph stores, whatever the order of the ids.
- Apply Fill to Stroke and Apply Stroke to Fill on a mark whose row
  already has the paint store it, as paint.setFill does.

A document without graph series marks among the edited objects skips the
comparison of paints.

Docs: docs/mcp.md and the params text of the paint commands.
This commit is contained in:
dotCooCoo
2026-11-13 06:20:05 -08:00
parent cf4d27c38e
commit 2343e0e8b0
7 changed files with 307 additions and 44 deletions
+14 -8
View File
@@ -79,7 +79,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Apply Fill to Stroke",
["Object", "Plug-ins", "Color Effects"],
None,
"{ids?|id?, skipNone?: bool} each target's top stroke takes its own top fill, as it is (a colour in its model, a global or spot colour linked at its tint, a gradient or pattern with its placement, None); a stroke given paint at width 0 gets 1 pt, as Swap Fill and Stroke does. Type: each run's stroke takes its own fill, and while the Type tool has characters selected (no ids), only those. A graph's series marks and labels keep the paint when the graph is drawn again. skipNone: leave alone a target whose fill is None. A target whose stroke already has that paint is left as it is, and when nothing changes no undo step is recorded. The default stroke takes the default fill the same way → {objects}",
"{ids?|id?, skipNone?: bool} each target's top stroke takes its own top fill, as it is (a colour in its model, a global or spot colour linked at its tint, a gradient or pattern with its placement, None); a stroke given paint at width 0 gets 1 pt, as Swap Fill and Stroke does. Type: each run's stroke takes its own fill, and while the Type tool has characters selected (no ids), only those. A graph's labels keep the paint when the graph is drawn again. On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible stroke when the command wrote that row (exceptions: docs/mcp.md). skipNone: leave alone a target whose fill is None. A target whose stroke already has that paint is left as it is, and when nothing changes no undo step is recorded. The default stroke takes the default fill the same way → {objects}",
has_doc,
|s, p| copy_paint(s, p, true)
),
@@ -226,6 +226,8 @@ fn copy_paint(s: &mut Session, p: &Value, to_stroke: bool) -> Result<Value> {
if !ids.is_empty() {
let before = s.doc()?.doc.clone();
super::super::overprint::edit_counted(s, label, |d| {
// Each object with the row the paint went to, for the paint a graph stores for its series.
let mut written = Vec::new();
for id in &ids {
let Some(node) = d.node_mut(*id) else { continue };
if let NodeKind::Text(t) = &mut node.kind {
@@ -239,19 +241,23 @@ fn copy_paint(s: &mut Session, p: &Value, to_stroke: bool) -> Result<Value> {
let (from, to) = if to_stroke { (ap.fill_paint(), ap.stroke_paint()) } else { (ap.stroke_paint(), ap.fill_paint()) };
// A missing item reads as None: copying None onto it would add an item that
// changes nothing.
if (skip_none && from.is_none()) || from == to {
if skip_none && from.is_none() {
continue;
}
if to_stroke {
restore_unpainted_stroke(ap, None, &from);
ap.set_stroke(from);
} else {
ap.set_fill(from);
if from != to {
if to_stroke {
restore_unpainted_stroke(ap, None, &from);
ap.set_stroke(from);
} else {
ap.set_fill(from);
}
}
// A row that already had the paint counts as written with it, as for `paint.setFill`.
written.extend(ap.items.iter().rposition(|it| it.is_fill() != to_stroke).map(|at| (*id, at)));
}
objects = super::super::appearance::count_changed(d, &before, &ids);
// The graph keeps the paint its series marks and labels now have.
let series = super::super::graph::capture_series_paints(d, &ids, !to_stroke);
let series = super::super::appearance::capture_series_paint_writes(&before, d, &ids, &written);
let labels = super::super::graph::capture_label_styles(d, &ids, &copy_run);
Ok(objects + usize::from(series || labels))
})?;
+27
View File
@@ -725,6 +725,33 @@ pub(crate) fn capture_series_changes(before: &vectorcraft_doc::Document, d: &mut
!marks.is_empty() && capture_series_writes(before, d, &marks, (false, false), (false, false), false, false)
}
/// Store on the graph the paint that an edit of `ids` (from `before` to `d`) leaves on the topmost visible fill and stroke
/// of the series marks among them: a paint or stroke weight that changed, and the paint of each row the edit wrote
/// (`written`: an object with the index of the row) when that row is the topmost visible one of its kind and has paint.
/// A row written under that one, and a hidden one, keep what the graph stores. When the edit reaches 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_paint_writes(
before: &vectorcraft_doc::Document,
d: &mut vectorcraft_doc::Document,
ids: &[NodeId],
written: &[(NodeId, usize)],
) -> bool {
// Only objects directly in a series group can be marks: other edits skip the comparison of paints and 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, each with all of its rows.
marks.sort_by_cached_key(|id| d.index_path(*id));
let mut rows = Vec::new();
for id in &marks {
rows.extend(top_paint_changed(before, d, &[*id]));
let shown = written.iter().filter(|(w, _)| w == id).filter_map(|(_, at)| Some((*id, top_row_kind(&d.node(*id)?.appearance, *at)?)));
rows.extend(painted_rows(d, shown.collect::<Vec<_>>()));
}
super::graph::capture_series_top_paints(d, &rows)
}
/// 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();
+1 -1
View File
@@ -46,7 +46,7 @@ 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_marks_in, series_object_opacity_differs, shown_paint,
capture_series_top_paints, paint_targets, series_marks_in, series_object_opacity_differs, shown_paint,
};
#[cfg(test)]
+2 -24
View File
@@ -143,29 +143,6 @@ pub(crate) fn paint_targets(doc: &Document, ids: &[NodeId]) -> Vec<NodeId> {
out
}
/// Copy the paint just applied to generated series art into that series' fill or stroke. Called
/// inside the same document edit, so the visible paint and future regeneration undo together.
/// Gradients, patterns and swatch links are kept as [`Paint`], not reduced to a solid colour.
/// Returns whether a series' captured fill or stroke changed.
pub(crate) fn capture_series_paints(doc: &mut Document, ids: &[NodeId], fill: bool) -> bool {
let members = series_members(doc);
if members.is_empty() {
return false;
}
let captures: Vec<_> = ids
.iter()
.filter_map(|id| {
// Only a series' own marks set its paint, not the art of a marker design.
let r = members.get(id).filter(|r| doc.parent_of(*id) == Some(r.group))?;
let node = doc.node(*id)?;
let paint = node.appearance.paint_at(None, fill)?.clone();
let width = (!fill).then(|| node.appearance.stroke_width());
Some((r.graph, r.index, fill, paint, width))
})
.collect();
store_series_paints(doc, captures)
}
/// The paint of the topmost visible fill (`true`) or stroke (`false`) of `node`, and a stroke's weight (0 without paint).
/// With no such row: no paint.
pub(crate) fn shown_paint(node: &Node, fill: bool) -> (Paint, f64) {
@@ -178,7 +155,8 @@ pub(crate) fn shown_paint(node: &Node, fill: bool) -> (Paint, f64) {
/// Copy the paint of the topmost visible fill (`true`) or stroke (`false`), and a stroke's weight, of generated series
/// art just edited (`edited`: each object with the kind of row, later entries replacing earlier ones) into that series'
/// fill or stroke; art left without such a row keeps no paint. A series that draws Filled Lines bands stores no stroke: its
/// fill or stroke; art left without such a row keeps no paint. Gradients, patterns and swatch links are kept as
/// [`Paint`], not reduced to a solid color. A series that draws Filled Lines bands stores no stroke: its
/// bands keep their own outline. Called inside the same document edit, so both undo together. Returns whether a series'
/// captured fill or stroke changed.
pub(crate) fn capture_series_top_paints(doc: &mut Document, edited: &[(NodeId, bool)]) -> bool {
+231 -1
View File
@@ -1263,7 +1263,7 @@ fn removing_a_filled_line_bands_stroke_leaves_its_color() {
let (mut s, id, band) = band_graph();
let was = series_parts(&s, id, 0)[0].appearance.fill_paint();
s.execute("paint.setStroke", &json!({"ids": [band.0], "none": true})).unwrap();
assert_eq!(spec(&s, id).series_paints[0].stroke, Some(Paint::None));
assert!(spec(&s, id).series_paints.first().is_none_or(|p| p.stroke.is_none()));
assert_eq!(redrawn_band(&mut s, id).0, was);
}
@@ -1733,6 +1733,236 @@ fn swapping_onto_a_hidden_top_fill_leaves_the_stored_fill() {
assert_eq!(spec(&s, id).series_paints[0].fill, red);
}
/// A column graph whose series 0 stores a red fill and a red 2 pt stroke set on its second mark, while the topmost fill
/// (over a visible one) and the green stroke of its first mark are hidden: the graph and the first mark.
fn hidden_top_rows() -> (Session, NodeId, NodeId) {
let (mut s, id, _, marks) = column_marks();
s.execute("appearance.addFill", &json!({"ids": [marks[0].0]})).unwrap();
let (_, upper) = fill_rows(&s, marks[0]);
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": upper, "visible": false})).unwrap();
let stroke = top_row(&s, marks[0], false);
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#00ff00", "weight": 3, "visible": false})).unwrap();
s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": top_row(&s, marks[1], true), "color": "#ff0000"})).unwrap();
s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": top_row(&s, marks[1], false), "color": "#ff0000", "weight": 2})).unwrap();
(s, id, marks[0])
}
/// `paint.setFill`, `paint.setStroke`, `paint.none`, Apply Fill to Stroke and Apply Stroke to Fill write the topmost row
/// of the kind even when it is hidden. The mark shows what it showed before, and the series keeps the paint it stored.
#[test]
fn a_paint_set_on_a_hidden_top_row_leaves_the_stored_paint() {
let cases = [
("paint.setFill", json!({"color": "#0000ff"})),
("paint.setFill", json!({"none": true})),
("paint.setStroke", json!({"color": "#0000ff"})),
("paint.setStroke", json!({"none": true})),
("paint.none", json!({})),
("addon.color.fillToStroke", json!({})),
("addon.color.strokeToFill", json!({})),
];
every_case(cases, |(cmd, params)| {
let (mut s, id, mark) = hidden_top_rows();
let was = spec(&s, id).series_paints;
let red = was[0].fill.clone().unwrap();
assert_eq!((was[0].stroke.clone(), was[0].stroke_width), (Some(red.clone()), Some(2.0)));
let mut p = params.clone();
if *cmd == "paint.none" {
s.execute("select.set", &json!({"ids": [mark.0]})).unwrap();
} else {
p["ids"] = json!([mark.0]);
}
let before = s.doc().unwrap().doc.node(mark).unwrap().clone();
s.execute(cmd, &p).unwrap();
assert_ne!(*s.doc().unwrap().doc.node(mark).unwrap(), before, "the command wrote the hidden row");
assert_eq!(spec(&s, id).series_paints, was);
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
assert!(marks_of(&s, id, 0).iter().all(|m| *m == (red.clone(), red.clone(), 2.0)), "{:?}", marks_of(&s, id, 0));
});
}
/// `paint.setFill` with `item` on the visible fill under a hidden topmost one changes what the mark shows, and the series
/// stores it. On a fill under a visible topmost one, the series keeps the paint of the topmost one.
#[test]
fn a_paint_set_on_a_row_under_the_top_one_is_stored_only_when_it_shows() {
let (mut s, id, mark) = hidden_top_rows();
let (lower, _) = fill_rows(&s, mark);
s.execute("paint.setFill", &json!({"ids": [mark.0], "item": lower, "color": "#0000ff"})).unwrap();
let blue = s.doc().unwrap().doc.node(mark).unwrap().appearance.paint_at(Some(lower), true).cloned();
assert!(blue.as_ref().is_some_and(|p| !p.is_none()));
assert_eq!(spec(&s, id).series_paints[0].fill, blue);
s.execute("edit.undo", &json!({})).unwrap();
assert_ne!(spec(&s, id).series_paints[0].fill, blue);
// The first mark has a green fill over another one, and the series stores the red fill of the second mark.
let (mut s, id, _, marks) = column_marks();
s.execute("appearance.addFill", &json!({"ids": [marks[0].0]})).unwrap();
let (lower, upper) = fill_rows(&s, marks[0]);
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": upper, "color": "#00ff00"})).unwrap();
s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": top_row(&s, marks[1], true), "color": "#ff0000"})).unwrap();
let red = Some(fill_of(&s, marks[1]));
assert_ne!(red, Some(fill_of(&s, marks[0])));
assert_eq!(spec(&s, id).series_paints[0].fill, red);
s.execute("paint.setFill", &json!({"ids": [marks[0].0], "item": lower, "color": "#0000ff"})).unwrap();
assert_ne!(s.doc().unwrap().doc.node(marks[0]).unwrap().appearance.paint_at(Some(lower), true), red.as_ref());
assert_eq!(spec(&s, id).series_paints[0].fill, red);
s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap();
assert!(marks_of(&s, id, 0).iter().all(|m| Some(&m.0) == red.as_ref()), "{:?}", marks_of(&s, id, 0));
}
/// A library gradient applied to a selected mark goes to the topmost fill as `paint.setFill` puts it there: the series
/// stores it from a visible row, and keeps its stored paint when that row is hidden.
#[test]
fn a_library_gradient_on_a_series_mark_is_stored_only_from_a_row_it_shows() {
for hidden in [false, true] {
let (mut s, id, mark) = hidden_top_rows();
if !hidden {
s.execute("appearance.showAllHidden", &json!({"ids": [mark.0]})).unwrap();
}
let lib = s.execute("library.create", &json!({})).unwrap()["id"].as_str().unwrap().to_string();
let rect = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 40, "height": 20})).unwrap()["id"].clone();
let stops = json!([{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]);
s.execute("paint.setFill", &json!({"gradient": {"kind": "linear", "stops": stops}, "ids": [rect]})).unwrap();
s.execute("select.set", &json!({"ids": [rect]})).unwrap();
s.execute("library.add", &json!({"library": lib, "kind": "fillGradient"})).unwrap();
s.execute("select.set", &json!({"ids": [mark.0]})).unwrap();
let was = spec(&s, id).series_paints;
s.execute("library.use", &json!({"library": lib, "kind": "fillGradient", "item": "Gradient"})).unwrap();
let fill = fill_of(&s, mark);
assert!(matches!(fill, Paint::Gradient(_)), "{hidden}");
let now = spec(&s, id).series_paints;
if hidden {
assert_eq!(now, was);
} else {
assert_eq!(now[0].fill, Some(fill));
}
}
}
/// A mark's fill and stroke rows already have the paint a command writes, while the series stores the paint of another
/// mark. `paint.setFill`, `paint.setStroke`, Apply Fill to Stroke and Apply Stroke to Fill leave the mark as it was, and
/// the series stores the mark's paint for the kind of row the command wrote, and only for that kind.
#[test]
fn writing_the_paint_a_mark_already_has_stores_it_for_the_series() {
let cases = [
("paint.setFill", json!({"color": "#ff0000"}), true),
("paint.setStroke", json!({"color": "#ff0000"}), false),
("addon.color.fillToStroke", json!({}), false),
("addon.color.strokeToFill", json!({}), true),
("addon.color.fillToStroke", json!({"skipNone": true}), false),
];
every_case(cases, |(cmd, params, fill)| {
let (mut s, id, _, marks) = column_marks();
for (mark, hex, weight) in [(marks[0], "#ff0000", 3), (marks[1], "#0000ff", 2)] {
s.execute("appearance.setItem", &json!({"ids": [mark.0], "index": top_row(&s, mark, true), "color": hex})).unwrap();
s.execute("appearance.setItem", &json!({"ids": [mark.0], "index": top_row(&s, mark, false), "color": hex, "weight": weight})).unwrap();
}
let was = spec(&s, id).series_paints;
let (red, blue) = (fill_of(&s, marks[0]), fill_of(&s, marks[1]));
assert_eq!((was[0].fill.clone(), was[0].stroke.clone(), was[0].stroke_width), (Some(blue.clone()), Some(blue.clone()), Some(2.0)));
let before = s.doc().unwrap().doc.node(marks[0]).unwrap().clone();
let mut p = params.clone();
p["ids"] = json!([marks[0].0]);
s.execute(cmd, &p).unwrap();
assert_eq!(*s.doc().unwrap().doc.node(marks[0]).unwrap(), before, "the mark is as it was");
let now = spec(&s, id).series_paints;
let expected = if *fill { (Some(red), Some(blue), Some(2.0)) } else { (Some(blue), Some(red), Some(3.0)) };
assert_eq!((now[0].fill.clone(), now[0].stroke.clone(), now[0].stroke_width), expected);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(spec(&s, id).series_paints, was);
});
}
/// Apply Fill to Stroke and Apply Stroke to Fill on a mark store the paint they give its topmost visible row, and every
/// mark of the series has it after the graph is drawn again. With `skipNone`, a mark without the source paint keeps what
/// the series stores.
#[test]
fn applying_one_paint_kind_to_the_other_on_a_series_mark_is_kept() {
let (mut s, id, marks) = stored_red_stroke();
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": top_row(&s, marks[0], true), "color": "#0000ff"})).unwrap();
let blue = fill_of(&s, marks[0]);
s.execute("addon.color.fillToStroke", &json!({"ids": [marks[0].0]})).unwrap();
let kept = spec(&s, id).series_paints;
assert_eq!((kept[0].fill.clone(), kept[0].stroke.clone()), (Some(blue.clone()), Some(blue.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, &m.1) == (&blue, &blue)), "{:?}", marks_of(&s, id, 0));
let (mut s, id, marks) = stored_red_stroke();
let red = spec(&s, id).series_paints[0].stroke.clone();
s.execute("addon.color.strokeToFill", &json!({"ids": [marks[1].0]})).unwrap();
assert_eq!(spec(&s, id).series_paints[0].fill, red);
s.execute("paint.setFill", &json!({"ids": [marks[0].0], "none": true})).unwrap();
s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": top_row(&s, marks[1], true), "color": "#0000ff"})).unwrap();
let was = spec(&s, id).series_paints;
s.execute("addon.color.fillToStroke", &json!({"ids": [marks[0].0], "skipNone": true})).unwrap();
assert_eq!(spec(&s, id).series_paints, was);
}
/// When one paint command writes several marks of a series with different paints, the frontmost mark sets what the
/// graph stores, whatever order the ids are given in and whatever the marks' ids are.
#[test]
fn a_paint_command_on_several_marks_of_a_series_stores_the_frontmost_ones_paint() {
every_case([[0, 1, 2], [2, 1, 0], [1, 2, 0]], |order| {
let (mut s, id, _, marks) = column_marks();
// The mark made last goes to the back, so the marks' ids are not in paint order.
s.execute("select.set", &json!({"ids": [marks[2].0]})).unwrap();
s.execute("object.arrange.sendToBack", &json!({})).unwrap();
let drawn: Vec<NodeId> = series_parts(&s, id, 0).iter().map(|m| m.id).filter(|m| marks[..3].contains(m)).collect();
assert_eq!(drawn, [marks[2], marks[0], marks[1]]);
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();
}
s.execute("addon.color.fillToStroke", &json!({"ids": order.map(|i| marks[i].0)})).unwrap();
assert_eq!(spec(&s, id).series_paints[0].stroke, Some(fill_of(&s, marks[1])), "{order:?}");
});
// The frontmost mark loses its stroke and a mark under it takes one: the series stores the frontmost mark's.
let (mut s, id, marks) = stored_red_stroke();
s.execute("appearance.setItem", &json!({"ids": [marks[2].0], "index": top_row(&s, marks[2], false), "color": "#00ff00", "weight": 2})).unwrap();
s.execute("paint.setFill", &json!({"ids": [marks[2].0], "none": true})).unwrap();
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": top_row(&s, marks[0], true), "color": "#0000ff"})).unwrap();
s.execute("addon.color.fillToStroke", &json!({"ids": [marks[0].0, marks[2].0]})).unwrap();
assert!(!s.doc().unwrap().doc.node(marks[0]).unwrap().appearance.stroke_paint().is_none());
assert_eq!(spec(&s, id).series_paints[0].stroke, Some(Paint::None));
}
/// A stroke set on a Filled Lines band with `paint.setStroke` or Apply Fill to Stroke is not stored: the band keeps its
/// color and its thin black outline when the graph is drawn again. A fill set with `paint.setFill` is stored.
#[test]
fn a_paint_command_stroke_on_a_filled_line_band_does_not_repaint_it() {
every_case([("paint.setStroke", json!({"color": "#ff0000"})), ("addon.color.fillToStroke", json!({}))], |(cmd, params)| {
let (mut s, id, band) = band_graph();
let was = series_parts(&s, id, 0)[0].appearance.fill_paint();
let mut p = params.clone();
p["ids"] = json!([band.0]);
s.execute(cmd, &p).unwrap();
assert!(!s.doc().unwrap().doc.node(band).unwrap().appearance.stroke_paint().is_none());
assert!(spec(&s, id).series_paints.first().is_none_or(|p| p.stroke.is_none()));
assert_eq!(redrawn_band(&mut s, id), (was, Paint::solid(vectorcraft_color::Color::BLACK), 0.25));
});
let (mut s, id, band) = band_graph();
s.execute("paint.setFill", &json!({"ids": [band.0], "color": "#0000ff"})).unwrap();
let blue = s.doc().unwrap().doc.node(band).unwrap().appearance.fill_paint();
assert_eq!(redrawn_band(&mut s, id), (blue, Paint::solid(vectorcraft_color::Color::BLACK), 0.25));
}
/// `paint.setStroke` with None on a marker that shows no stroke changes nothing, and the line keeps the stroke the series
/// stores. On the line itself it stores the empty stroke.
#[test]
fn removing_the_stroke_of_a_mark_without_one_leaves_the_stored_stroke() {
let (mut s, id, line, marker) = line_and_marker();
s.execute("paint.setStroke", &json!({"ids": [line.0], "color": "#ff0000"})).unwrap();
let red = s.doc().unwrap().doc.node(line).unwrap().appearance.stroke_paint();
assert_eq!(spec(&s, id).series_paints[0].stroke, Some(red.clone()));
s.execute("paint.setStroke", &json!({"ids": [marker.0], "none": true})).unwrap();
assert_eq!(spec(&s, id).series_paints[0].stroke, Some(red.clone()));
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
assert_eq!(line_parts(&s, id).0.appearance.stroke_paint(), red);
let line = line_parts(&s, id).0.id;
s.execute("paint.setStroke", &json!({"ids": [line.0], "none": true})).unwrap();
assert_eq!(spec(&s, id).series_paints[0].stroke, Some(Paint::None));
}
/// 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();
+24 -8
View File
@@ -21,7 +21,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Fill",
[],
None,
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical: the stops sweep once around the center `start`, clockwise from the vector; diamond: the stops run from the center `start` out to a square with a corner at `end`), method?: classic|perceptual (as paint.editGradient; default classic), spread?: pad|repeat|reflect (as paint.editGradient; default pad), repeats?: 1..100 (as paint.editGradient; default 1), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep, counterclockwise, and is an error for the other kinds; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits; an error with a conic or diamond), dither?: bool (linear, radial, conic and diamond fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear, radial, conic or diamond `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white; a colorized image whose link is missing takes a solid color as its new colorize color, and refuses a process color while its color is a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern) → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}",
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical: the stops sweep once around the center `start`, clockwise from the vector; diamond: the stops run from the center `start` out to a square with a corner at `end`), method?: classic|perceptual (as paint.editGradient; default classic), spread?: pad|repeat|reflect (as paint.editGradient; default pad), repeats?: 1..100 (as paint.editGradient; default 1), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep, counterclockwise, and is an error for the other kinds; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits; an error with a conic or diamond), dither?: bool (linear, radial, conic and diamond fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear, radial, conic or diamond `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white; a colorized image whose link is missing takes a solid color as its new colorize color, and refuses a process color while its color is a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern). On a path directly in a graph series, the graph also stores the paint of the topmost visible fill when the command wrote that row or changed it (exceptions: docs/mcp.md) → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}",
has_doc,
|s, p| set_paint(s, p, true)
),
@@ -30,7 +30,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Stroke",
[],
None,
"{…as paint.setFill, item?: the stroke item to set} same as paint.setFill, for the stroke; with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's stroke changes for this instance only (solid, none, gradient or pattern)",
"{…as paint.setFill, item?: the stroke item to set} same as paint.setFill, for the stroke; with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's stroke changes for this instance only (solid, none, gradient or pattern). On a path directly in a graph series, the graph also stores the paint and weight of the topmost visible stroke when the command wrote that row or changed it (exceptions: docs/mcp.md)",
has_doc,
|s, p| { set_paint(s, p, false) }
),
@@ -64,10 +64,18 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(json!({ "fillActive": s.fill_active }))
}
),
cmd!("paint.none", "None", [], Some("/"), "{} set the active proxy (fill or stroke) to None → {objects}", has_doc, |s, _| {
let f = s.fill_active;
set_paint(s, &json!({"none": true}), f)
}),
cmd!(
"paint.none",
"None",
[],
Some("/"),
"{} set the active proxy (fill or stroke) to None, as paint.setFill or paint.setStroke with none does → {objects}",
has_doc,
|s, _| {
let f = s.fill_active;
set_paint(s, &json!({"none": true}), f)
}
),
cmd!(
"transparency.set",
"Transparency",
@@ -523,6 +531,9 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool)
vec![]
};
let label = if fill { "Fill Color" } else { "Stroke Color" };
// Each object whose appearance took the paint, with the row it went to, for the paint a graph stores for its series
// (a series mark is a path, so it always has a row here).
let written = std::cell::RefCell::new(Vec::new());
edit_items_then(
s,
&ids,
@@ -581,10 +592,15 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool)
if !fill {
super::object::snap_if_aligned(n);
}
let at = match index {
Some(_) => n.appearance.item_of_kind(index, fill),
None => n.appearance.items.iter().rposition(|it| it.is_fill() == fill),
};
written.borrow_mut().extend(at.map(|at| (n.id, at)));
Ok(())
},
|_, d, changed| {
let series = super::graph::capture_series_paints(d, changed, fill);
|before, d, changed| {
let series = super::appearance::capture_series_paint_writes(before, d, changed, &written.borrow());
let labels = super::graph::capture_label_styles(d, changed, &|st| {
if fill {
st.fill = paint.clone();
+8 -2
View File
@@ -756,8 +756,14 @@ outline, so these commands do not store a stroke paint or weight for a series th
`paint.swap`, they still store a stroke opacity and blend mode set on a band, and the band's outline takes them. Graph
designs keep their own paint. A row that had no
paint before the command and has none after it does not change 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.
`paint.setStroke`, `paint.none`, `addon.color.fillToStroke` and `addon.color.strokeToFill` follow the same rules, and so
does `library.use` when it applies a gradient or pattern. They write the topmost row of the kind; `paint.setFill` and
`paint.setStroke` write the row `item` names instead, and without ids the three `paint.*` commands write the active
row of the Appearance panel when it is of that kind. The graph stores the paint only when the written row is the
topmost visible one of its kind, or when the command changes what the mark shows. A paint written to a hidden row or to
a row under the topmost visible one is not stored, and the mark loses it when the graph is drawn again. A mark whose row
already has the paint counts as written, so its paint replaces the one another mark of the series left on the graph.
When one of these commands writes several paths of a series, the frontmost of them sets what the graph stores.
An `appearance.*` command or `graphicStyle.apply` given a series group's id, and
`graphicStyle.redefine` on a series group linked to the style, edit the group's own rows, and the graph does not store
them.