From f3a4c0ac1ab835af2c50198ec4b5e745efe26514 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 23:35:57 -0800 Subject: [PATCH] Paste: Paste without Formatting also drops a pie's own legend fill mark Paste without Formatting dropped the label formats a pasted graph keeps but left the mark that its legend labels' fill is their own. After a later change to a legend label (its size, for example), the next redraw read the default black fill as the labels' own and drew them black on dark wedges again. The mark now stays behind with the other formats. A pie's legend label whose series name holds line breaks of its own (from a quoted CSV cell) is now located by all of its lines, as a redraw centers it. Before, only `|` counted as a line break, so a tall label on stacked pies could take the contrast of the wrong ring. Paste no longer recolors the legend labels of a pie whose wedges a merged or changed swatch recolors. That is a separate change to Paste and goes in its own pull request. Paste without Formatting sets the legend labels by the wedge colors as copied. --- crates/engine/src/cmd/edit.rs | 5 +- crates/engine/src/cmd/graph.rs | 2 +- crates/engine/src/cmd/graph/series.rs | 130 ++--------- crates/engine/src/cmd/typemenu.rs | 2 +- crates/engine/src/tests/clipboard.rs | 298 ++++++++------------------ docs/mcp.md | 7 +- 6 files changed, 111 insertions(+), 333 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 022bcb6d1f..00b6a338f2 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -179,7 +179,7 @@ pub fn specs() -> Vec { "Paste", ["Edit"], Some("Cmd+V"), - "{center?: [x, y], artboardsAt?: [x, y], dx?, dy?, swatchConflict?} paste centred on `center` (the app passes the view centre), else offset by dx/dy (default: the Paste Offset preference). Pasting brings the image blobs, symbols, patterns, global and spot swatches (with the tint swatches of the tints used), gradient swatches, graphic styles, character and paragraph styles and brushes the objects use; one of the same name that differs comes in renamed. swatchConflict, for a swatch whose name the document gives another colour (clipboard.conflicts): \"merge\" (default: the objects take the document's swatch) | \"add\" (the pasted swatch comes in renamed) | {name: \"merge\"|\"add\"}; when the document's swatches change the color of a pasted pie's wedges (after a merge, or pasted back after a swatch it uses changed), the legend labels in those wedges are white on a dark wedge and black on a light one unless the pie keeps a legend label fill. With Paste Remembers Layers on (layer.pasteRemembersLayers), objects go back into the layers and sublayers they came from (by the names of the layer and the layers around it in another document; a missing sublayer is made inside its layer; a hidden or locked one is an error and nothing is pasted). Artboards copied with artboard.copy come with their art, together right of the last artboard as they lay, or centered on `artboardsAt` (the Artboard tool's canvas menu passes the right-click point; a value that is not [x, y] is an error) (the other paste commands but Paste on All Artboards: where they were), their art back in the layers it came from, and the Artboard tool, when chosen, selects them. In Draw Inside (view.drawMode) the objects go inside the object drawn into, as new art does, and Paste Remembers Layers doesn't apply; edit.pasteInPlace and edit.pasteOnAllArtboards do the same, and in Draw Behind they go behind the selection as edit.pasteInBack puts them; edit.pasteInFront and edit.pasteInBack ignore the drawing mode → {ids, artboard: the first new artboard's index or null, artboards: the new artboards' indices, added: resources added, merged: conflicts merged, renamed: [{kind, from, to}]}", + "{center?: [x, y], artboardsAt?: [x, y], dx?, dy?, swatchConflict?} paste centred on `center` (the app passes the view centre), else offset by dx/dy (default: the Paste Offset preference). Pasting brings the image blobs, symbols, patterns, global and spot swatches (with the tint swatches of the tints used), gradient swatches, graphic styles, character and paragraph styles and brushes the objects use; one of the same name that differs comes in renamed. swatchConflict, for a swatch whose name the document gives another colour (clipboard.conflicts): \"merge\" (default: the objects take the document's swatch) | \"add\" (the pasted swatch comes in renamed) | {name: \"merge\"|\"add\"}. With Paste Remembers Layers on (layer.pasteRemembersLayers), objects go back into the layers and sublayers they came from (by the names of the layer and the layers around it in another document; a missing sublayer is made inside its layer; a hidden or locked one is an error and nothing is pasted). Artboards copied with artboard.copy come with their art, together right of the last artboard as they lay, or centered on `artboardsAt` (the Artboard tool's canvas menu passes the right-click point; a value that is not [x, y] is an error) (the other paste commands but Paste on All Artboards: where they were), their art back in the layers it came from, and the Artboard tool, when chosen, selects them. In Draw Inside (view.drawMode) the objects go inside the object drawn into, as new art does, and Paste Remembers Layers doesn't apply; edit.pasteInPlace and edit.pasteOnAllArtboards do the same, and in Draw Behind they go behind the selection as edit.pasteInBack puts them; edit.pasteInFront and edit.pasteInBack ignore the drawing mode → {ids, artboard: the first new artboard's index or null, artboards: the new artboards' indices, added: resources added, merged: conflicts merged, renamed: [{kind, from, to}]}", has_clipboard, |s, p| paste(s, p, PasteMode::Offset) ), @@ -1455,9 +1455,6 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho for (k, n) in clip.nodes.iter().enumerate() { let mut c = d.reid(n); imported.apply(&mut c); - // The document's swatches (merged, or changed since the copy in the same document) can give a pie's - // wedges another color: their legend labels follow it. - super::graph::wedge_label_contrast(n, &mut c); let sc = vectorcraft_doc::Scaling { patterns: mode == PasteMode::Drag, ..Default::default() }; if xf != Affine::IDENTITY { // A cross-document drag carries the graphic's appearance with it, diff --git a/crates/engine/src/cmd/graph.rs b/crates/engine/src/cmd/graph.rs index 1bf9f9fe24..071ca484ab 100644 --- a/crates/engine/src/cmd/graph.rs +++ b/crates/engine/src/cmd/graph.rs @@ -49,7 +49,7 @@ pub(crate) use series::{ LabelTextChange, capture_label_anti_alias, capture_label_changes, capture_label_object_opacity, capture_label_paragraphs, capture_label_rows, capture_label_styles, capture_label_text, capture_series_object_opacity, capture_series_opacity, capture_series_top_paints, is_design_art, label_object_opacity_differs, labels_in, paint_targets, plain_labels, series_group_marks, series_group_marks_where, series_groups_among, - series_marks_in, series_object_opacity_differs, shown_paint, wedge_label_contrast, + series_marks_in, series_object_opacity_differs, shown_paint, }; #[cfg(test)] diff --git a/crates/engine/src/cmd/graph/series.rs b/crates/engine/src/cmd/graph/series.rs index 0dc95dd04e..ee33f23517 100644 --- a/crates/engine/src/cmd/graph/series.rs +++ b/crates/engine/src/cmd/graph/series.rs @@ -681,15 +681,17 @@ fn own_labels(n: &Node, g: &GraphSpec) -> Vec<(NodeId, Role)> { out } -/// Set the labels of graph node `n` (a copy outside any document) as a new graph's labels are set: the character and -/// paragraph formats the graph keeps for them are dropped, and each label takes its kind's default character style, -/// unscaled (keeping its rotation), and default paragraph attributes, keeping its alignment and where it starts. A pie's legend label on a dark -/// wedge is white, as a redraw sets it. Every other object in the graph goes to `other`. The rest of the graph's art, -/// and what its labels say, stay as they are. +/// Set the labels of graph node `n` (a copy outside any document) in a new graph's label formats: the character and +/// paragraph formats the graph keeps for them are dropped, with the mark that its legend labels' fill is their own, and +/// each label takes its kind's default character style, unscaled (keeping its rotation), and default paragraph +/// attributes, keeping its alignment and where it starts. A pie's legend label on a dark wedge is white, as a redraw +/// sets it. Every other object in the graph goes to `other`. The rest of the graph's art, and what its labels say, stay +/// as they are. pub(crate) fn plain_labels(n: &mut Node, other: &dyn Fn(&mut Node)) { fn plain(l: &mut vectorcraft_doc::LabelStyles) { (l.values, l.categories, l.legend) = (None, None, None); (l.values_para, l.categories_para, l.legend_para) = (None, None, None); + l.legend_fill_set = false; } // Which legend labels a redraw sets on a dark wedge, read while the labels are as the graph drew them. let on_dark = match n.extras().graph.as_deref() { @@ -940,10 +942,10 @@ fn redrawn_on_dark(n: &Node, g: &GraphSpec) -> Option> { for w in &wedges { by_series.entry(w.series).or_default().push(w); } - // A redraw puts the middle of a label's lines (its series name split at `|`, at the default leading) 0.35 of the - // label size below a point inside its wedge, at the label's origin across. The label is on the nearest wedge of its - // series to that point, the one holding it. When the wedges of the series are all dark or all light, the place does - // not matter. + // A redraw puts the middle of a label's lines (its series name split at `|` and line breaks, at the default leading) + // 0.35 of the label size below a point inside its wedge, at the label's origin across. The label is on the nearest + // wedge of its series to that point, the one holding it. When the wedges of the series are all dark or all light, the + // place does not matter. let uniform: HashMap> = by_series.iter().map(|(s, ws)| (*s, ws.first().map(|w| w.dark).filter(|d| ws.iter().all(|w| w.dark == *d)))).collect(); let ls = label_size(&g.label_styles, Role::Legend); @@ -956,7 +958,7 @@ fn redrawn_on_dark(n: &Node, g: &GraphSpec) -> Option> { continue; } let NodeKind::Text(text) = &r.kind else { return None }; - let middle = name.split('|').count().saturating_sub(1) as f64 * ls * 1.2 / 2.0; + let middle = name.split(['|', '\n']).count().saturating_sub(1) as f64 * ls * 1.2 / 2.0; let point = text.xf * vectorcraft_geom::Point::new(0.0, middle - ls * 0.35); let named = by_series.get(&s)?; if budget < named.len() { @@ -999,11 +1001,11 @@ fn legend_on_dark(n: &Node, g: &GraphSpec) -> HashMap { /// The point a redraw centers legend label `text` of series `name` on, in a pie of spec `g` whose legend labels are /// `ls` points: 0.35 of the label size above where a redraw (`label` in `labels.rs`) puts the middle of the label's -/// lines, at the label's origin across. The lines are those of the series name, split at `|`, at the default leading; -/// with kept paragraph attributes, a redraw puts the middle of the first and last baselines there instead, when each of -/// those lines that holds characters has one baseline and the first and last hold characters. +/// lines, at the label's origin across. The lines are those of the series name, split at `|` and line breaks, at the +/// default leading; with kept paragraph attributes, a redraw puts the middle of the first and last baselines there +/// instead, when each of those lines that holds characters has one baseline and the first and last hold characters. fn label_anchor(text: &vectorcraft_doc::TextObject, name: &str, g: &GraphSpec, ls: f64) -> vectorcraft_geom::Point { - let lines: Vec<&str> = name.split('|').collect(); + let lines: Vec<&str> = name.split(['|', '\n']).collect(); let ys: Vec = text.baselines().map(|(a, _)| a.y).collect(); let set = lines.iter().filter(|l| !l.is_empty()).count(); let ends_set = lines.first().is_some_and(|l| !l.is_empty()) && lines.last().is_some_and(|l| !l.is_empty()); @@ -1096,106 +1098,6 @@ fn wedge_contrast(legend: &[&Node], wedges: &[Wedge], g: &GraphSpec) -> HashMap< out } -/// Does `n` hold, or is it, a pie that colors its legend labels by their wedge ([`picks_legend_fill`])? -fn holds_wedge_labels(n: &Node) -> bool { - let mut found = false; - n.walk(&mut |m| found |= m.extras().graph.as_deref().is_some_and(picks_legend_fill)); - found -} - -/// Recolor the legend labels of every pie in or at `after` that colors them by their wedge ([`picks_legend_fill`]) when -/// its wedges changed color from `before` (the same art before it took the document's swatches): a label white on a -/// dark wedge, or black on a light one, takes the contrast its wedge shows now, as a redraw sets it. The wedge of a label -/// is any wedge of the series it names that showed the label's contrast before, or the nearest of them when those -/// wedges changed apart; a label naming no series, and a label whose characters are not all white or all black, keep -/// their colors. Paste calls it -/// after the pasted art takes the document's swatches. -pub(crate) fn wedge_label_contrast(before: &Node, after: &mut Node) { - if !holds_wedge_labels(after) { - return; - } - recolor_legend(before, after); - let Some(was) = before.children() else { return }; - for (b, a) in was.iter().zip(after.children_mut().into_iter().flatten()) { - if holds_wedge_labels(a) { - wedge_label_contrast(b, Arc::make_mut(a)); - } - } -} - -/// [`wedge_label_contrast`] for graph `after` alone, not the graphs inside it. -fn recolor_legend(before: &Node, after: &mut Node) { - let Some(g) = after.extras().graph.as_deref().filter(|g| picks_legend_fill(g)) else { return }; - let (was, now) = (pie_wedges(before), pie_wedges(after)); - if was.len() != now.len() || was.iter().zip(&now).all(|(b, a)| b.dark == a.dark) { - return; - } - // For each series, the contrast its wedges show now, by the contrast they showed before (`None`: they differ). - let mut shift: HashMap<(u32, bool), Option> = HashMap::new(); - for (b, a) in was.iter().zip(&now) { - shift.entry((a.series, b.dark)).and_modify(|d| *d = d.filter(|d| *d == a.dark)).or_insert(Some(a.dark)); - } - let names = series_by_label(g); - let (white, black) = (Paint::solid(Color::WHITE), Paint::solid(Color::BLACK)); - let mut on_dark = HashMap::new(); - use vectorcraft_geom::kurbo::ParamCurveNearest; - // How far point `at` lies from a wedge: 0 inside it. - let away = |w: &Wedge, at: vectorcraft_geom::Point| { - if w.path.contains(at) { 0.0 } else { w.path.segments().map(|s| s.nearest(at, 1e-3).distance_sq).fold(f64::INFINITY, f64::min) } - }; - // Wedge visits left for labels whose series' wedges changed apart, so a huge pie stays bounded. - let mut budget: usize = 1 << 20; - // For each label text, the first series name that shows it, as `wedge_contrast` reads it. - let mut series_names: HashMap = HashMap::new(); - for name in &g.series { - series_names.entry(name.replace('|', "\n")).or_insert(name.as_str()); - } - let ls = label_size(&g.label_styles, Role::Legend); - // The positions of each series' wedges in `was` and `now`. - let mut by_series: HashMap> = HashMap::new(); - for (i, a) in now.iter().enumerate() { - by_series.entry(a.series).or_default().push(i); - } - for t in legend_labels(after) { - let NodeKind::Text(text) = &t.kind else { continue }; - let was_dark = if text.runs.iter().all(|r| r.style.fill == white) { - true - } else if text.runs.iter().all(|r| r.style.fill == black) { - false - } else { - continue; - }; - let series: &[u32] = names.get(&text.plain_text()).map_or(&[], |s| s.as_slice()); - let mut shown = series.iter().filter_map(|s| shift.get(&(*s, was_dark))); - let Some(first) = shown.next().copied() else { continue }; - if let Some(dark) = first.filter(|d| shown.all(|e| *e == Some(*d))) { - on_dark.insert(t.id, dark); - continue; - } - // The wedges of the series it names changed apart: the label follows the one nearest to the point a redraw - // centers it on among those that showed its contrast before. - let words = text.plain_text(); - let name = series_names.get(&words).map_or_else(|| words.replace('\n', "|"), |s| (*s).to_owned()); - let at = label_anchor(text, &name, g, ls); - let candidates: Vec<(&Wedge, &Wedge)> = series - .iter() - .filter_map(|s| by_series.get(s)) - .flatten() - .filter_map(|i| was.get(*i).zip(now.get(*i))) - .filter(|(b, _)| b.dark == was_dark) - .collect(); - if budget < candidates.len() { - continue; - } - budget -= candidates.len(); - let nearest = candidates.iter().map(|(b, a)| (away(b, at), a.dark)).min_by(|x, y| x.0.total_cmp(&y.0)); - if let Some((_, dark)) = nearest { - on_dark.insert(t.id, dark); - } - } - set_legend_fills(after, &on_dark); -} - /// Keep the anti-aliasing method `method` given to graph labels `ids` for every label of their kind, as /// [`capture_label_styles`] keeps a formatting change. Returns whether a kept method changed. pub(crate) fn capture_label_anti_alias(doc: &mut Document, ids: &[NodeId], method: vectorcraft_doc::text::TypeAntiAlias) -> bool { diff --git a/crates/engine/src/cmd/typemenu.rs b/crates/engine/src/cmd/typemenu.rs index 58c4fe332a..0701f6eeca 100644 --- a/crates/engine/src/cmd/typemenu.rs +++ b/crates/engine/src/cmd/typemenu.rs @@ -1768,7 +1768,7 @@ fn strip_formatting(n: &mut vectorcraft_doc::Node) { refresh_bounds(t); } // A graph keeps its labels' character and paragraph formats for each redraw: they go with the - // labels' own formatting, and the labels are set as a new graph's are. The graph's generated + // labels' own formatting, and the labels are set in a new graph's label formats. The graph's generated // bounds follow its changed labels, so the next redraw does not read them as a move. if n.extras().graph.is_some() { super::graph::edit_graph_art(n, |n| super::graph::plain_labels(n, &strip_formatting)); diff --git a/crates/engine/src/tests/clipboard.rs b/crates/engine/src/tests/clipboard.rs index c896bc5a3c..368bc6de10 100644 --- a/crates/engine/src/tests/clipboard.rs +++ b/crates/engine/src/tests/clipboard.rs @@ -733,6 +733,41 @@ fn pie_labels_in_wedges_pasted_without_formatting_stay_readable() { assert_eq!(fills(&s, p), drawn); } +/// A pie whose legend labels were given a black fill of their own loses that mark when pasted without formatting, so a +/// later change to a legend label's size does not bring the black fill back on the dark wedge. +#[test] +fn a_legend_fill_of_its_own_stays_behind_when_pasted_without_formatting() { + let mut s = session(); + let csv = ",North,South\nP,1,1"; + let g = id_of(&s.execute("graph.create", &json!({"type": "pie", "x": 50, "y": 50, "width": 200, "height": 200, "csv": csv})).unwrap()); + s.execute("graph.setType", &json!({"id": g.0, "pieLegend": "wedges"})).unwrap(); + /// The legend label of graph `id` that says `name`. + fn label(s: &Session, id: NodeId, name: &str) -> (NodeId, Paint) { + let mut out = None; + node(s, id).walk(&mut |n| { + if let NodeKind::Text(t) = &n.kind + && t.plain_text() == name + { + out = Some((n.id, t.first_style().fill.clone())); + } + }); + out.unwrap() + } + let (north, fill) = label(&s, g, "North"); + assert_eq!(fill, Paint::solid(Color::WHITE), "white on the black wedge"); + s.execute("text.setStyle", &json!({"ids": [north.0], "fill": "#000000"})).unwrap(); + assert!(node(&s, g).extras().graph.as_deref().unwrap().label_styles.legend_fill_set); + copy(&mut s, &[g]); + new_doc(&mut s); + let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0]; + assert!(!node(&s, p).extras().graph.as_deref().unwrap().label_styles.legend_fill_set); + assert_eq!(label(&s, p, "North").1, Paint::solid(Color::WHITE), "pasted as a redraw sets it"); + let (north, _) = label(&s, p, "North"); + s.execute("text.setStyle", &json!({"ids": [north.0], "size": 11})).unwrap(); + s.execute("graph.setData", &json!({"id": p.0, "csv": csv})).unwrap(); + assert_eq!(label(&s, p, "North").1, Paint::solid(Color::WHITE), "still white when the pie is drawn again"); +} + /// A two-line legend label in a narrow dark wedge, whose text sits off the wedge's middle line, stays white when its graph /// is pasted without formatting. #[test] @@ -793,193 +828,6 @@ fn a_legend_label_in_a_narrow_wedge_pasted_without_formatting_stays_readable() { assert_eq!(xs(&s, p), drawn); } -/// A pie wedge whose dark global swatch merges into the document's light swatch of that name gets a black legend label, -/// as a redraw gives it, with Paste and with Paste without Formatting. -#[test] -fn wedge_labels_follow_a_merged_swatch_when_pasted() { - let mut s = session(); - s.execute("swatch.new", &json!({"name": "Ink", "color": "#000000", "global": true})).unwrap(); - let csv = ",North,South\nP,1,1"; - let g = id_of(&s.execute("graph.create", &json!({"type": "pie", "x": 50, "y": 50, "width": 200, "height": 200, "csv": csv})).unwrap()); - s.execute("graph.setType", &json!({"id": g.0, "pieLegend": "wedges"})).unwrap(); - let north = node(&s, g).children().unwrap().iter().find(|c| c.extras().series_index == Some(0)).unwrap().id; - s.execute("paint.setFill", &json!({"ids": [north.0], "swatch": "Ink"})).unwrap(); - fn north_fill(s: &Session, id: NodeId) -> Paint { - let mut out = None; - node(s, id).walk(&mut |n| { - if let NodeKind::Text(t) = &n.kind - && t.plain_text() == "North" - { - out = Some(t.first_style().fill.clone()); - } - }); - out.unwrap() - } - assert_eq!(north_fill(&s, g), Paint::solid(Color::WHITE)); - copy(&mut s, &[g]); - new_doc(&mut s); - s.execute("swatch.new", &json!({"name": "Ink", "color": "#ffffff", "global": true})).unwrap(); - for cmd in ["edit.pasteWithoutFormatting", "edit.paste"] { - let p = ids_of(&s.execute(cmd, &json!({"swatchConflict": "merge"})).unwrap())[0]; - assert_eq!(north_fill(&s, p), Paint::solid(Color::BLACK), "{cmd}"); - s.execute("graph.setData", &json!({"id": p.0, "csv": csv})).unwrap(); - assert_eq!(north_fill(&s, p), Paint::solid(Color::BLACK), "{cmd} redrawn"); - } - // Add keeps the copied color under a new name, and the label stays white. - let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({"swatchConflict": "add"})).unwrap())[0]; - assert_eq!(north_fill(&s, p), Paint::solid(Color::WHITE)); - // Pasted back into its own document after the swatch was changed there, the pie takes the swatch as it is now, and - // so does the label. - new_doc(&mut s); - s.execute("swatch.new", &json!({"name": "Ink", "color": "#000000", "global": true})).unwrap(); - let g = id_of(&s.execute("graph.create", &json!({"type": "pie", "x": 50, "y": 50, "width": 200, "height": 200, "csv": csv})).unwrap()); - s.execute("graph.setType", &json!({"id": g.0, "pieLegend": "wedges"})).unwrap(); - let north = node(&s, g).children().unwrap().iter().find(|c| c.extras().series_index == Some(0)).unwrap().id; - s.execute("paint.setFill", &json!({"ids": [north.0], "swatch": "Ink"})).unwrap(); - assert_eq!(north_fill(&s, g), Paint::solid(Color::WHITE)); - copy(&mut s, &[g]); - s.execute("swatch.edit", &json!({"name": "Ink", "color": "#ffffff"})).unwrap(); - for cmd in ["edit.pasteWithoutFormatting", "edit.paste"] { - let p = ids_of(&s.execute(cmd, &json!({})).unwrap())[0]; - assert_eq!(north_fill(&s, p), Paint::solid(Color::BLACK), "{cmd} into its own document"); - } -} - -/// A wedge label with some characters given another color keeps them when a merged swatch is pasted with its pie. -#[test] -fn a_partly_colored_wedge_label_keeps_its_colors_when_a_swatch_merges() { - let mut s = session(); - let r = swatched_rect(&mut s, "Ink", "#000000"); - let csv = ",North,South\nP,1,1"; - let g = id_of(&s.execute("graph.create", &json!({"type": "pie", "x": 50, "y": 50, "width": 200, "height": 200, "csv": csv})).unwrap()); - s.execute("graph.setType", &json!({"id": g.0, "pieLegend": "wedges"})).unwrap(); - let mut north = None; - node(&s, g).walk(&mut |n| { - if let NodeKind::Text(t) = &n.kind - && t.plain_text() == "North" - { - north = Some(n.id); - } - }); - s.execute("text.setStyle", &json!({"id": north.unwrap().0, "fill": "#ff0000", "start": 0, "end": 2})).unwrap(); - fn fills(s: &Session, id: NodeId) -> Vec { - let mut out = vec![]; - node(s, id).walk(&mut |n| { - if let NodeKind::Text(t) = &n.kind - && t.plain_text() == "North" - { - out = t.runs.iter().map(|r| r.style.fill.clone()).collect(); - } - }); - out - } - let drawn = fills(&s, g); - assert!(drawn.contains(&Paint::solid(hex("#ff0000"))) && drawn.len() > 1, "{drawn:?}"); - copy(&mut s, &[r, g]); - new_doc(&mut s); - s.execute("swatch.new", &json!({"name": "Ink", "color": "#ffffff", "global": true})).unwrap(); - let ids = ids_of(&s.execute("edit.paste", &json!({"swatchConflict": "merge"})).unwrap()); - let p = ids.into_iter().find(|id| node(&s, *id).extras().graph.is_some()).unwrap(); - assert_eq!(fills(&s, p), drawn); -} - -/// When a merged swatch changes only one of the wedges of a series, only that wedge's legend label changes color, also -/// for a long label set left so that most of it lies over the other pie. -#[test] -fn a_merged_swatch_recolors_only_the_label_of_the_wedge_it_changes() { - merged_swatch_recolors_one_label("North", false); - merged_swatch_recolors_one_label("North and its neighbors, with every station reporting a total for the year, counted again by hand", true); -} - -/// The paths of the first series' wedges of graph `g`, in paint order. -fn first_series_wedges(s: &Session, g: NodeId) -> Vec { - let mut wedges = vec![]; - for c in node(s, g).children().unwrap().iter() { - if c.extras().series_index == Some(0) { - c.walk(&mut |m| { - if matches!(m.kind, NodeKind::Path { .. }) { - wedges.push(m.id); - } - }); - } - } - wedges -} - -/// [`a_merged_swatch_recolors_only_the_label_of_the_wedge_it_changes`] for a first series called `name`, its legend -/// labels set left when `left`. -fn merged_swatch_recolors_one_label(name: &str, left: bool) { - let mut s = session(); - s.execute("swatch.new", &json!({"name": "Ink", "color": "#000000", "global": true})).unwrap(); - s.execute("swatch.new", &json!({"name": "Coal", "color": "#000000", "global": true})).unwrap(); - let csv = format!(",\"{name}\",South\nP,1,1\nQ,1,1"); - let g = id_of(&s.execute("graph.create", &json!({"type": "pie", "x": 50, "y": 50, "width": 400, "height": 200, "csv": csv})).unwrap()); - s.execute("graph.setType", &json!({"id": g.0, "pieLegend": "wedges", "piePosition": "even"})).unwrap(); - // The two wedges of the first series, painted with the two swatches. - let wedges = first_series_wedges(&s, g); - assert_eq!(wedges.len(), 2); - s.execute("paint.setFill", &json!({"ids": [wedges[0].0], "swatch": "Ink"})).unwrap(); - s.execute("paint.setFill", &json!({"ids": [wedges[1].0], "swatch": "Coal"})).unwrap(); - /// The bounds of object `id` in graph `g`. - fn bounds(s: &Session, g: NodeId, id: NodeId) -> Rect { - let mut out = None; - node(s, g).walk(&mut |n| { - if n.id == id { - out = n.geometric_bounds(); - } - }); - out.unwrap() - } - /// Each label of the first series with its fill, and whether it starts nearer the Ink wedge than the Coal one - /// (`wedges`), with the bounds of the Ink label. - fn labels(s: &Session, g: NodeId, name: &str, wedges: &[NodeId]) -> (Vec<(bool, Paint)>, Option) { - let (ink, coal) = (bounds(s, g, wedges[0]).center(), bounds(s, g, wedges[1]).center()); - let (mut out, mut on_ink) = (vec![], None); - node(s, g).walk(&mut |n| { - if let NodeKind::Text(t) = &n.kind - && t.plain_text() == name - { - let at = t.xf.translation().to_point(); - let nearer = at.distance(ink) < at.distance(coal); - if nearer { - on_ink = n.geometric_bounds(); - } - out.push((nearer, t.first_style().fill.clone())); - } - }); - out.sort_by_key(|(nearer, _)| *nearer); - (out, on_ink) - } - if left { - let mut ids = vec![]; - node(&s, g).walk(&mut |n| { - if matches!(&n.kind, NodeKind::Text(t) if t.plain_text() == name) { - ids.push(n.id); - } - }); - for id in ids { - s.execute("text.setStyle", &json!({"id": id.0, "justify": "left"})).unwrap(); - } - } - let white = Paint::solid(Color::WHITE); - let (drawn, on_ink) = labels(&s, g, name, &wedges); - assert_eq!(drawn, vec![(false, white.clone()), (true, white.clone())]); - if left { - // The Ink label's middle lies in the Coal wedge, so its middle does not tell its wedge. - let coal = bounds(&s, g, wedges[1]); - assert!(coal.contains(on_ink.unwrap().center()), "{on_ink:?} against {coal:?}"); - } - copy(&mut s, &[g]); - new_doc(&mut s); - s.execute("swatch.new", &json!({"name": "Ink", "color": "#ffffff", "global": true})).unwrap(); - for cmd in ["edit.pasteWithoutFormatting", "edit.paste"] { - let p = ids_of(&s.execute(cmd, &json!({"swatchConflict": "merge"})).unwrap())[0]; - // The pasted graph's wedges are in the copy's order. - let pasted = first_series_wedges(&s, p); - assert_eq!(labels(&s, p, name, &pasted).0, vec![(false, white.clone()), (true, Paint::solid(Color::BLACK))], "{cmd} {name}"); - } -} - /// Two-line legend labels of a light and a dark series with one name keep a redraw's colors when pasted without /// formatting, though the narrow light wedge's label reaches into the dark wedge after it. #[test] @@ -1006,18 +854,21 @@ fn shared_name_labels_in_a_narrow_wedge_pasted_without_formatting_keep_their_col new_doc(&mut s); let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0]; assert_eq!(qs(&s, p), drawn); - // Names that hold a line break of their own (a redraw sets them as one line at the point) keep them too. - new_doc(&mut s); - let named = json!({"type": "pie", "x": 50, "y": 50, "width": 200, "height": 200, "series": ["A", "B", "Q\nQ", "Q\nQ"], "categories": ["P"], "rows": [[9, 0, 0.02, 26.98]]}); - let g = id_of(&s.execute("graph.create", &named).unwrap()); - s.execute("graph.setType", &json!({"id": g.0, "pieLegend": "wedges"})).unwrap(); - let broken = qs(&s, g); - assert_eq!(broken.len(), 2); - assert_ne!(broken[0], broken[1], "names with line breaks"); - copy(&mut s, &[g]); - new_doc(&mut s); - let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0]; - assert_eq!(qs(&s, p), broken, "names with line breaks"); + // Names that hold a line break of their own (a redraw sets them on lines centered on the point, as it sets `|`) keep + // them too. + for name in ["Q\nQ", "Q\nQ\nQ\nQ\nQ\nQ\nQ\nQ\nQ"] { + new_doc(&mut s); + let named = json!({"type": "pie", "x": 50, "y": 50, "width": 200, "height": 200, "series": ["A", "B", name, name], "categories": ["P"], "rows": [[9, 0, 0.02, 26.98]]}); + let g = id_of(&s.execute("graph.create", &named).unwrap()); + s.execute("graph.setType", &json!({"id": g.0, "pieLegend": "wedges"})).unwrap(); + let broken = qs(&s, g); + assert_eq!(broken.len(), 2); + assert_ne!(broken[0], broken[1], "names with line breaks"); + copy(&mut s, &[g]); + new_doc(&mut s); + let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0]; + assert_eq!(qs(&s, p), broken, "names with {} line breaks", name.matches('\n').count()); + } // So do labels whose names show the same text, one with a line break of its own and one with `|`. new_doc(&mut s); let named = json!({"type": "pie", "x": 50, "y": 50, "width": 200, "height": 200, "series": ["Q\n", "B", "Q|", "Q|"], "categories": ["P"], "rows": [[9, 0, 0.02, 26.98]]}); @@ -1080,9 +931,8 @@ fn shared_name_labels_in_a_narrow_wedge_pasted_without_formatting_keep_their_col new_doc(&mut s); let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0]; assert_eq!(placed(&s, p), large, "large labels drawn again"); - // A scaled and rotated pie pasted with a swatch that merges keeps them too. + // A scaled and rotated pie keeps them too. new_doc(&mut s); - let r = swatched_rect(&mut s, "Ink", "#000000"); let g = id_of(&s.execute("graph.create", &json!({"type": "pie", "x": 50, "y": 50, "width": 200, "height": 200, "csv": csv})).unwrap()); s.execute("graph.setType", &json!({"id": g.0, "pieLegend": "wedges"})).unwrap(); sel(&mut s, &[g]); @@ -1090,11 +940,9 @@ fn shared_name_labels_in_a_narrow_wedge_pasted_without_formatting_keep_their_col s.execute("object.rotate", &json!({"angle": 30})).unwrap(); let drawn = qs(&s, g); assert_ne!(drawn[0], drawn[1]); - copy(&mut s, &[r, g]); + copy(&mut s, &[g]); new_doc(&mut s); - s.execute("swatch.new", &json!({"name": "Ink", "color": "#ffffff", "global": true})).unwrap(); - let ids = ids_of(&s.execute("edit.paste", &json!({"swatchConflict": "merge"})).unwrap()); - let p = ids.into_iter().find(|id| node(&s, *id).extras().graph.is_some()).unwrap(); + let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0]; assert_eq!(qs(&s, p), drawn); } @@ -1211,6 +1059,21 @@ fn wedges_of_one_series_painted_apart_keep_their_label_colors_when_pasted_withou } assert_eq!(pasted[0], pasted[1], "{csv}"); } + // A series name of nine lines, from line breaks a quoted CSV cell keeps, on two stacked pies: each label is centered + // on its own ring, as a redraw centers it. + fn tall(s: &Session, id: NodeId) -> Vec { + let mut out = vec![]; + node(s, id).walk(&mut |n| { + if let NodeKind::Text(t) = &n.kind + && t.plain_text() == ["N"; 9].join("\n") + { + out.push(t.first_style().fill.clone()); + } + }); + out + } + let csv = format!(",\"{}\"\nP,1\nQ,3", ["N"; 9].join("\n")); + paste_painted_apart(&mut s, &csv, "stacked", false, tall); } /// Paste without Formatting a pie graph made from `csv` with pies at `position` and Legends in Wedges, after its first @@ -1288,8 +1151,7 @@ fn a_hidden_wedge_fill_does_not_color_its_label() { let csv = ",North,South\nP,1,1"; let g = id_of(&s.execute("graph.create", &json!({"type": "pie", "x": 50, "y": 50, "width": 200, "height": 200, "csv": csv})).unwrap()); s.execute("graph.setType", &json!({"id": g.0, "pieLegend": "wedges"})).unwrap(); - let before = node(&s, g).clone(); - let mut n = before.clone(); + let mut n = node(&s, g).clone(); // A hidden black fill above the light South wedge's fill. for c in n.children_mut().unwrap().iter_mut().filter(|c| c.extras().series_index == Some(1)) { for w in std::sync::Arc::make_mut(c).children_mut().unwrap().iter_mut() { @@ -1300,7 +1162,7 @@ fn a_hidden_wedge_fill_does_not_color_its_label() { } } } - crate::cmd::graph::wedge_label_contrast(&before, &mut n); + crate::cmd::graph::plain_labels(&mut n, &|_| {}); let mut south = None; n.walk(&mut |m| { if let NodeKind::Text(t) = &m.kind @@ -1369,6 +1231,26 @@ fn a_graph_without_generated_bounds_pasted_without_formatting_follows_the_offset let r = node(&s, p).extras().graph.as_deref().unwrap().rect; let off = [r.x0 - plot.x0 - 30.0, r.y0 - plot.y0 + 5.0, r.x1 - plot.x1 - 30.0, r.y1 - plot.y1 + 5.0]; assert!(off.iter().all(|d| d.abs() < 1e-6), "{plot:?} → {r:?}"); + + // A graph saved with the bounds of all its art but not those of its art without type, drawn with 30 pt labels: the + // labels set at 9 pt do not read as a move or scale when it is drawn again. + let g = id_of(&s.execute("graph.create", &json!({"type": "bar", "x": 300, "y": 60, "width": 100, "height": 80, "csv": csv})).unwrap()); + sel(&mut s, &[g]); + s.execute("text.setStyle", &json!({"size": 30})).unwrap(); + s.execute("graph.setData", &json!({"id": g.0, "csv": csv})).unwrap(); + let plot = { + let mut extras = doc_mut(&mut s).node_mut(g).unwrap().extras_mut(); + let spec = extras.graph.as_mut().unwrap(); + spec.placed_art = None; + spec.rect + }; + copy(&mut s, &[g]); + let v = s.execute("edit.pasteWithoutFormatting", &json!({"dx": 30, "dy": -5})).unwrap(); + let p = ids_of(&v)[0]; + s.execute("graph.setData", &json!({"id": p.0, "csv": csv})).unwrap(); + let r = node(&s, p).extras().graph.as_deref().unwrap().rect; + let off = [r.x0 - plot.x0 - 30.0, r.y0 - plot.y0 + 5.0, r.x1 - plot.x1 - 30.0, r.y1 - plot.y1 + 5.0]; + assert!(off.iter().all(|d| d.abs() < 1e-6), "without the bounds of the art without type: {plot:?} → {r:?}"); } #[test] diff --git a/docs/mcp.md b/docs/mcp.md index d342b587cd..ccc11f9e1c 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -7059,17 +7059,14 @@ spot colour as its Spot Colors option says). The result reports `{ids, added, me to}]}`. `edit.pasteWithoutFormatting` sets the labels of a pasted graph in a new graph's label character style and -paragraph attributes, keeping each label's alignment. The pasted graph no longer keeps its labels' character and +paragraph attributes, keeping each label's alignment and where it starts. The pasted graph no longer keeps its labels' character and paragraph formats, so a later `graph.setData` or `graph.setType` sets the labels the same way. The rest of the graph's art is pasted as it was copied. A global or spot swatch whose name the document gives another colour is a conflict: `clipboard.conflicts` lists them, and `swatchConflict` answers: `"merge"` (the default: the objects take the document's swatch), `"add"` (the pasted swatch comes in renamed) or one answer per name. Pasting back into the document the objects came from uses -its resources as they are now and raises no conflict. When the document's swatches change the color of a pasted pie's -wedges (after a merge, or in the document the pie came from after a swatch it uses was changed), the legend labels in -those wedges are set white on a dark wedge and black on a light one, as a redraw sets them, unless the pie keeps a fill -for its legend labels. +its resources as they are now and raises no conflict. Placement: `edit.paste {center}` centres the objects on a point (the app passes the view centre), else offsets them by `dx`/`dy` (the Paste Offset preference). With nothing selected, `edit.pasteInFront` / `edit.pasteInBack` put them