Merge pull request #7772 from dotCooCoo/fix/paste-plain-graph-labels

Paste: Paste without Formatting keeps a pasted graph's labels plain when it is drawn again
This commit is contained in:
BFlat
2026-11-15 16:40:03 +07:00
committed by GitHub
7 changed files with 1209 additions and 41 deletions
+3 -3
View File
@@ -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)]
+17
View File
@@ -88,6 +88,23 @@ pub(crate) fn keep_placed(d: &mut Document, graphs: &[(NodeId, Option<Rect>, 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<()> {
+1 -1
View File
@@ -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
+456 -26
View File
@@ -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<NodeId, (NodeId, Role)> {
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<NodeId> =
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<NodeId> = 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<NodeId, Role> = own_labels(n, g).into_iter().collect();
struct Plain<'a> {
roles: HashMap<NodeId, Role>,
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<NodeId, bool>) {
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<Node>]) -> 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<Wedge> {
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<Node>]) -> Vec<Wedge> {
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<String, Vec<u32>> {
let mut names: HashMap<String, Vec<u32>> = 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<HashMap<NodeId, bool>> {
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<u32, Vec<(usize, [f64; 4])>> = 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::<Option<Vec<_>>>()
};
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<usize>> = 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<u32, Vec<&Wedge>> = 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<u32, Option<bool>> =
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<NodeId, bool> {
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<f64> = 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<NodeId, bool> {
use vectorcraft_geom::kurbo::ParamCurveNearest;
let mut out = HashMap::new();
if legend.is_empty() {
return out;
}
let mut by_series: HashMap<u32, Vec<&Wedge>> = 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<String, (Vec<&Wedge>, 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<String, &str> = 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 {
+7 -10
View File
@@ -138,7 +138,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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() {
+720 -1
View File
@@ -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::<Vec<_>>(), ["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<Rect>)> {
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<f64> {
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<f64> {
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<Paint> {
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<Paint> {
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<Paint> {
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<Paint> {
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<Paint> {
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<Paint>) -> Vec<Paint> {
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<NodeId>, Vec<Paint>) {
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(&copy);
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]
+5
View File
@@ -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