Graphs: labels keep paragraph settings and anti-aliasing when the graph is drawn again

Paragraph settings given to all of a graph label's text with
text.setFormat (indents, spacing, hyphenation, Justification options,
composer, direction, kashida, kinsoku, mojikumi, burasagari and the
rest) and the label's anti-aliasing method were lost when the graph
drew its labels again, because the graph kept only a character style
for each kind of label. Each kind now also keeps its paragraph settings
and anti-aliasing method, saved with the graph in its label styles, and
the graph still sets each label's alignment for its place.

The legend spaces its entries by the kept legend settings: Legend
Across Top measures each label with its indents and spacing, both
legend layouts make room for extra leading or paragraph spacing, and a
label with a negative indent or negative space before moves right or
down so it stays clear of its swatch and of the entry above it.
This commit is contained in:
dotCooCoo
2026-11-10 23:40:25 -08:00
parent 9f44e37274
commit e76524fd8b
9 changed files with 379 additions and 33 deletions
+2 -2
View File
@@ -43,8 +43,8 @@ use generate::generate;
#[cfg(test)]
use series::default_series_color;
pub(crate) use series::{
capture_label_styles, capture_series_object_opacity, capture_series_opacity, capture_series_paints, capture_series_top_paints, paint_targets,
series_object_opacity_differs, shown_paint,
capture_label_anti_alias, capture_label_paragraphs, capture_label_styles, capture_series_object_opacity, capture_series_opacity,
capture_series_paints, capture_series_top_paints, paint_targets, series_object_opacity_differs, shown_paint,
};
#[cfg(test)]
+10 -6
View File
@@ -2,7 +2,8 @@ use super::axis::{LABEL_SIZE, MAX_CATEGORY_TICKS, MAX_TICK_MARKS, one_line, rada
use super::columns::{Allowance, ColumnArt, column_art, column_designs};
use super::data::fmt_value;
use super::labels::{
Gen, Role, SHADOW_BUDGET, SHADOW_OPACITY, blank_label, filled_lines_on, label_size, label_width, polyline, recolour_text, runs, shadow_of,
Gen, Role, SHADOW_BUDGET, SHADOW_OPACITY, blank_label, filled_lines_on, label_extra_height, label_shift, label_size, label_width, polyline,
recolour_text, runs, shadow_of,
};
use super::markers::{MARKER_BUDGET, marker_cost, marker_designs};
use super::series::{series_marks, with_series_opacity};
@@ -901,6 +902,8 @@ impl<'a> Plot<'a> {
fn legend_label(&mut self, (x, y): (f64, f64), label: &str) -> Arc<Node> {
let ls = self.ls;
let (dx, dy) = label_shift(&self.g.label_styles, label);
let (x, y) = (x + dx, y + dy);
self.b.label(Role::Legend, Point::new(x + 12.0, y + 7.5 + (ls - LABEL_SIZE) * 0.35), label, Justify::Left, false)
}
@@ -909,10 +912,11 @@ impl<'a> Plot<'a> {
fn legend_across_top(&mut self) -> Vec<Arc<Node>> {
let (g, r, ls) = (self.g, self.r, self.ls);
let entries: Vec<(usize, String)> = (0..self.legend_series()).map(|s| (s, self.legend_text(s))).filter(|(_, l)| !blank_label(l)).collect();
let mut rows: Vec<Vec<(usize, String, f64)>> = vec![vec![]];
let mut rows: Vec<Vec<(usize, String, f64, f64)>> = vec![vec![]];
let mut used = 0.0;
for (s, label) in entries {
let w = 12.0 + label_width(&g.label_styles, &label) + 12.0;
let h = ls * 1.8 * label.split('|').count() as f64 + label_extra_height(&g.label_styles, &label);
// The gap after an entry may run past the plot's edge.
if used + w - 12.0 > r.width() && rows.last().is_some_and(|row| !row.is_empty()) {
rows.push(vec![]);
@@ -920,10 +924,10 @@ impl<'a> Plot<'a> {
}
used += w;
if let Some(row) = rows.last_mut() {
row.push((s, label, w));
row.push((s, label, w, h));
}
}
let height = |row: &[(usize, String, f64)]| ls * 1.8 * row.iter().map(|(_, l, _)| l.split('|').count()).max().unwrap_or(1) as f64;
let height = |row: &[(usize, String, f64, f64)]| row.iter().map(|(.., h)| *h).fold(ls * 1.8, f64::max);
// Above the plot and anything drawn over it (radar and stacked-pie labels).
let top = self.b.out.iter().chain(self.on_top.iter()).filter_map(|n| n.geometric_bounds()).map(|bb| bb.y0).fold(r.y0, f64::min);
let top = self.bars_reach.map_or(top, |b| top.min(b.y0));
@@ -931,7 +935,7 @@ impl<'a> Plot<'a> {
let mut labels = vec![];
for row in &rows {
let mut x = r.x0;
for (s, label, w) in row {
for (s, label, w, _) in row {
self.legend_swatch(*s, (x, y));
labels.push(self.legend_label((x, y), label));
x += w;
@@ -954,7 +958,7 @@ impl<'a> Plot<'a> {
y += ls * 1.8;
continue;
}
y += ls * 1.8 * label.split('|').count() as f64;
y += ls * 1.8 * label.split('|').count() as f64 + label_extra_height(&self.g.label_styles, &label);
self.legend_swatch(s, (x, row_y));
labels.push(self.legend_label((x, row_y), &label));
}
+58 -4
View File
@@ -10,7 +10,7 @@ use super::{
pub(super) struct Gen<'a> {
pub(super) d: &'a mut Document,
pub(super) out: Vec<Arc<Node>>,
/// The labels' kept character styles.
/// The labels' kept character styles and paragraph attributes.
pub(super) styles: &'a vectorcraft_doc::LabelStyles,
/// What the column designs may still add.
pub(super) columns: Allowance,
@@ -45,6 +45,30 @@ pub(super) fn label_style(styles: &vectorcraft_doc::LabelStyles, role: Role) ->
st
}
/// The paragraph attributes labels of `role` are set with: the kept ones, else the defaults. The caller sets the
/// alignment.
pub(super) fn label_para(styles: &vectorcraft_doc::LabelStyles, role: Role) -> vectorcraft_doc::ParaStyle {
let kept = match role {
Role::Value => &styles.values_para,
Role::Category => &styles.categories_para,
Role::Legend => &styles.legend_para,
};
let mut para = kept.clone().unwrap_or_default();
// A file's attributes within the limits the Paragraph controls keep to (they are copied onto every label).
para.keep_spacing_in_range();
para
}
/// The anti-aliasing method labels of `role` are drawn with: the kept one, else Sharp.
pub(super) fn label_anti_alias(styles: &vectorcraft_doc::LabelStyles, role: Role) -> vectorcraft_doc::text::TypeAntiAlias {
let kept = match role {
Role::Value => styles.values_anti_alias,
Role::Category => styles.categories_anti_alias,
Role::Legend => styles.legend_anti_alias,
};
kept.unwrap_or_default()
}
/// How big labels of `role` are set: a kept style's size, else 9 pt. The labels' spacing follows it.
pub(super) fn label_size(styles: &vectorcraft_doc::LabelStyles, role: Role) -> f64 {
label_style(styles, role).size
@@ -63,6 +87,8 @@ impl Gen<'_> {
pub(super) fn text(&mut self, role: Role, at: Point, s: &str, justify: Justify) -> Arc<Node> {
let style = label_style(self.styles, role);
let mut t = TextObject::point(at, s, style);
t.para = label_para(self.styles, role);
t.anti_alias = label_anti_alias(self.styles, role);
t.para.justify = justify;
t.system_layout = self.d.setup.system_layout;
refresh_bounds(&mut t);
@@ -162,12 +188,40 @@ pub(super) fn shadow_of(d: &mut Document, n: &Node) -> Arc<Node> {
Arc::new(d.reid(&c))
}
/// How wide label `s` is set (its widest line), without adding it to a document.
pub(super) fn label_width(styles: &vectorcraft_doc::LabelStyles, s: &str) -> f64 {
/// The bounds of legend label `s` set from the origin with paragraph attributes `para`, without adding it to a document.
fn legend_label_bounds(styles: &vectorcraft_doc::LabelStyles, s: &str, para: vectorcraft_doc::ParaStyle) -> Option<Rect> {
let style = label_style(styles, Role::Legend);
let mut t = TextObject::point(Point::ZERO, &s.replace('|', "\n"), style);
t.para = para;
t.para.justify = Justify::Left;
refresh_bounds(&mut t);
Node::new(NodeId(0), NodeKind::Text(Box::new(t))).geometric_bounds().map_or(0.0, |b| b.width())
Node::new(NodeId(0), NodeKind::Text(Box::new(t))).geometric_bounds()
}
/// How far label `s` reaches right of where it starts (its widest line and any indent), after the legend moves it right
/// by [`label_shift`], without adding it to a document.
pub(super) fn label_width(styles: &vectorcraft_doc::LabelStyles, s: &str) -> f64 {
legend_label_bounds(styles, s, label_para(styles, Role::Legend)).map_or(0.0, |b| (b.x1 + label_shift(styles, s).0).max(0.0))
}
/// The bounds of legend label `s` set with its kept paragraph attributes and with the defaults, when it has kept ones.
fn kept_and_default_bounds(styles: &vectorcraft_doc::LabelStyles, s: &str) -> Option<(Rect, Rect)> {
styles.legend_para.as_ref()?;
Some((legend_label_bounds(styles, s, label_para(styles, Role::Legend))?, legend_label_bounds(styles, s, vectorcraft_doc::ParaStyle::default())?))
}
/// How much further left and further up legend label `s` reaches with its kept paragraph attributes than with the
/// defaults (a negative indent or negative spacing), so the legend moves it right and down by that much and it stays
/// clear of its swatch and of the entries before it.
pub(super) fn label_shift(styles: &vectorcraft_doc::LabelStyles, s: &str) -> (f64, f64) {
kept_and_default_bounds(styles, s).map_or((0.0, 0.0), |(kept, default)| ((default.x0 - kept.x0).max(0.0), (default.y0 - kept.y0).max(0.0)))
}
/// How much further down legend label `s` reaches with its kept paragraph attributes than with the defaults (more
/// leading, or space before or after), after the legend moves it down by [`label_shift`], so the legend makes room
/// for it.
pub(super) fn label_extra_height(styles: &vectorcraft_doc::LabelStyles, s: &str) -> f64 {
kept_and_default_bounds(styles, s).map_or(0.0, |(kept, default)| (kept.y1 + (default.y0 - kept.y0).max(0.0) - default.y1).max(0.0))
}
/// A label with nothing to show (empty, or only `|` line breaks).
+59 -12
View File
@@ -1,7 +1,7 @@
use vectorcraft_color::BlendMode;
use super::axis::radar_axis;
use super::labels::{Role, filled_lines_on, label_style};
use super::labels::{Role, filled_lines_on, label_para, label_style};
use super::{Arc, CharStyle, Color, Document, GraphKind, GraphSpec, HashMap, Node, NodeId, NodeKind, Paint, Result, Session, Value, bad};
// ---------- generation ----------
@@ -406,23 +406,34 @@ fn graph_labels(doc: &Document) -> HashMap<NodeId, (NodeId, Role)> {
out
}
/// Keep a formatting change made to graph labels `ids` (Character or Appearance, on the whole graph or on labels
/// picked with Group Selection) for every label of their kind: `change` is applied to the kind's kept style (or
/// the default one), so the labels look the same when the graph is drawn again. Called inside the same edit, so
/// they undo together. Returns whether a kept style changed.
pub(crate) fn capture_label_styles(doc: &mut Document, ids: &[NodeId], change: &dyn Fn(&mut CharStyle)) -> bool {
if ids.is_empty() {
return false;
}
/// 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 {
let labels = graph_labels(doc);
if labels.is_empty() {
return false;
}
let mut kinds: Vec<(NodeId, Role)> = ids.iter().filter_map(|id| labels.get(id).copied()).collect();
kinds.sort_by_key(|(g, r)| (g.0, *r as u8));
kinds.dedup();
let mut changed = false;
for (graph, role) in kinds {
let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue };
let slot = match role {
Role::Value => &mut spec.label_styles.values_anti_alias,
Role::Category => &mut spec.label_styles.categories_anti_alias,
Role::Legend => &mut spec.label_styles.legend_anti_alias,
};
changed |= *slot != Some(method);
*slot = Some(method);
}
changed
}
/// Keep a formatting change made to graph labels `ids` (Character or Appearance, on the whole graph or on labels
/// picked with Group Selection) for every label of their kind: `change` is applied to the kind's kept style (or
/// the default one), so the labels look the same when the graph is drawn again. Called inside the same edit, so
/// they undo together. Returns whether a kept style changed.
pub(crate) fn capture_label_styles(doc: &mut Document, ids: &[NodeId], change: &dyn Fn(&mut CharStyle)) -> bool {
let mut changed = false;
for (graph, role) in label_kinds(doc, ids) {
let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue };
let mut style = label_style(&spec.label_styles, role);
change(&mut style);
@@ -437,6 +448,42 @@ pub(crate) fn capture_label_styles(doc: &mut Document, ids: &[NodeId], change: &
changed
}
/// Keep a paragraph change made to all of the text of graph labels `ids` (Paragraph, on the whole graph or on labels
/// picked with Group Selection) for every label of their kind, as [`capture_label_styles`] does for character
/// formats. Returns whether kept attributes changed, or the error `change` returns for the kept attributes.
pub(crate) fn capture_label_paragraphs(
doc: &mut Document,
ids: &[NodeId],
change: &dyn Fn(&mut vectorcraft_doc::ParaStyle) -> Result<()>,
) -> Result<bool> {
let mut changed = false;
for (graph, role) in label_kinds(doc, ids) {
let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue };
let mut para = label_para(&spec.label_styles, role);
change(&mut para)?;
let slot = match role {
Role::Value => &mut spec.label_styles.values_para,
Role::Category => &mut spec.label_styles.categories_para,
Role::Legend => &mut spec.label_styles.legend_para,
};
changed |= slot.as_ref() != Some(&para);
*slot = Some(para);
}
Ok(changed)
}
/// The graphs and kinds of label among `ids`, each once.
fn label_kinds(doc: &Document, ids: &[NodeId]) -> Vec<(NodeId, Role)> {
if ids.is_empty() {
return vec![];
}
let labels = graph_labels(doc);
let mut kinds: Vec<(NodeId, Role)> = ids.iter().filter_map(|id| labels.get(id).copied()).collect();
kinds.sort_by_key(|(g, r)| (g.0, *r as u8));
kinds.dedup();
kinds
}
/// The series a command on graph `id` (of `count` series) applies to: `seriesIndexes`, else, with no `id` param, a
/// selection made only of this graph's series (Group Selection). Empty: the whole graph.
pub(super) fn picked_series(s: &Session, p: &Value, id: NodeId, count: usize, cmd: &str) -> Result<Vec<usize>> {
+199
View File
@@ -4055,6 +4055,45 @@ fn formatting_the_whole_graphs_text_survives_it_being_drawn_again() {
assert_eq!(spec(&s, id).label_styles, vectorcraft_doc::LabelStyles::default());
}
/// An anti-aliasing method given to one category label is kept for every category label when the graph is drawn
/// again, through save and open, and undoes with the change.
#[test]
fn a_graph_labels_anti_aliasing_method_survives_it_being_drawn_again() {
use vectorcraft_doc::text::TypeAntiAlias;
let methods = |s: &Session, id: NodeId| -> Vec<(String, TypeAntiAlias)> {
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let mut out = vec![];
for g in ["Axes", "Legend"] {
for c in group(&n, g).children().unwrap() {
if let NodeKind::Text(t) = &c.kind {
out.push((t.plain_text(), t.anti_alias));
}
}
}
out
};
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
let c = text_id(&s, id, "Axes", "Q2");
s.execute("text.setFormat", &json!({"ids": [c.0], "antiAlias": "strong"})).unwrap();
label_looks(&mut s, id);
let drawn = methods(&s, id);
let method = |t: &str| drawn.iter().find(|(x, _)| x == t).map(|(_, m)| *m).unwrap();
assert_eq!((method("Q1"), method("Q3")), (TypeAntiAlias::Strong, TypeAntiAlias::Strong), "every category label: {drawn:?}");
assert_eq!((method("0"), method("2024")), (TypeAntiAlias::Sharp, TypeAntiAlias::Sharp), "other labels: {drawn:?}");
// One undo takes the data change back, the next the method and the kept method together.
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(spec(&s, id).label_styles.categories_anti_alias, None);
s.execute("edit.redo", &json!({})).unwrap();
let path = vectorcraft_testkit::temp_dir("graph-label-anti-alias").join("labels.vectorcraft");
s.execute("document.save", &json!({"path": path})).unwrap();
s.execute("document.open", &json!({"path": path})).unwrap();
assert_eq!(spec(&s, id).label_styles.categories_anti_alias, Some(TypeAntiAlias::Strong));
assert!(serde_json::to_value(GraphSpec::default()).unwrap().get("labelStyles").is_none());
}
/// A stroke given to all of a graph's text without a weight is kept at 1 pt, as `paint.setStroke` keeps it.
#[test]
fn a_stroke_given_to_the_whole_graphs_text_is_kept_at_1_pt() {
@@ -4219,3 +4258,163 @@ fn csv_round_trip_keeps_blank_row_in_single_column_graph() {
assert!(c.is_empty());
assert_eq!(r, g.cells());
}
/// Each generated label's text, paragraph attributes and alignment.
fn label_paras(s: &Session, id: NodeId) -> Vec<(String, vectorcraft_doc::ParaStyle)> {
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let mut out = vec![];
for name in ["Axes", "Legend"] {
for c in group(&n, name).children().unwrap() {
if let NodeKind::Text(t) = &c.kind {
out.push((t.plain_text(), t.para.clone()));
}
}
}
out
}
#[test]
fn paragraph_formats_are_kept_when_the_graph_is_drawn_again() {
use vectorcraft_doc::{Composer, ParaDirection, text::Kashida};
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
let aligned: Vec<_> = label_paras(&s, id).into_iter().map(|(t, p)| (t, p.justify)).collect();
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("text.setFormat", &json!({"composer": "singleLine", "kashida": "long", "direction": "rightToLeft", "wordSpacing": [70, 90, 120]}))
.unwrap();
s.execute("graph.setData", &json!({"id": id.0, "csv": ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"})).unwrap();
let paras = label_paras(&s, id);
assert!(!paras.is_empty());
for (text, p) in &paras {
assert_eq!(p.composer, Composer::SingleLine, "{text}");
assert_eq!(p.kashida, Kashida::Long, "{text}");
assert_eq!(p.direction, Some(ParaDirection::RightToLeft), "{text}");
assert_eq!(p.justification.word_spacing, [70.0, 90.0, 120.0], "{text}");
}
// The graph still aligns each label to its place.
assert_eq!(paras.iter().map(|(t, p)| (t.clone(), p.justify)).collect::<Vec<_>>(), aligned);
// One kind of label: only that kind keeps it, also through save and open.
let col = graph(&mut s, "column");
let v = text_id(&s, col, "Axes", "4");
s.execute("select.set", &json!({"ids": [v.0]})).unwrap();
s.execute("text.setFormat", &json!({"composer": "singleLine"})).unwrap();
let path = vectorcraft_testkit::temp_dir("graph-label-paras").join("labels.vectorcraft");
s.execute("document.save", &json!({"path": path})).unwrap();
s.execute("document.open", &json!({"path": path})).unwrap();
s.execute("select.set", &json!({"ids": [col.0]})).unwrap();
s.execute("graph.setData", &json!({"id": col.0, "csv": ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"})).unwrap();
let paras = label_paras(&s, col);
let composer = |t: &str| paras.iter().find(|(x, _)| x == t).unwrap().1.composer;
assert_eq!(composer("0"), Composer::SingleLine, "every value axis number");
assert_eq!(composer("Q1"), Composer::default(), "category labels as they were");
assert_eq!(composer("2024"), Composer::default(), "legend labels as they were");
// Part of a label's text: nothing kept.
let q = text_id(&s, col, "Axes", "Q2");
s.execute("select.set", &json!({"ids": [q.0]})).unwrap();
s.execute("text.setFormat", &json!({"kashida": "short", "start": 0, "end": 1})).unwrap();
s.execute("graph.setData", &json!({"id": col.0, "csv": ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"})).unwrap();
assert!(label_paras(&s, col).iter().all(|(_, p)| p.kashida == Kashida::default()));
// Spacing that would be out of order in the kept attributes is refused, even when it is in order on the label.
let v = text_id(&s, col, "Axes", "4");
s.execute("select.set", &json!({"ids": [col.0]})).unwrap();
s.execute("text.setFormat", &json!({"wordSpacing": [60, 70, 80]})).unwrap();
s.execute("select.set", &json!({"ids": [v.0]})).unwrap();
s.execute("text.setFormat", &json!({"wordSpacing": [80, 100, 133], "start": 0, "end": 1})).unwrap();
assert!(s.execute("text.setFormat", &json!({"wordSpacing": {"min": 90}})).is_err());
let kept = spec(&s, col).label_styles.values_para.unwrap();
assert_eq!(kept.justification.word_spacing, [60.0, 70.0, 80.0]);
}
#[test]
fn a_legend_across_top_spaces_its_labels_by_their_kept_paragraph_settings() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
s.execute("graph.setType", &json!({"legendAcrossTop": true})).unwrap();
let l = text_id(&s, id, "Legend", "2024");
s.execute("select.set", &json!({"ids": [l.0]})).unwrap();
s.execute("text.setFormat", &json!({"leftIndent": 40})).unwrap();
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("graph.setData", &json!({"id": id.0, "csv": ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"})).unwrap();
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let label = group(&n, "Legend").children().unwrap().iter().find(|c| matches!(&c.kind, NodeKind::Text(t) if t.plain_text() == "2024")).unwrap();
let label = label.geometric_bounds().unwrap();
let next = series_parts(&s, id, 1).last().unwrap().geometric_bounds().unwrap();
assert!(next.x0 >= label.x1, "the next swatch starts after the indented label: {label:?} {next:?}");
}
#[test]
fn a_legend_label_with_a_kept_negative_indent_stays_clear_of_its_swatch() {
for across_top in [true, false] {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
s.execute("graph.setType", &json!({"legendAcrossTop": across_top})).unwrap();
let l = text_id(&s, id, "Legend", "2025");
s.execute("select.set", &json!({"ids": [l.0]})).unwrap();
s.execute("text.setFormat", &json!({"leftIndent": -40})).unwrap();
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("graph.setData", &json!({"id": id.0, "csv": ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"})).unwrap();
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let bounds = |text: &str| {
let label =
group(&n, "Legend").children().unwrap().iter().find(|c| matches!(&c.kind, NodeKind::Text(t) if t.plain_text() == text)).unwrap();
label.geometric_bounds().unwrap()
};
let (first, label) = (bounds("2024"), bounds("2025"));
let swatch = series_parts(&s, id, 1).last().unwrap().geometric_bounds().unwrap();
assert!(label.x0 >= swatch.x1, "the label starts after its swatch (across top: {across_top}): {swatch:?} {label:?}");
if across_top {
assert!(swatch.x0 >= first.x1, "the swatch starts after the label before it: {first:?} {swatch:?}");
}
}
}
#[test]
fn a_legend_makes_room_for_the_kept_leading_of_its_labels() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let csv = ",20|24,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6";
let id = NodeId(
s.execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"]
.as_u64()
.unwrap(),
);
let l = text_id(&s, id, "Legend", "20\n24");
s.execute("select.set", &json!({"ids": [l.0]})).unwrap();
s.execute("text.setFormat", &json!({"autoLeading": 400})).unwrap();
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap();
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let bounds = |text: &str| {
let label = group(&n, "Legend").children().unwrap().iter().find(|c| matches!(&c.kind, NodeKind::Text(t) if t.plain_text() == text)).unwrap();
label.geometric_bounds().unwrap()
};
let (first, next) = (bounds("20\n24"), bounds("2025"));
assert!(next.y0 >= first.y1, "the next legend label starts below the first one: {first:?} {next:?}");
}
#[test]
fn a_legend_label_with_kept_negative_space_before_stays_below_the_entry_above_it() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let csv = ",2024,20|25\nQ1,3,2\nQ2,5,4\nQ3,-1,6";
let id = NodeId(
s.execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"]
.as_u64()
.unwrap(),
);
let l = text_id(&s, id, "Legend", "20\n25");
s.execute("select.set", &json!({"ids": [l.0]})).unwrap();
s.execute("text.setFormat", &json!({"spaceBefore": -60})).unwrap();
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap();
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let bounds = |text: &str| {
let label = group(&n, "Legend").children().unwrap().iter().find(|c| matches!(&c.kind, NodeKind::Text(t) if t.plain_text() == text)).unwrap();
label.geometric_bounds().unwrap()
};
let (first, next) = (bounds("2024"), bounds("20\n25"));
assert!(next.y0 >= first.y1, "the second legend label starts below the first one: {first:?} {next:?}");
}
File diff suppressed because one or more lines are too long