From 1399c17c5a499970bf1319a3437474aa34c58308 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 14:23:42 -0800 Subject: [PATCH] Graphs: centered labels with a line break in their text stay centered A quoted CSV cell keeps its line breaks, and a category or series label draws each one as a new line, as it does a `|`. Labels beside an axis or in a wedge counted their lines by `|` alone, so a label with a line break was set off center, and with kept leading or paragraph spacing it was not moved back to its middle when the graph was drawn again. The label now counts lines in the text it lays out. Follow-up to #6574. --- crates/engine/src/cmd/graph/labels.rs | 14 +++++----- crates/engine/src/cmd/graph/tests.rs | 37 +++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/graph/labels.rs b/crates/engine/src/cmd/graph/labels.rs index 290e099500..8e56ad8ae6 100644 --- a/crates/engine/src/cmd/graph/labels.rs +++ b/crates/engine/src/cmd/graph/labels.rs @@ -149,20 +149,22 @@ impl Gen<'_> { refresh_bounds(&mut t); t } - /// A category or series label: a `|` in it starts a new line (Add graph labels). `middle`: the lines are centred - /// on `at` (labels beside an axis or in a wedge) instead of running down from it. + /// A category or series label: a `|` in it starts a new line (Add graph labels), as does a line break a quoted CSV + /// cell keeps. `middle`: the lines are centred on `at` (labels beside an axis or in a wedge) instead of running down + /// from it. pub(super) fn label(&mut self, role: Role, at: Point, s: &str, justify: Justify, middle: bool) -> Arc { - let lines = s.split('|').count(); + let text = s.replace('|', "\n"); + let lines = text.split('\n').count(); // Default leading puts the first and last baselines this far above and below `at`. let half = (lines - 1) as f64 * label_size(self.styles, role) * 1.2 / 2.0; let start = if middle { Point::new(at.x, at.y - half) } else { at }; - let mut t = self.text_object(role, start, &s.replace('|', "\n"), justify); + let mut t = self.text_object(role, start, &text, justify); // Kept leading or paragraph spacing moves the lines: put the middle of the first and last baselines on `at`. An // empty line has no baseline: an empty first or last line leaves the label where the default leading puts it. if middle && kept_para(self.styles, role).is_some() { let ys: Vec = t.baselines().map(|(a, _)| a.y).collect(); - let ends_set = s.split('|').next().is_some_and(|l| !l.is_empty()) && s.split('|').next_back().is_some_and(|l| !l.is_empty()); - let set = s.split('|').filter(|l| !l.is_empty()).count(); + let ends_set = text.split('\n').next().is_some_and(|l| !l.is_empty()) && text.split('\n').next_back().is_some_and(|l| !l.is_empty()); + let set = text.split('\n').filter(|l| !l.is_empty()).count(); if let (Some(first), Some(last), true) = (ys.first(), ys.last(), ends_set && ys.len() == set) { t.xf = Affine::translate((0.0, half - (first + last) / 2.0)) * t.xf; } diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index c160a78833..7ac88f85e2 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -6826,6 +6826,43 @@ fn a_centered_label_with_an_empty_middle_line_and_kept_leading_stays_centered() assert!((after - before).abs() < 0.01, "the three-line category label keeps its middle: {before} {after}"); } +#[test] +fn a_centered_label_with_a_line_break_in_its_text_is_set_as_with_a_bar() { + // A quoted CSV cell keeps its line breaks, so they reach the label as well as the `|` it is written with. + for (bar, quoted, text) in [("Q|1", "\"Q\n1\"", "Q\n1"), ("Q|||1", "\"Q\n||1\"", "Q\n\n\n1")] { + let label_middle = |s: &Session, id: NodeId| { + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let label = + group(&n, "Axes").children().unwrap().iter().find(|c| matches!(&c.kind, NodeKind::Text(t) if t.plain_text() == text)).unwrap(); + let b = label.geometric_bounds().unwrap(); + (b.y0 + b.y1) / 2.0 + }; + let drawn = |cat: &str, leading: bool| { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let csv = format!(",2024,2025\n{cat},3,2\nQ2,5,4\nQ3,-1,6"); + let id = NodeId( + s.execute("graph.create", &json!({"type": "bar", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"] + .as_u64() + .unwrap(), + ); + if leading { + let l = text_id(&s, id, "Axes", text); + 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(); + } + label_middle(&s, id) + }; + for leading in [false, true] { + let (by_bar, by_break) = (drawn(bar, leading), drawn(quoted, leading)); + assert!((by_bar - by_break).abs() < 0.01, "{text:?} sits where {bar:?} does (kept leading: {leading}): {by_bar} {by_break}"); + } + assert!((drawn(quoted, true) - drawn(quoted, false)).abs() < 0.01, "{text:?} keeps its middle with kept leading"); + } +} + #[test] fn a_legend_across_top_measures_its_labels_with_system_layout() { let mut s = Session::new();