mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-15 21:57:04 +00:00
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:
@@ -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)]
|
||||
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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(©);
|
||||
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]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user