mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-11 18:54:55 +00:00
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:
+21
-3
@@ -136,9 +136,11 @@ impl crate::Document {
|
||||
}
|
||||
}
|
||||
|
||||
/// The character style a graph's labels keep through regeneration once formatted (Format the text in graphs): the
|
||||
/// value axis numbers, the category labels (category axis, radar spokes, pie names) and the legend labels (beside or
|
||||
/// across the top, or in pie wedges). `None` draws the default 9 pt black.
|
||||
/// The character style and anti-aliasing method a graph's labels keep through regeneration once formatted (Format
|
||||
/// the text in graphs): the value axis numbers, the category labels (category axis, radar spokes, pie names) and the
|
||||
/// legend labels (beside or across the top, or in pie wedges). `None` draws the default 9 pt black. The `*_para`
|
||||
/// fields are the paragraph attributes each kind keeps the same way (`None`: the defaults); the graph still sets each
|
||||
/// label's alignment.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct LabelStyles {
|
||||
@@ -148,6 +150,22 @@ pub struct LabelStyles {
|
||||
pub categories: Option<crate::CharStyle>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub legend: Option<crate::CharStyle>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub values_para: Option<crate::ParaStyle>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub categories_para: Option<crate::ParaStyle>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub legend_para: Option<crate::ParaStyle>,
|
||||
/// The anti-aliasing method the value axis numbers keep once set (Character panel › Anti-aliasing Method).
|
||||
/// `None` draws them Sharp.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub values_anti_alias: Option<crate::text::TypeAntiAlias>,
|
||||
/// The anti-aliasing method the category labels keep once set. `None` draws them Sharp.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub categories_anti_alias: Option<crate::text::TypeAntiAlias>,
|
||||
/// The anti-aliasing method the legend labels keep once set. `None` draws them Sharp.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub legend_anti_alias: Option<crate::text::TypeAntiAlias>,
|
||||
}
|
||||
|
||||
impl LabelStyles {
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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(¶);
|
||||
*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>> {
|
||||
|
||||
@@ -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 ¶s {
|
||||
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
Reference in New Issue
Block a user