diff --git a/crates/engine/src/cmd/graph.rs b/crates/engine/src/cmd/graph.rs index 118c4cd04f..7be0a122df 100644 --- a/crates/engine/src/cmd/graph.rs +++ b/crates/engine/src/cmd/graph.rs @@ -41,7 +41,7 @@ pub(crate) use data::{apply_csv, parse_csv}; #[cfg(test)] use data::{fmt_value, to_csv}; pub use edit::{GraphTypeSubject, graph_type_subject}; -pub(crate) use edit::{graphs_holding, keep_placed}; +pub(crate) use edit::{edit_graph_art, graphs_holding, keep_placed}; pub use generate::calculated_axes; #[cfg(test)] use generate::generate; @@ -50,8 +50,8 @@ use series::default_series_color; 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, series_group_marks, series_group_marks_where, series_groups_among, series_marks_in, - series_object_opacity_differs, shown_paint, + 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, }; #[cfg(test)] diff --git a/crates/engine/src/cmd/graph/edit.rs b/crates/engine/src/cmd/graph/edit.rs index 0d914ad8ff..378a8e3745 100644 --- a/crates/engine/src/cmd/graph/edit.rs +++ b/crates/engine/src/cmd/graph/edit.rs @@ -88,6 +88,23 @@ pub(crate) fn keep_placed(d: &mut Document, graphs: &[(NodeId, Option, Opt } } +/// Let `edit` change the art of graph node `n` (a copy outside any document) without the next redraw reading the +/// change as a move or scale: the plot rect first takes any move or scale of the art since it was generated, as a +/// redraw measures it, and the bounds the graph keeps become those of the changed art (also for a graph that had none, +/// so a later move is followed). +pub(crate) fn edit_graph_art(n: &mut Node, edit: impl FnOnce(&mut Node)) { + let moved = measured(n).and_then(|(placed, now)| n.extras().graph.as_deref().and_then(|g| carried(g.rect, placed, now))); + if let (Some(rect), Some(g)) = (moved, n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) { + g.rect = rect; + } + edit(n); + let children = n.children(); + let (whole, art) = (children.and_then(|c| children_bounds(c)), children.and_then(|c| art_bounds(c))); + if let Some(g) = n.extras_if_any().and_then(|e| e.graph.as_deref_mut()) { + (g.placed, g.placed_art) = (whole, art); + } +} + /// Regenerate graph `id` from `spec` (adjusting the plot rect for any move/scale since the last /// generation, which is recorded in the spec's `placed_art` bounds, or `placed` in graphs saved without them). pub(super) fn regenerate(d: &mut Document, id: NodeId, mut spec: GraphSpec) -> Result<()> { diff --git a/crates/engine/src/cmd/graph/generate.rs b/crates/engine/src/cmd/graph/generate.rs index 247dc92346..f589ed9ae5 100644 --- a/crates/engine/src/cmd/graph/generate.rs +++ b/crates/engine/src/cmd/graph/generate.rs @@ -293,7 +293,7 @@ fn bar_rect(horizontal: bool, a: f64, w: f64, v0: f64, v1: f64) -> Rect { } /// White text on a dark wedge. -fn is_dark(fill: &Paint) -> bool { +pub(super) fn is_dark(fill: &Paint) -> bool { fill.color().is_some_and(|c| { let [r, g, b] = c.to_rgb(); 0.2126 * r + 0.7152 * g + 0.0722 * b < 0.5 diff --git a/crates/engine/src/cmd/graph/series.rs b/crates/engine/src/cmd/graph/series.rs index 681c1a6a4a..ee33f23517 100644 --- a/crates/engine/src/cmd/graph/series.rs +++ b/crates/engine/src/cmd/graph/series.rs @@ -1,8 +1,13 @@ use vectorcraft_color::BlendMode; +use vectorcraft_geom::Shape; use super::axis::radar_axis; -use super::labels::{Role, filled_lines_on, label_para, label_style}; -use super::{Arc, CharStyle, Color, Document, GraphKind, GraphSpec, HashMap, Node, NodeId, NodeKind, Paint, Rect, Result, Session, Value, bad}; +use super::generate::is_dark; +use super::labels::{Role, filled_lines_on, label_para, label_size, label_style}; +use super::{ + Affine, Arc, CharStyle, Color, Document, GraphKind, GraphSpec, HashMap, Node, NodeId, NodeKind, Paint, Rect, Result, Session, Value, bad, + refresh_bounds, +}; // ---------- generation ---------- @@ -639,35 +644,460 @@ fn graph_labels(doc: &Document) -> HashMap { let mut out = HashMap::new(); doc.walk(|n| { let Some(g) = n.extras().graph.as_deref() else { return }; - for c in n.children().into_iter().flatten() { - match (&c.kind, c.name.as_deref(), c.extras().series_index) { - (NodeKind::Text(_), _, _) if g.kind == GraphKind::Pie => { - out.insert(c.id, (n.id, Role::Category)); - } - (NodeKind::Group { .. }, Some("Legend"), None) => { - for t in c.children().into_iter().flatten().filter(|t| matches!(t.kind, NodeKind::Text(_))) { - out.insert(t.id, (n.id, Role::Legend)); - } - } - (NodeKind::Group { .. }, Some("Axes"), None) => { - let texts: Vec = - c.children().into_iter().flatten().filter(|t| matches!(t.kind, NodeKind::Text(_))).map(|t| t.id).collect(); - let cats = match g.kind { - GraphKind::Scatter => 0, - GraphKind::Radar => texts.len(), - _ => g.categories.iter().take(g.rows.len().max(1)).filter(|c| !c.is_empty()).count().min(texts.len()), - }; - for (i, t) in texts.iter().enumerate() { - out.insert(*t, (n.id, if i + cats >= texts.len() { Role::Category } else { Role::Value })); - } - } - _ => {} - } + for (id, role) in own_labels(n, g) { + out.insert(id, (n.id, role)); } }); out } +/// The generated labels of graph `n` (with spec `g`) and what each shows, as [`graph_labels`] finds them. +fn own_labels(n: &Node, g: &GraphSpec) -> Vec<(NodeId, Role)> { + let mut out = vec![]; + for c in n.children().into_iter().flatten() { + match (&c.kind, c.name.as_deref(), c.extras().series_index) { + (NodeKind::Text(_), _, _) if g.kind == GraphKind::Pie => { + out.push((c.id, Role::Category)); + } + (NodeKind::Group { .. }, Some("Legend"), None) => { + for t in c.children().into_iter().flatten().filter(|t| matches!(t.kind, NodeKind::Text(_))) { + out.push((t.id, Role::Legend)); + } + } + (NodeKind::Group { .. }, Some("Axes"), None) => { + let texts: Vec = c.children().into_iter().flatten().filter(|t| matches!(t.kind, NodeKind::Text(_))).map(|t| t.id).collect(); + let cats = match g.kind { + GraphKind::Scatter => 0, + GraphKind::Radar => texts.len(), + _ => g.categories.iter().take(g.rows.len().max(1)).filter(|c| !c.is_empty()).count().min(texts.len()), + }; + for (i, t) in texts.iter().enumerate() { + out.push((*t, if i + cats >= texts.len() { Role::Category } else { Role::Value })); + } + } + _ => {} + } + } + out +} + +/// 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() { + Some(g) if g.kind == GraphKind::Pie && g.legend && g.pie_legend_in_wedges => { + let mut redraw = g.clone(); + plain(&mut redraw.label_styles); + redrawn_on_dark(n, &redraw).unwrap_or_else(|| legend_on_dark(n, g)) + } + _ => HashMap::new(), + }; + let Some(g) = n.extras_if_any().and_then(|e| e.graph.as_deref_mut()) else { return }; + plain(&mut g.label_styles); + let Some(g) = n.extras().graph.as_deref() else { return }; + let roles: HashMap = own_labels(n, g).into_iter().collect(); + struct Plain<'a> { + roles: HashMap, + other: &'a dyn Fn(&mut Node), + } + fn visit(c: &mut Node, p: &Plain) { + let role = p.roles.get(&c.id).copied(); + if let (Some(role), NodeKind::Text(t)) = (role, &mut c.kind) { + let plain = vectorcraft_doc::LabelStyles::default(); + let justify = t.para_at(0).justify; + let text = t.plain_text(); + t.runs = vec![vectorcraft_doc::TextRun { text, style: label_style(&plain, role), inline: None }]; + let mut para = label_para(&plain, role); + para.justify = justify; + t.set_all_paras(para); + // The label loses its scale and keeps its rotation (and a reflection), so it stays turned with the + // rest of a rotated graph's art. + let [a, b, c, d, _, _] = t.xf.as_coeffs(); + let flip = if a * d - b * c < 0.0 { Affine::scale_non_uniform(1.0, -1.0) } else { Affine::IDENTITY }; + t.xf = Affine::translate(t.xf.translation()) * Affine::rotate(b.atan2(a)) * flip; + refresh_bounds(t); + return; + } + // The Axes and Legend groups, and any other group of the graph's own, are looked through. + if role.is_none() && matches!(c.kind, NodeKind::Group { .. }) && c.extras().graph.is_none() { + for k in c.children_mut().into_iter().flatten() { + visit(Arc::make_mut(k), p); + } + return; + } + (p.other)(c); + } + let p = Plain { roles, other }; + for c in n.children_mut().into_iter().flatten() { + visit(Arc::make_mut(c), &p); + } + set_legend_fills(n, &on_dark); +} + +/// Give each legend label of graph `n` in `on_dark` white characters (`true`) or black ones. +fn set_legend_fills(n: &mut Node, on_dark: &HashMap) { + if on_dark.is_empty() { + return; + } + // The legend labels are in the graph's Legend group. + for c in n.children_mut().into_iter().flatten() { + if !c.children().into_iter().flatten().any(|t| on_dark.contains_key(&t.id)) { + continue; + } + for t in Arc::make_mut(c).children_mut().into_iter().flatten() { + let Some(dark) = on_dark.get(&t.id) else { continue }; + let fill = Paint::solid(if *dark { Color::WHITE } else { Color::BLACK }); + if matches!(&t.kind, NodeKind::Text(text) if text.runs.iter().all(|r| r.style.fill == fill)) { + continue; + } + if let NodeKind::Text(text) = &mut Arc::make_mut(t).kind { + text.runs.iter_mut().for_each(|r| r.style.fill = fill.clone()); + } + } + } +} + +/// The legend labels of graph `n`: the type in its Legend group, as [`own_labels`] finds them. +fn legend_labels(n: &Node) -> Vec<&Node> { + legend_in(n.children().map_or(&[], |c| c.as_slice())) +} + +/// The type in the Legend group among a graph's `children`. +fn legend_in(children: &[Arc]) -> Vec<&Node> { + children + .iter() + .filter(|c| matches!(c.kind, NodeKind::Group { .. }) && c.name.as_deref() == Some("Legend") && c.extras().series_index.is_none()) + .flat_map(|c| c.children().into_iter().flatten()) + .map(|t| t.as_ref()) + .filter(|t| matches!(t.kind, NodeKind::Text(_))) + .collect() +} + +/// A pie wedge: its series, its outline and whether the fill it shows is dark. +struct Wedge { + series: u32, + bounds: vectorcraft_geom::Rect, + path: vectorcraft_geom::BezPath, + dark: bool, +} + +/// The wedges of pie `n` (the paths of its series groups), in paint order. +fn pie_wedges(n: &Node) -> Vec { + wedges_in(n.children().map_or(&[], |c| c.as_slice())) +} + +/// The wedges among a pie's `children`, as [`pie_wedges`] finds them. +fn wedges_in(children: &[Arc]) -> Vec { + let mut wedges = vec![]; + for c in children { + let Some(series) = c.extras().series_index else { continue }; + c.walk(&mut |m| { + if let NodeKind::Path { path, .. } = &m.kind { + let path = path.to_bezpath(); + wedges.push(Wedge { series, bounds: path.bounding_box(), path, dark: is_dark(&shown_paint(m, true).0) }); + } + }); + } + wedges +} + +/// The text of the label of each series of graph `g` in its wedges, with the series that show it (several when series +/// share a name). +fn series_by_label(g: &GraphSpec) -> HashMap> { + let mut names: HashMap> = HashMap::new(); + for (s, name) in g.series.iter().enumerate() { + if let Ok(s) = u32::try_from(s) { + names.entry(name.replace('|', "\n")).or_default().push(s); + } + } + names +} + +/// The legend labels of pie `n`, each with whether a redraw from spec `g` sets it on a dark wedge, by the fill each wedge +/// of `n` shows now. The pie is drawn again apart from any document, which places each label in its wedge as `g` sets +/// it and tells each label's series. Each redrawn wedge takes the fill of the wedge of `n` of its series with the same +/// bounds, and each legend label of `n` is the nearest redrawn one with the same text, places measured in the bounds of +/// all the wedges. `None` when a wedge or a label has no counterpart of its own, or a wedge out of its place in paint +/// order has several (art or labels added, removed, edited or restyled since the pie was drawn). +fn redrawn_on_dark(n: &Node, g: &GraphSpec) -> Option> { + use vectorcraft_geom::kurbo::ParamCurveNearest; + // Each series name that shows a label is drawn with a mark and its index after it, which moves no line, so each + // redrawn label tells its series. + const MARK: char = '\u{1f}'; + let mut marked = g.clone(); + for (s, name) in marked.series.iter_mut().enumerate() { + if !super::labels::blank_label(name) { + name.push(MARK); + name.push_str(&s.to_string()); + } + } + let legend = legend_labels(n); + let mut scratch = Document::new(1.0, 1.0); + let art = super::generate::generate(&mut scratch, &marked); + let redrawn = legend_in(&art); + let now = pie_wedges(n); + let mut wedges = wedges_in(&art); + if redrawn.len() != legend.len() || wedges.len() != now.len() { + return None; + } + // Places are measured in the bounds of all the wedges, since the pie may have been moved or resized since it was drawn. + let frame = |ws: &[Wedge]| ws.iter().map(|w| w.bounds).reduce(|a, b| a.union(b)); + let (Some(was), Some(is)) = (frame(&wedges), frame(&now)) else { return None }; + let at = |f: vectorcraft_geom::Rect, p: vectorcraft_geom::Point| ((p.x - f.x0) / f.width().max(1e-9), (p.y - f.y0) / f.height().max(1e-9)); + let corners = |f: vectorcraft_geom::Rect, w: &Wedge| { + let (a, b) = (at(f, vectorcraft_geom::Point::new(w.bounds.x0, w.bounds.y0)), at(f, vectorcraft_geom::Point::new(w.bounds.x1, w.bounds.y1))); + [a.0, a.1, b.0, b.1] + }; + // A wedge and a redrawn one are the same wedge when they show the same series with the same bounds. Wedges are + // compared this many times in all, labels included; past it, nothing is matched. + let mut budget: usize = 1 << 20; + let mut copied_wedges: HashMap> = HashMap::new(); + for (k, w) in now.iter().enumerate() { + copied_wedges.entry(w.series).or_default().push((k, corners(is, w))); + } + // The wedge in the same place in paint order when its bounds are the same, else the one of the series whose bounds + // are nearest, when they are the same to within 0.001 of the whole; each copied wedge is matched once. + let off = |c: &[f64; 4], d: &[f64; 4]| c.iter().zip(d).map(|(p, q)| (p - q).abs()).fold(0.0, f64::max); + let mut used = vec![false; now.len()]; + let mut fills = Vec::with_capacity(wedges.len()); + for (i, r) in wedges.iter().enumerate() { + let c = corners(was, r); + let k = match now.get(i) { + Some(w) if w.series == r.series && off(&c, &corners(is, w)) <= 1e-7 => i, + _ => { + let group = copied_wedges.get(&r.series)?; + if budget < group.len() { + return None; + } + budget -= group.len(); + let (k, d) = group.iter().map(|(k, d)| (*k, off(&c, d))).min_by(|a, b| a.1.total_cmp(&b.1))?; + if d > 1e-3 { + return None; + } + k + } + }; + if std::mem::replace(used.get_mut(k)?, true) { + return None; + } + fills.push(now.get(k)?.dark); + } + for (r, dark) in wedges.iter_mut().zip(fills) { + r.dark = dark; + } + // Each label's words and origin, in the frame of its pie's wedges. + let place = |labels: &[&Node], f| { + labels + .iter() + .map(|t| match &t.kind { + NodeKind::Text(text) => Some((text.plain_text(), at(f, text.xf * vectorcraft_geom::Point::ORIGIN))), + _ => None, + }) + .collect::>>() + }; + let (Some(copied), Some(mut drawn)) = (place(&legend, is), place(&redrawn, was)) else { return None }; + // The series of each redrawn label, and its words without the mark. + let mut series = Vec::with_capacity(drawn.len()); + for (words, _) in &mut drawn { + let (said, s) = words.rsplit_once(MARK)?; + let s: usize = s.parse().ok()?; + let name = g.series.get(s)?; + if *said != name.replace('|', "\n") { + return None; + } + series.push((s, name.as_str())); + words.truncate(said.len()); + } + // Each label is the nearest redrawn label that says the same, and no two labels are the same redrawn one. + let mut same: HashMap<&str, Vec> = HashMap::new(); + for (j, (words, _)) in drawn.iter().enumerate() { + same.entry(words.as_str()).or_default().push(j); + } + let mut taken = vec![false; drawn.len()]; + let mut matched = Vec::with_capacity(copied.len()); + for (words, p) in &copied { + let group = same.get(words.as_str())?; + if budget < group.len() { + return None; + } + budget -= group.len(); + let away = |j: &usize| drawn.get(*j).map_or(f64::INFINITY, |(_, q)| (p.0 - q.0).powi(2) + (p.1 - q.1).powi(2)); + let j = *group.iter().min_by(|a, b| away(a).total_cmp(&away(b)))?; + if std::mem::replace(taken.get_mut(j)?, true) { + return None; + } + matched.push(j); + } + let mut by_series: HashMap> = HashMap::new(); + 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 `|` 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); + let mut out = HashMap::new(); + for (t, j) in legend.iter().zip(matched) { + let (Some(r), Some((s, name))) = (redrawn.get(j), series.get(j).copied()) else { return None }; + let s = u32::try_from(s).ok()?; + if let Some(Some(dark)) = uniform.get(&s) { + out.insert(t.id, *dark); + continue; + } + let NodeKind::Text(text) = &r.kind else { return None }; + 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() { + return None; + } + budget -= named.len(); + // The nearest wedge, measured exactly only while its bounds are nearer than the nearest one found so far. + let to_box = |b: vectorcraft_geom::Rect| { + let (dx, dy) = ((b.x0 - point.x).max(point.x - b.x1).max(0.0), (b.y0 - point.y).max(point.y - b.y1).max(0.0)); + dx * dx + dy * dy + }; + let away = |w: &Wedge| { + if w.path.contains(point) { 0.0 } else { w.path.segments().map(|seg| seg.nearest(point, 1e-3).distance_sq).fold(f64::INFINITY, f64::min) } + }; + let mut by_box: Vec<(f64, &Wedge)> = named.iter().map(|w| (to_box(w.bounds), *w)).collect(); + by_box.sort_by(|a, b| a.0.total_cmp(&b.0)); + let mut best: Option<(f64, bool)> = None; + for (near, w) in by_box { + if best.is_some_and(|b| near >= b.0) { + break; + } + let d = away(w); + if best.is_none_or(|b| d < b.0) { + best = Some((d, w.dark)); + } + } + out.insert(t.id, best?.1); + } + Some(out) +} + +/// The legend labels of graph `n` (a pie with Legends in Wedges, its labels where it drew them), each with whether a +/// redraw sets it on a dark wedge, by the fill each wedge shows now. A label is placed by the point a redraw centers it +/// on, which lies inside its wedge: the wedge holding it of the series the label names (the nearest one when none holds +/// it), or of any series when the label names none. When the wedges of the series a label names are all dark or all +/// light, the label's place does not matter. +fn legend_on_dark(n: &Node, g: &GraphSpec) -> HashMap { + wedge_contrast(&legend_labels(n), &pie_wedges(n), g) +} + +/// 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 `|` 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(['|', '\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()); + let middle = match (ys.first(), ys.last()) { + (Some(first), Some(last)) if g.label_styles.legend_para.is_some() && ends_set && ys.len() == set => (first + last) / 2.0, + _ => lines.len().saturating_sub(1) as f64 * ls * 1.2 / 2.0, + }; + text.xf * vectorcraft_geom::Point::new(0.0, middle - ls * 0.35) +} + +/// [`legend_on_dark`] for legend labels `legend` of a pie of spec `g` with wedges `wedges`. +fn wedge_contrast(legend: &[&Node], wedges: &[Wedge], g: &GraphSpec) -> HashMap { + use vectorcraft_geom::kurbo::ParamCurveNearest; + let mut out = HashMap::new(); + if legend.is_empty() { + return out; + } + let mut by_series: HashMap> = HashMap::new(); + for w in wedges { + by_series.entry(w.series).or_default().push(w); + } + // For each label text, the wedges of the series that show it, and how many of them are dark. + let named_wedges: HashMap, usize)> = series_by_label(g) + .into_iter() + .map(|(name, series)| { + let named: Vec<&Wedge> = series.iter().filter_map(|s| by_series.get(s)).flatten().copied().collect(); + let darks = named.iter().filter(|w| w.dark).count(); + (name, (named, darks)) + }) + .collect(); + let none = (vec![], 0); + // For each label text, the first series name that shows it (names that differ only in `|` against a line break of + // their own show the same text, and nothing in the label tells them apart). + 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 search for the nearest wedge looks at this many wedges in all; past it, a label takes the contrast most of the + // wedges of its series show. + let mut budget: usize = 1 << 20; + for t in legend { + let NodeKind::Text(text) = &t.kind else { continue }; + let id = t.id; + let words = text.plain_text(); + let (named, darks) = named_wedges.get(&words).unwrap_or(&none); + let darks = *darks; + if (darks == 0 || darks == named.len()) + && let Some(w) = named.first() + { + out.insert(id, w.dark); + continue; + } + let name = series_names.get(&words).map_or_else(|| words.replace('\n', "|"), |s| (*s).to_owned()); + let at = label_anchor(text, &name, g, ls); + if named.is_empty() { + let cost = wedges.len(); + let dark = budget >= cost && wedges.iter().any(|w| w.dark && w.bounds.contains(at) && w.path.contains(at)); + budget = budget.saturating_sub(cost); + out.insert(id, dark); + continue; + } + if budget < named.len() { + out.insert(id, darks * 2 > named.len()); + continue; + } + budget -= named.len(); + // The nearest wedge, measured exactly only while its bounds are nearer than the nearest one found so far. + let to_box = |r: vectorcraft_geom::Rect| { + let (dx, dy) = ((r.x0 - at.x).max(at.x - r.x1).max(0.0), (r.y0 - at.y).max(at.y - r.y1).max(0.0)); + dx * dx + dy * dy + }; + let away = |w: &Wedge| { + 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) } + }; + let mut by_box: Vec<(f64, &Wedge)> = named.iter().map(|w| (to_box(w.bounds), *w)).collect(); + by_box.sort_by(|a, b| a.0.total_cmp(&b.0)); + let mut best = (f64::INFINITY, darks * 2 > named.len()); + for (near, w) in by_box { + if near >= best.0 { + break; + } + let d = away(w); + if d < best.0 { + best = (d, w.dark); + } + } + out.insert(id, best.1); + } + out +} + /// 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 01615e5f4f..0701f6eeca 100644 --- a/crates/engine/src/cmd/typemenu.rs +++ b/crates/engine/src/cmd/typemenu.rs @@ -138,7 +138,7 @@ pub fn specs() -> Vec { "Paste without Formatting", ["Edit"], Some("Cmd+Alt+V"), - "{center?, dx?, dy?, swatchConflict?} paste as edit.paste; pasted text takes the default character style (one run) and leaves its character and paragraph styles behind, as do the labels of pasted graphs → {ids, added, merged, renamed}", + "{center?, dx?, dy?, swatchConflict?} paste as edit.paste; pasted text takes the default character style (one run) and leaves its character and paragraph styles behind, as do the labels of pasted graphs, which take a new graph's label character style and paragraph attributes (keeping their alignment); a pasted graph no longer keeps its labels' character and paragraph formats, so a later redraw sets them the same way → {ids, added, merged, renamed}", has_clipboard, paste_plain ), @@ -1767,15 +1767,12 @@ fn strip_formatting(n: &mut vectorcraft_doc::Node) { t.set_all_paras(Default::default()); refresh_bounds(t); } - // A graph keeps the names of its labels' character and paragraph styles for each redraw. - if let Some(g) = n.extras_if_any().and_then(|e| e.graph.as_deref_mut()) { - let l = &mut g.label_styles; - for st in [&mut l.values, &mut l.categories, &mut l.legend].into_iter().flatten() { - st.style_name = None; - } - for pa in [&mut l.values_para, &mut l.categories_para, &mut l.legend_para].into_iter().flatten() { - pa.style_name = None; - } + // A graph keeps its labels' character and paragraph formats for each redraw: they go with the + // 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)); + return; } if let Some(ch) = n.children_mut() { for c in ch.iter_mut() { diff --git a/crates/engine/src/tests/clipboard.rs b/crates/engine/src/tests/clipboard.rs index be79004be2..368bc6de10 100644 --- a/crates/engine/src/tests/clipboard.rs +++ b/crates/engine/src/tests/clipboard.rs @@ -531,7 +531,726 @@ fn a_pasted_graph_brings_and_follows_its_label_styles_and_paints() { new_doc(&mut s); let v = s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap(); assert!(doc(&s).char_styles.is_empty() && doc(&s).para_styles.is_empty(), "{v}"); - assert_eq!(graph_links(&s, ids_of(&v)[0]), (brand, None, None)); + // The value labels' red characters stay behind too; the series fill and the legend rows keep Brand. + assert_eq!(graph_links(&s, ids_of(&v)[0]), (vec!["Brand".to_string(); 2], None, None)); +} + +/// Each label of graph `id` (the text of its Axes and Legend groups) with its size and its first paragraph's format. +fn graph_label_looks(s: &Session, id: NodeId) -> Vec<(String, f64, vectorcraft_doc::ParaStyle)> { + let mut out = vec![]; + node(s, id).walk(&mut |n| { + if let NodeKind::Text(t) = &n.kind { + out.push((t.plain_text(), t.first_style().size, t.para_at(0).clone())); + } + }); + out +} + +/// Paste without Formatting sets a pasted graph's labels in a new graph's label size and paragraph attributes, and the +/// graph keeps them when it is drawn again: the character and paragraph formats its labels keep stay behind with their +/// text formatting. +#[test] +fn a_graph_pasted_without_formatting_keeps_plain_labels_when_drawn_again() { + let mut s = session(); + let csv = ",2024,2025\nQ1,3,2\nQ2,5,4"; + let g = id_of(&s.execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 120, "height": 80, "csv": csv})).unwrap()); + let fresh = graph_label_looks(&s, g); + sel(&mut s, &[g]); + s.execute("text.setStyle", &json!({"size": 14})).unwrap(); + s.execute("text.setFormat", &json!({"spaceAfter": 6})).unwrap(); + assert!(graph_label_looks(&s, g).iter().all(|(_, size, para)| *size == 14.0 && para.space_after == 6.0)); + // The labels as drawn are also set in a character style. + s.execute("charStyle.new", &json!({"name": "Emphasis", "attrs": {"size": 14.0}})).unwrap(); + let mut labels = vec![]; + node(&s, g).walk(&mut |n| { + if matches!(n.kind, NodeKind::Text(_)) { + labels.push(n.id); + } + }); + for id in labels { + if let Some(NodeKind::Text(t)) = doc_mut(&mut s).node_mut(id).map(|n| &mut n.kind) { + t.runs.iter_mut().for_each(|r| r.style.style_name = Some("Emphasis".into())); + } + } + copy(&mut s, &[g]); + assert_eq!(s.clipboard.char_styles.iter().map(|c| c.name.as_str()).collect::>(), ["Emphasis"]); + // Where a redraw of the copied graph puts its plot. + s.execute("graph.setData", &json!({"id": g.0, "csv": csv})).unwrap(); + let plot = node(&s, g).extras().graph.as_deref().unwrap().rect; + + new_doc(&mut s); + let steps = s.doc().unwrap().history.undo.len(); + let v = s.execute("edit.pasteWithoutFormatting", &json!({"dx": 10, "dy": 20})).unwrap(); + let p = ids_of(&v)[0]; + assert!(doc(&s).char_styles.is_empty(), "the labels' character style stays behind: {v}"); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "one undo step"); + assert_eq!(s.doc().unwrap().history.undo.last().map(|e| e.label.as_str()), Some("Paste without Formatting")); + let l = node(&s, p).extras().graph.as_deref().unwrap().label_styles.clone(); + assert_eq!((l.values, l.categories, l.legend), (None, None, None)); + assert_eq!((l.values_para, l.categories_para, l.legend_para), (None, None, None)); + let pasted = graph_label_looks(&s, p); + assert_eq!(pasted, fresh, "the labels are set as a new graph's are"); + + s.execute("graph.setData", &json!({"id": p.0, "csv": csv})).unwrap(); + assert_eq!(graph_label_looks(&s, p), pasted, "and stay so when the graph is drawn again"); + let r = node(&s, p).extras().graph.as_deref().unwrap().rect; + let off = [r.x0 - plot.x0 - 10.0, r.y0 - plot.y0 - 20.0, r.x1 - plot.x1 - 10.0, r.y1 - plot.y1 - 20.0]; + assert!(off.iter().all(|d| d.abs() < 1e-6), "the plot moves by the offset and keeps its size: {plot:?} → {r:?}"); + s.execute("graph.setType", &json!({"id": p.0, "type": "column"})).unwrap(); + assert_eq!(graph_label_looks(&s, p), pasted, "a change of type draws them the same way"); + + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(doc(&s).node(p).is_none(), "one undo takes the paste back"); +} + +/// Paste without Formatting leaves the rest of a pasted graph's art as it was copied: a column painted by hand keeps its +/// paint, and nothing is drawn again. +#[test] +fn a_graph_pasted_without_formatting_keeps_its_art() { + let mut s = session(); + let csv = ",2024,2025\nQ1,3,2\nQ2,5,4"; + let g = id_of(&s.execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 120, "height": 80, "csv": csv})).unwrap()); + let red = Paint::solid(hex("ff0000")); + let mut column = None; + node(&s, g).walk(&mut |n| { + if column.is_none() && matches!(n.kind, NodeKind::Path { .. }) && n.appearance.fill_paint() != Paint::None { + column = Some(n.id); + } + }); + let column = column.unwrap(); + let n = doc_mut(&mut s).node_mut(column).unwrap(); + n.appearance = vectorcraft_doc::Appearance::basic(red.clone(), Paint::None, 0.0).into(); + /// Every object of graph `id` that is not type: its kind's name, its fill and its bounds. + fn art(s: &Session, id: NodeId) -> Vec<(String, Paint, Option)> { + let mut out = vec![]; + node(s, id).walk(&mut |n| { + if !matches!(n.kind, NodeKind::Text(_)) { + out.push((format!("{:?}", std::mem::discriminant(&n.kind)), n.appearance.fill_paint(), n.geometric_bounds())); + } + }); + out + } + let copied = art(&s, g); + copy(&mut s, &[g]); + new_doc(&mut s); + let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({"dx": 15, "dy": 25})).unwrap())[0]; + let pasted = art(&s, p); + let dx = pasted[0].2.unwrap().x0 - copied[0].2.unwrap().x0; + let dy = pasted[0].2.unwrap().y0 - copied[0].2.unwrap().y0; + let moved: Vec<_> = copied.into_iter().map(|(k, f, b)| (k, f, b.map(|b| b + vectorcraft_geom::Vec2::new(dx, dy)))).collect(); + assert_eq!(pasted.len(), moved.len()); + for (a, b) in pasted.iter().zip(&moved) { + assert_eq!((&a.0, &a.1), (&b.0, &b.1)); + let (a, b) = (a.2.unwrap(), b.2.unwrap()); + assert!((a.x0 - b.x0).abs() + (a.y0 - b.y0).abs() + (a.x1 - b.x1).abs() + (a.y1 - b.y1).abs() < 1e-6, "{a:?} {b:?}"); + } + assert!(pasted.iter().any(|(_, f, _)| *f == red), "the column keeps the paint it was given"); +} + +/// The labels of a scaled graph pasted without formatting are set at a new graph's label size, unscaled, as a redraw +/// sets them. +#[test] +fn a_scaled_graph_pasted_without_formatting_gets_unscaled_labels() { + let mut s = session(); + let csv = ",2024\nQ1,3\nQ2,5"; + let g = id_of(&s.execute("graph.create", &json!({"type": "column", "x": 50, "y": 50, "width": 120, "height": 80, "csv": csv})).unwrap()); + let fresh = graph_label_looks(&s, g)[0].1; + sel(&mut s, &[g]); + s.execute("object.scale", &json!({"sx": 200, "sy": 200})).unwrap(); + /// Each label's size as drawn: its character size times the scale of its transform. + fn drawn_sizes(s: &Session, id: NodeId) -> Vec { + let mut out = vec![]; + node(s, id).walk(&mut |n| { + if let NodeKind::Text(t) = &n.kind { + out.push(t.first_style().size * t.xf.determinant().abs().sqrt()); + } + }); + out + } + assert!(drawn_sizes(&s, g).iter().all(|z| (z - fresh * 2.0).abs() < 1e-6), "{:?}", drawn_sizes(&s, g)); + copy(&mut s, &[g]); + new_doc(&mut s); + let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0]; + assert!(drawn_sizes(&s, p).iter().all(|z| (z - fresh).abs() < 1e-6), "{:?}", drawn_sizes(&s, p)); + s.execute("graph.setData", &json!({"id": p.0, "csv": csv})).unwrap(); + assert!(drawn_sizes(&s, p).iter().all(|z| (z - fresh).abs() < 1e-6), "{:?}", drawn_sizes(&s, p)); + + // A rotated graph's labels keep their rotation, so they stay turned with the rest of its art. + /// Each label's rotation in degrees: the angle of its transform's x axis. + fn angles(s: &Session, id: NodeId) -> Vec { + let mut out = vec![]; + node(s, id).walk(&mut |n| { + if let NodeKind::Text(t) = &n.kind { + let [a, b, ..] = t.xf.as_coeffs(); + out.push(b.atan2(a).to_degrees()); + } + }); + out + } + let r = id_of(&s.execute("graph.create", &json!({"type": "column", "x": 300, "y": 50, "width": 120, "height": 80, "csv": csv})).unwrap()); + sel(&mut s, &[r]); + s.execute("object.scale", &json!({"sx": 200, "sy": 200})).unwrap(); + s.execute("object.rotate", &json!({"angle": 30})).unwrap(); + let turned = angles(&s, r); + assert!(turned.iter().all(|a| (a.abs() - 30.0).abs() < 1e-6), "{turned:?}"); + copy(&mut s, &[r]); + new_doc(&mut s); + let q = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0]; + assert_eq!(angles(&s, q).len(), turned.len()); + assert!(angles(&s, q).iter().zip(&turned).all(|(a, b)| (a - b).abs() < 1e-6), "{:?} against {turned:?}", angles(&s, q)); + assert!(drawn_sizes(&s, q).iter().all(|z| (z - fresh).abs() < 1e-6), "{:?}", drawn_sizes(&s, q)); +} + +/// A pie's legend labels in their wedges keep a redraw's contrast when pasted without formatting: white on a dark +/// wedge, black on a light one. +#[test] +fn pie_labels_in_wedges_pasted_without_formatting_stay_readable() { + 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(); + /// Each text of graph `id` with its characters' fill. + fn fills(s: &Session, id: NodeId) -> Vec<(String, Paint)> { + let mut out = vec![]; + node(s, id).walk(&mut |n| { + if let NodeKind::Text(t) = &n.kind { + out.push((t.plain_text(), t.first_style().fill.clone())); + } + }); + out + } + let drawn = fills(&s, g); + assert!(drawn.contains(&("North".into(), Paint::solid(Color::WHITE))), "the first wedge is black: {drawn:?}"); + sel(&mut s, &[g]); + s.execute("text.setStyle", &json!({"size": 14})).unwrap(); + copy(&mut s, &[g]); + new_doc(&mut s); + let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0]; + assert_eq!(fills(&s, p), drawn); + s.execute("graph.setData", &json!({"id": p.0, "csv": csv})).unwrap(); + 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] +fn a_legend_label_in_a_narrow_wedge_pasted_without_formatting_stays_readable() { + let mut s = session(); + // The first wedge fills a quarter from the top; the dark fourth series ("Narrow", series 3) follows it for 0.2 degrees, + // narrower than the label's distance from the wedge's middle line. + let csv = ",A,B,C,Narrow|,E\nP,9,0,0,0.02,26.98"; + 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(); + fn narrow(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().starts_with("Narrow") + { + out = Some(t.first_style().fill.clone()); + } + }); + out.unwrap() + } + assert_eq!(narrow(&s, g), Paint::solid(Color::WHITE)); + copy(&mut s, &[g]); + new_doc(&mut s); + let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0]; + assert_eq!(narrow(&s, p), Paint::solid(Color::WHITE)); + s.execute("graph.setData", &json!({"id": p.0, "csv": csv})).unwrap(); + assert_eq!(narrow(&s, p), Paint::solid(Color::WHITE)); + // A standard legend beside the pie stays black. + 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": "standard"})).unwrap(); + assert_eq!(narrow(&s, g), Paint::solid(Color::BLACK)); + copy(&mut s, &[g]); + new_doc(&mut s); + let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0]; + assert_eq!(narrow(&s, p), Paint::solid(Color::BLACK)); + // Two series with one name: each label keeps its own wedge's contrast, though the second label lies nearer the + // middle of the first (dark) wedge's bounds than of its own. + fn xs(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() == "X" + { + out.push(t.first_style().fill.clone()); + } + }); + out + } + let csv = ",X,X,Y\nP,8,1,89"; + let g = id_of(&s.execute("graph.create", &json!({"type": "pie", "x": 0, "y": 0, "width": 2000, "height": 2000, "csv": csv})).unwrap()); + s.execute("graph.setType", &json!({"id": g.0, "pieLegend": "wedges"})).unwrap(); + let drawn = xs(&s, g); + assert_eq!(drawn, vec![Paint::solid(Color::WHITE), Paint::solid(Color::BLACK)]); + copy(&mut s, &[g]); + new_doc(&mut s); + let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0]; + assert_eq!(xs(&s, p), drawn); +} + +/// 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] +fn shared_name_labels_in_a_narrow_wedge_pasted_without_formatting_keep_their_colors() { + let mut s = session(); + let csv = ",A,B,Q|,Q|\nP,9,0,0.02,26.98"; + 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(); + fn qs(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().starts_with('Q') + { + out.push(t.first_style().fill.clone()); + } + }); + out + } + let drawn = qs(&s, g); + assert_eq!(drawn.len(), 2); + assert_ne!(drawn[0], drawn[1], "one light and one dark series"); + copy(&mut s, &[g]); + 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 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]]}); + let g = id_of(&s.execute("graph.create", &named).unwrap()); + s.execute("graph.setType", &json!({"id": g.0, "pieLegend": "wedges"})).unwrap(); + // The fills of the Q labels by their place from left to right and top to bottom. + fn placed(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().starts_with('Q') + { + let o = t.xf.translation(); + out.push((o.x, o.y, t.first_style().fill.clone())); + } + }); + out.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.total_cmp(&b.1))); + out.into_iter().map(|(_, _, f)| f).collect() + } + let alike = placed(&s, g); + assert_eq!(alike.len(), 3); + copy(&mut s, &[g]); + new_doc(&mut s); + let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0]; + assert_eq!(placed(&s, p), alike, "names that show the same text"); + // The narrow light wedge's label (the second) brought to the front keeps them too. + new_doc(&mut s); + let g = id_of(&s.execute("graph.create", &named).unwrap()); + s.execute("graph.setType", &json!({"id": g.0, "pieLegend": "wedges"})).unwrap(); + let legend = node(&s, g).children().unwrap().iter().find(|c| c.name.as_deref() == Some("Legend")).unwrap().clone(); + let labels = legend.children().unwrap(); + assert_eq!(labels.len(), 3); + s.execute("object.arrange.bringToFront", &json!({"ids": [labels[1].id.0]})).unwrap(); + copy(&mut s, &[g]); + new_doc(&mut s); + let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0]; + assert_eq!(placed(&s, p), alike, "names that show the same text, labels reordered"); + // Labels given another size, which leaves them where they were drawn, keep them too. + new_doc(&mut s); + 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]); + s.execute("text.setStyle", &json!({"size": 14})).unwrap(); + copy(&mut s, &[g]); + new_doc(&mut s); + let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0]; + assert_eq!(qs(&s, p), drawn, "resized labels"); + // Large labels of four series of one name, drawn again at that size, keep the colors that drawing gave them. + new_doc(&mut s); + let four = ",Q|,Q|,Q|,Q|\nP,1,1,1,1"; + let g = id_of(&s.execute("graph.create", &json!({"type": "pie", "x": 50, "y": 50, "width": 200, "height": 200, "csv": four})).unwrap()); + s.execute("graph.setType", &json!({"id": g.0, "pieLegend": "wedges"})).unwrap(); + sel(&mut s, &[g]); + s.execute("text.setStyle", &json!({"size": 200})).unwrap(); + s.execute("graph.setData", &json!({"id": g.0, "csv": four})).unwrap(); + let large = placed(&s, g); + assert_eq!(large.len(), 4); + assert!(large.contains(&Paint::solid(Color::WHITE)) && large.contains(&Paint::solid(Color::BLACK)), "{large:?}"); + copy(&mut s, &[g]); + 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 keeps them too. + new_doc(&mut s); + 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]); + s.execute("object.scale", &json!({"sx": 400, "sy": 400})).unwrap(); + s.execute("object.rotate", &json!({"angle": 30})).unwrap(); + let drawn = qs(&s, g); + assert_ne!(drawn[0], drawn[1]); + copy(&mut s, &[g]); + new_doc(&mut s); + let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0]; + assert_eq!(qs(&s, p), drawn); +} + +/// The legend labels of a pie that kept a fill of their own (red, or black on every wedge), scaled, with two series of +/// one name, are pasted without formatting in the colors a redraw gives them. +#[test] +fn recolored_wedge_labels_pasted_without_formatting_match_a_redraw() { + for fill in ["#ff0000", "#000000"] { + recolored_wedge_labels_match_a_redraw(fill); + } +} + +fn recolored_wedge_labels_match_a_redraw(fill: &str) { + let mut s = session(); + let csv = ",A,B,Q|,Q|\nP,9,0,0.02,26.98"; + 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]); + s.execute("text.setStyle", &json!({"fill": fill})).unwrap(); + s.execute("object.scale", &json!({"sx": 400, "sy": 400})).unwrap(); + fn legend(s: &Session, id: NodeId) -> Vec<(String, Paint)> { + let mut out = vec![]; + node(s, id).walk(&mut |n| { + if let NodeKind::Text(t) = &n.kind + && ["A", "Q"].iter().any(|p| t.plain_text().starts_with(p)) + { + out.push((t.plain_text(), t.first_style().fill.clone())); + } + }); + out + } + assert!(legend(&s, g).iter().all(|(_, f)| *f == Paint::solid(hex(fill))), "{fill}: {:?}", legend(&s, g)); + copy(&mut s, &[g]); + new_doc(&mut s); + let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0]; + let pasted = legend(&s, p); + s.execute("graph.setData", &json!({"id": p.0, "csv": csv})).unwrap(); + let redrawn = legend(&s, p); + assert_eq!(pasted, redrawn, "{fill}"); + assert!( + redrawn.iter().any(|(_, f)| *f == Paint::solid(Color::WHITE)) && redrawn.iter().any(|(_, f)| *f == Paint::solid(Color::BLACK)), + "{fill}: {redrawn:?}" + ); +} + +/// A wedge whose fill was changed after its label was drawn gives the label, pasted without formatting, the color a +/// redraw gives it. +#[test] +fn a_repainted_wedge_sets_its_label_color_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(); + s.execute("graph.setData", &json!({"id": g.0, "csv": csv})).unwrap(); + fn labels(s: &Session, id: NodeId) -> Vec<(String, Paint)> { + let mut out = vec![]; + node(s, id).walk(&mut |n| { + if let NodeKind::Text(t) = &n.kind + && ["North", "South"].contains(&t.plain_text().as_str()) + { + out.push((t.plain_text(), t.first_style().fill.clone())); + } + }); + out + } + // Each wedge takes the other's contrast: the dark one (its label white) white, the light one black. + let drawn = labels(&s, g); + let white = |name: &str| drawn.iter().any(|(t, f)| t == name && *f == Paint::solid(Color::WHITE)); + let mut wedges = vec![]; + for c in node(&s, g).children().unwrap().iter() { + let Some(series) = c.extras().series_index else { continue }; + let dark = white(["North", "South"][series as usize]); + c.walk(&mut |m| { + if let NodeKind::Path { .. } = m.kind { + wedges.push((m.id, dark)); + } + }); + } + assert!(wedges.iter().any(|w| w.1) && wedges.iter().any(|w| !w.1), "{drawn:?}"); + for (id, dark) in &wedges { + s.execute("paint.setFill", &json!({"ids": [id.0], "color": if *dark { "#ffffff" } else { "#000000" }})).unwrap(); + } + let before = labels(&s, g); + copy(&mut s, &[g]); + new_doc(&mut s); + let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0]; + let pasted = labels(&s, p); + s.execute("graph.setData", &json!({"id": p.0, "csv": csv})).unwrap(); + assert_eq!(pasted, labels(&s, p), "copied as {before:?}"); +} + +/// When one pie's wedge of a series is repainted and another pie's wedge of that series is not, each pasted legend label +/// takes the contrast of the wedge it is in. +#[test] +fn wedges_of_one_series_painted_apart_keep_their_label_colors_when_pasted_without_formatting() { + let mut s = session(); + fn north(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.push(t.first_style().fill.clone()); + } + }); + out + } + // Two pies side by side, and two stacked pies of one series (a disk on a larger one, and on one nearly its size). + for (csv, position) in [(",North,South\nP,1,1\nQ,1,1", "even"), (",North\nP,1\nQ,3", "stacked"), (",North\nP,1000\nQ,999", "stacked")] { + // The second time, the repainted wedge is brought to the front of its series first. + let mut pasted = vec![]; + for reorder in [false, true] { + pasted.push(paste_painted_apart(&mut s, csv, position, reorder, north)); + } + 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 +/// wedge of the first series takes the other contrast and, with `reorder`, comes to the front of its series. Returns +/// what `north` reads from the pasted graph, after checking the two values differ. +fn paste_painted_apart(s: &mut Session, csv: &str, position: &str, reorder: bool, north: fn(&Session, NodeId) -> Vec) -> Vec { + new_doc(s); + 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": position})).unwrap(); + let drawn = north(s, g); + assert_eq!(drawn.len(), 2); + assert_eq!(drawn[0], drawn[1]); + // Repaint the first North wedge with the contrast its label shows, so it takes the other contrast. + let mut first = None; + for c in node(s, g).children().unwrap().iter() { + if c.extras().series_index == Some(0) && first.is_none() { + c.walk(&mut |m| { + if first.is_none() && matches!(m.kind, NodeKind::Path { .. }) { + first = Some(m.id); + } + }); + } + } + let first = first.unwrap(); + let color = if drawn[0] == Paint::solid(Color::WHITE) { "#ffffff" } else { "#000000" }; + s.execute("paint.setFill", &json!({"ids": [first.0], "color": color})).unwrap(); + if reorder { + s.execute("object.arrange.bringToFront", &json!({"ids": [first.0]})).unwrap(); + } + copy(s, &[g]); + new_doc(s); + let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0]; + let labels = north(s, p); + assert_ne!(labels[0], labels[1], "drawn as {drawn:?}, {position}, reorder {reorder}"); + labels +} + +/// Legend labels of series that share a name, brought into another order, each keep the contrast of the wedge they are +/// in when the pie is pasted without formatting. +#[test] +fn reordered_shared_name_labels_keep_their_colors_when_pasted_without_formatting() { + let mut s = session(); + let g = id_of(&s.execute("graph.create", &json!({"type": "pie", "x": 50, "y": 50, "width": 200, "height": 200, "csv": ",X,X\nP,1,1"})).unwrap()); + s.execute("graph.setType", &json!({"id": g.0, "pieLegend": "wedges"})).unwrap(); + // Each label's fill, by its place from left to right and top to bottom. + fn labels(s: &Session, id: NodeId) -> (Vec, Vec) { + let mut out = vec![]; + node(s, id).walk(&mut |n| { + if let NodeKind::Text(t) = &n.kind + && t.plain_text() == "X" + { + let o = t.xf.translation(); + out.push((o.x, o.y, n.id, t.first_style().fill.clone())); + } + }); + out.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.total_cmp(&b.1))); + out.into_iter().map(|(_, _, id, f)| (id, f)).unzip() + } + let (ids, drawn) = labels(&s, g); + assert_eq!(drawn.len(), 2); + assert_ne!(drawn[0], drawn[1]); + let first = node(&s, g).children().unwrap().iter().find(|c| c.name.as_deref() == Some("Legend")).unwrap().children().unwrap()[0].id; + assert!(ids.contains(&first)); + s.execute("object.arrange.bringToFront", &json!({"ids": [first.0]})).unwrap(); + copy(&mut s, &[g]); + new_doc(&mut s); + let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0]; + assert_eq!(labels(&s, p).1, drawn); +} + +/// A wedge's hidden fill does not set its legend label's color: the fill it shows does. +#[test] +fn a_hidden_wedge_fill_does_not_color_its_label() { + 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(); + 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() { + let w = std::sync::Arc::make_mut(w); + if let Some(vectorcraft_doc::AppearanceItem::Fill(f)) = w.appearance.items.iter().find(|it| it.is_fill()).cloned() { + let hidden = vectorcraft_doc::FillLayer { paint: Paint::solid(Color::BLACK), visible: false, ..f }; + w.appearance.items.push(vectorcraft_doc::AppearanceItem::Fill(hidden)); + } + } + } + crate::cmd::graph::plain_labels(&mut n, &|_| {}); + let mut south = None; + n.walk(&mut |m| { + if let NodeKind::Text(t) = &m.kind + && t.plain_text() == "South" + { + south = Some(t.first_style().fill.clone()); + } + }); + assert_eq!(south, Some(Paint::solid(Color::BLACK))); +} + +/// A graph inside a group, and a graph inside a graph, get plain labels too. +#[test] +fn graphs_in_groups_pasted_without_formatting_get_plain_labels() { + let mut s = session(); + let csv = ",2024\nQ1,3\nQ2,5"; + let g = id_of(&s.execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 120, "height": 80, "csv": csv})).unwrap()); + let fresh = graph_label_looks(&s, g); + sel(&mut s, &[g]); + s.execute("text.setStyle", &json!({"size": 14})).unwrap(); + let r = rect(&mut s, 10.0, 10.0, 20.0, 20.0); + sel(&mut s, &[g, r]); + let group = id_of(&s.execute("object.group", &json!({})).unwrap()); + // A copy of the graph placed inside it, as a file may hold one. + let inner = { + let d = doc_mut(&mut s); + let mut copy = d.node(g).unwrap().clone(); + copy = d.reid(©); + let id = copy.id; + d.node_mut(g).unwrap().children_mut().unwrap().push(std::sync::Arc::new(copy)); + id + }; + copy(&mut s, &[group]); + new_doc(&mut s); + let p = ids_of(&s.execute("edit.pasteWithoutFormatting", &json!({})).unwrap())[0]; + let mut graphs = vec![]; + node(&s, p).walk(&mut |n| { + if let Some(spec) = n.extras().graph.as_deref() { + graphs.push((n.id, spec.label_styles.clone())); + } + }); + assert_eq!(graphs.len(), 2, "{inner:?}"); + for (id, l) in graphs { + assert_eq!((l.values, l.categories, l.legend, l.values_para, l.categories_para, l.legend_para), (None, None, None, None, None, None)); + let looks = graph_label_looks(&s, id); + assert!(looks.iter().all(|(_, size, _)| *size == fresh[0].1), "{looks:?}"); + } +} + +/// A graph without its generated bounds (as a file may hold it) is drawn again where Paste without Formatting put it. +#[test] +fn a_graph_without_generated_bounds_pasted_without_formatting_follows_the_offset() { + let mut s = session(); + let csv = ",2024\nQ1,3\nQ2,5"; + let g = id_of(&s.execute("graph.create", &json!({"type": "bar", "x": 50, "y": 60, "width": 100, "height": 80, "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 = 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), "{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 6794343a1e..0e2603b91c 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -7171,6 +7171,11 @@ added is reused), which the pasted objects follow. Linked colours show the docum spot colour as its Spot Colors option says). The result reports `{ids, added, merged, renamed: [{kind, from, 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 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