ggplot_rs/render/text.rs
1//! Text-metric helpers for axis-label fit (B3-1): ggplot-rs owns the
2//! outer-label-overflow and `check.overlap` logic centrally, instead of a
3//! downstream consumer post-processing rendered SVG (the old approach had
4//! to re-parse `<text>` elements out of the markup).
5//!
6//! Ported verbatim from the consumer's `ticks.rs` em table and
7//! `TickLabel::bbox`, which is the only reason the measurements match what
8//! callers previously saw.
9
10/// Estimated width (px) of `text` set at font size `size`, in the house sans
11/// (Hanken Grotesk, or the system UI font it falls back to). This is a
12/// heuristic per-character average-width table, not real font metrics —
13/// good enough for layout decisions (fit, overlap, auto-wrap), not for
14/// pixel-perfect text extents.
15pub fn text_width(text: &str, size: f64) -> f64 {
16 let em: f64 = text
17 .chars()
18 .map(|ch| match ch {
19 ' ' => 0.27,
20 '.' | ',' | ':' | ';' | '\'' | '|' | '!' | 'i' | 'j' | 'l' | 'I' => 0.27,
21 'f' | 't' | 'r' | '(' | ')' | '[' | ']' | '-' | '/' => 0.36,
22 '0'..='9' | '$' | '€' | '£' | '+' | '=' | '<' | '>' | '#' | '*' | '?' => 0.57,
23 'm' | 'w' => 0.8,
24 'M' | 'W' | '%' | '@' => 0.86,
25 'a'..='z' => 0.53,
26 'A'..='Z' | '&' => 0.66,
27 _ => 0.62,
28 })
29 .sum();
30 em * size
31}
32
33/// Shorten `label` with a trailing ellipsis ("…") until it (plus the
34/// ellipsis) fits within `max_width` px at font `size` (#159: a vertical
35/// multi-column legend grid that still overflows after exhausting column
36/// reduction falls back to truncating its labels). `None` means `label`
37/// already fits — no truncation needed, so the caller keeps the
38/// byte-identical untruncated path. A `max_width` too narrow for even a
39/// bare "…" still returns `Some("…")` — the best any truncation can do —
40/// rather than an empty string.
41pub fn truncate_to_width(label: &str, max_width: f64, size: f64) -> Option<String> {
42 if text_width(label, size) <= max_width {
43 return None;
44 }
45 let chars: Vec<char> = label.chars().collect();
46 for keep in (0..chars.len()).rev() {
47 let candidate: String = chars[..keep].iter().collect::<String>() + "…";
48 if text_width(&candidate, size) <= max_width {
49 return Some(candidate);
50 }
51 }
52 Some("…".to_string())
53}
54
55/// Word-wraps `text` to lines no wider than `max_width` px at font `size`,
56/// measured with [`text_width`] (ggtext / `str_wrap` behaviour: break at
57/// whitespace; a single word wider than `max_width` on its own is hard-split
58/// so it never overflows the box). Returns `vec![String::new()]` for empty
59/// input and otherwise always at least one line, so a caller can take
60/// `lines.len()` as the wrapped block's line count without a special case.
61///
62/// `max_width <= 0.0` (or non-finite) disables wrapping — each word still
63/// splits only when it alone would overflow a *positive* width, so a
64/// degenerate width never panics or infinite-loops; it returns `text`
65/// unsplit as a single line (matching "no wrap requested").
66pub fn wrap_text(text: &str, max_width: f64, size: f64) -> Vec<String> {
67 if text.is_empty() {
68 return vec![String::new()];
69 }
70 if !max_width.is_finite() || max_width <= 0.0 {
71 return vec![text.to_string()];
72 }
73 let mut lines: Vec<String> = Vec::new();
74 let mut cur = String::new();
75 for word in text.split_whitespace() {
76 let mut word = word.to_string();
77 // A single word that alone overflows max_width: hard-break it by
78 // character rather than let it overflow the box.
79 while text_width(&word, size) > max_width {
80 let chars: Vec<char> = word.chars().collect();
81 if chars.len() <= 1 {
82 break;
83 }
84 let mut cut = chars.len() - 1;
85 while cut > 1 && text_width(&chars[..cut].iter().collect::<String>(), size) > max_width
86 {
87 cut -= 1;
88 }
89 if !cur.is_empty() {
90 lines.push(std::mem::take(&mut cur));
91 }
92 lines.push(chars[..cut].iter().collect());
93 word = chars[cut..].iter().collect();
94 }
95 if cur.is_empty() {
96 cur = word;
97 } else {
98 let candidate = format!("{cur} {word}");
99 if text_width(&candidate, size) <= max_width {
100 cur = candidate;
101 } else {
102 lines.push(std::mem::replace(&mut cur, word));
103 }
104 }
105 }
106 if !cur.is_empty() || lines.is_empty() {
107 lines.push(cur);
108 }
109 lines
110}
111
112/// The widest wrapped line's width (px) at font `size` — the natural width
113/// of a wrapped text block, e.g. for a [`GeomLabel`](crate::geom::text::GeomLabel)
114/// background box sized to fit its wrapped text.
115pub fn wrapped_block_width(lines: &[String], size: f64) -> f64 {
116 lines
117 .iter()
118 .map(|l| text_width(l, size))
119 .fold(0.0, f64::max)
120}
121
122/// Estimated bounding box `[x0, y0, x1, y1]` of a label anchored at
123/// `(x, y)`, set at font `size`, rotated `angle` degrees (SVG convention,
124/// clockwise), with `anchor` in `0.0` (text starts at `x`) `..=1.0` (text
125/// ends at `x`) — `0.5` centres it, matching `TextAnchor::{Start,Middle,
126/// End}`. Ported from the consumer's `TickLabel::bbox`.
127pub(crate) fn label_bbox(
128 text: &str,
129 size: f64,
130 x: f64,
131 y: f64,
132 angle: f64,
133 anchor: f64,
134) -> [f64; 4] {
135 let w = text_width(text, size);
136 let h = size * 1.2;
137 let x0 = -w * anchor;
138 let (s, c) = angle.to_radians().sin_cos();
139 let mut b = [
140 f64::INFINITY,
141 f64::INFINITY,
142 f64::NEG_INFINITY,
143 f64::NEG_INFINITY,
144 ];
145 for (dx, dy) in [
146 (x0, -h / 2.0),
147 (x0 + w, -h / 2.0),
148 (x0, h / 2.0),
149 (x0 + w, h / 2.0),
150 ] {
151 let (px, py) = (x + dx * c - dy * s, y + dx * s + dy * c);
152 b = [b[0].min(px), b[1].min(py), b[2].max(px), b[3].max(py)];
153 }
154 b
155}
156
157/// For rotated bottom-axis tick labels, the extra vertical *rise* (above
158/// the un-rotated anchor point) and *fall* (below it) the rotated bounding
159/// boxes need, maxed over `labels` (sipemu/ggplot-rs#162). A label
160/// end-anchored at a point just below the panel and rotated toward the
161/// panel (e.g. the usual 45° "reads up-and-to-the-right" tilt) swings its
162/// far end — `rise` — back up by roughly `width · sin(angle)`; a label
163/// rotated away from the panel (e.g. 270°, hanging straight down) has no
164/// rise at all, only `fall`.
165///
166/// Both `GGPlot::layout_built` (to reserve enough panel-bottom margin that
167/// `fall` stays on-canvas) and `draw_x_axis` (to place the rotation anchor
168/// `rise` further from the axis line than the flat-label offset, so the
169/// rotated label never swings back over the panel) need the *same*
170/// numbers — this is their one shared source, so the two can never drift
171/// apart.
172pub(crate) fn rotated_label_rise_fall<'a>(
173 labels: impl Iterator<Item = &'a str>,
174 size: f64,
175 angle: f64,
176 anchor: f64,
177) -> (f64, f64) {
178 let mut rise = 0.0_f64;
179 let mut fall = 0.0_f64;
180 for label in labels {
181 let b = label_bbox(label, size, 0.0, 0.0, angle, anchor);
182 rise = rise.max(-b[1]);
183 fall = fall.max(b[3]);
184 }
185 (rise.max(0.0), fall.max(0.0))
186}
187
188/// Which end of a rotated axis-tick label should pin to the tick
189/// (sipemu/ggplot-rs#163): `1.0` (`TextAnchor::End`, the label's *last*
190/// character at the tick) or `0.0` (`TextAnchor::Start`, its *first*
191/// character at the tick) — whichever keeps the label's *far* end swinging
192/// away from the panel instead of into it.
193///
194/// `dir` is `draw_x_axis`'s own tick direction: `+1.0` for a bottom axis
195/// (the panel sits above the axis line), `-1.0` for a top axis (the panel
196/// sits below it). The right anchor depends on *both* the rotation's
197/// direction and which side the panel is on — not the angle alone: a
198/// counter-clockwise (SVG-negative) angle swings its far end away from the
199/// panel when its *last* character is pinned, when the panel is above
200/// (bottom axis); a clockwise (SVG-positive) angle needs the opposite —
201/// its *first* character pinned — for the same effect. Both flip for a top
202/// axis, where "away from the panel" points the other way. Concretely:
203/// `End` exactly when `dir * sin(angle) < 0`.
204///
205/// Picking the wrong one for a given sign pushes the whole label away from
206/// the tick instead (0.22.1's bug, on a bottom axis): the tick-anchored
207/// end stays near the tick but the free end swings into the previous
208/// category's space (or, with the direction only half-fixed, the anchor
209/// itself gets pushed far from the tick to clear the panel on a top axis)
210/// instead of the near-axis end simply staying at the tick either way.
211///
212/// Only meaningful when the caller already knows the label is rotated
213/// (`angle.abs() > 10.0`); the flat case uses `0.5` (`TextAnchor::Middle`)
214/// unconditionally, never this function.
215pub(crate) fn rotated_anchor_frac(angle: f64, dir: f64) -> f64 {
216 if dir * angle.to_radians().sin() < 0.0 {
217 1.0
218 } else {
219 0.0
220 }
221}
222
223/// ggplot2's `check.overlap` draw order (its internal `label_priority`):
224/// always keep the first and last breaks, then recursively bisect the
225/// remaining range (midpoint first, then its two halves), so thinning drops
226/// the *most crowded middle* first rather than every other label. R parity
227/// (ggplot2 4.0.3): `axis_label_priority(5)` is `1 5 3 2 4`;
228/// `axis_label_priority(10)` is `1 10 5 3 2 4 7 6 8 9` (1-indexed).
229pub(crate) fn axis_label_priority(n: usize) -> Vec<usize> {
230 if n == 0 {
231 return Vec::new();
232 }
233 if n == 1 {
234 return vec![1];
235 }
236 let mut out = vec![1, n];
237 bisect(2, n - 1, &mut out);
238 out
239}
240
241/// 1-indexed inclusive range `[lo, hi]`: midpoint first (floor division),
242/// then the left half, then the right half.
243fn bisect(lo: usize, hi: usize, out: &mut Vec<usize>) {
244 if lo > hi {
245 return;
246 }
247 let mid = (lo + hi) / 2;
248 out.push(mid);
249 if mid > lo {
250 bisect(lo, mid - 1, out);
251 }
252 bisect(mid + 1, hi, out);
253}
254
255#[cfg(test)]
256mod tests {
257 use super::*;
258
259 #[test]
260 fn priority_matches_r_ggplot2_4_0_3() {
261 assert_eq!(axis_label_priority(5), vec![1, 5, 3, 2, 4]);
262 assert_eq!(axis_label_priority(10), vec![1, 10, 5, 3, 2, 4, 7, 6, 8, 9]);
263 }
264
265 #[test]
266 fn priority_edge_cases() {
267 assert_eq!(axis_label_priority(0), Vec::<usize>::new());
268 assert_eq!(axis_label_priority(1), vec![1]);
269 assert_eq!(axis_label_priority(2), vec![1, 2]);
270 }
271
272 #[test]
273 fn truncate_to_width_keeps_short_labels_untouched() {
274 assert_eq!(truncate_to_width("ab", 1000.0, 10.0), None);
275 }
276
277 #[test]
278 fn truncate_to_width_shortens_with_an_ellipsis() {
279 let full = "a very long legend label indeed";
280 let w = text_width(full, 10.0);
281 let truncated = truncate_to_width(full, w / 2.0, 10.0).expect("should truncate");
282 assert!(truncated.ends_with('…'));
283 assert!(text_width(&truncated, 10.0) <= w / 2.0);
284 assert!(truncated.chars().count() < full.chars().count());
285 }
286
287 #[test]
288 fn truncate_to_width_falls_back_to_a_bare_ellipsis() {
289 // Even "…" alone doesn't fit a near-zero width — still the best
290 // truncation can do, not an empty string.
291 assert_eq!(
292 truncate_to_width("hello", 0.01, 10.0),
293 Some("…".to_string())
294 );
295 }
296
297 #[test]
298 fn text_width_matches_consumer_em_table() {
299 assert_eq!(text_width("", 10.0), 0.0);
300 assert!((text_width("i", 10.0) - 2.7).abs() < 1e-9);
301 assert!((text_width("M", 10.0) - 8.6).abs() < 1e-9);
302 }
303
304 #[test]
305 fn wrap_text_breaks_at_whitespace_within_width() {
306 let lines = wrap_text("alpha beta gamma delta", 1000.0, 10.0);
307 assert_eq!(lines, vec!["alpha beta gamma delta".to_string()]);
308 }
309
310 #[test]
311 fn wrap_text_wraps_to_multiple_lines_when_narrow() {
312 let w = text_width("alpha beta", 10.0);
313 let lines = wrap_text("alpha beta gamma delta", w + 1.0, 10.0);
314 assert!(lines.len() >= 2, "expected wrapping, got {lines:?}");
315 for l in &lines {
316 assert!(
317 text_width(l, 10.0) <= w + 1.0 + 1e-6,
318 "line {l:?} overflows width"
319 );
320 }
321 assert_eq!(lines.join(" "), "alpha beta gamma delta");
322 }
323
324 #[test]
325 fn wrap_text_hard_breaks_a_single_overlong_word() {
326 let lines = wrap_text("supercalifragilisticexpialidocious", 40.0, 10.0);
327 assert!(lines.len() > 1);
328 assert_eq!(lines.concat(), "supercalifragilisticexpialidocious");
329 for l in &lines {
330 assert!(text_width(l, 10.0) <= 40.0 + 1e-6);
331 }
332 }
333
334 #[test]
335 fn wrap_text_hard_break_flushes_a_pending_line_first() {
336 // "ab" fits its own line (`cur` is non-empty) before the second
337 // word triggers a hard break — the hard-break path must flush
338 // `cur` as its own line rather than lose or merge it.
339 let lines = wrap_text("ab supercalifragilisticexpialidocious", 40.0, 10.0);
340 assert_eq!(lines[0], "ab");
341 assert!(lines.len() > 2, "{lines:?}");
342 assert_eq!(lines[1..].concat(), "supercalifragilisticexpialidocious");
343 for l in &lines {
344 assert!(text_width(l, 10.0) <= 40.0 + 1e-6);
345 }
346 }
347
348 #[test]
349 fn wrap_text_width_too_narrow_for_even_one_character_still_terminates() {
350 // A width narrower than any single character: hard-breaking can't
351 // cut further (`chars.len() <= 1`), so it gives up and keeps that
352 // one character whole rather than looping forever.
353 let lines = wrap_text("i", 0.5, 10.0);
354 assert_eq!(lines, vec!["i".to_string()]);
355 }
356
357 #[test]
358 fn wrap_text_empty_input_is_one_empty_line() {
359 assert_eq!(wrap_text("", 100.0, 10.0), vec![String::new()]);
360 }
361
362 #[test]
363 fn wrap_text_non_positive_width_disables_wrapping() {
364 assert_eq!(
365 wrap_text("alpha beta gamma", 0.0, 10.0),
366 vec!["alpha beta gamma".to_string()]
367 );
368 assert_eq!(
369 wrap_text("alpha beta gamma", -5.0, 10.0),
370 vec!["alpha beta gamma".to_string()]
371 );
372 }
373
374 #[test]
375 fn wrapped_block_width_is_the_widest_line() {
376 let lines = vec!["short".to_string(), "a longer line here".to_string()];
377 let w = wrapped_block_width(&lines, 10.0);
378 assert_eq!(w, text_width("a longer line here", 10.0));
379 }
380
381 #[test]
382 fn label_bbox_unrotated_middle_anchor() {
383 let b = label_bbox("ab", 10.0, 100.0, 50.0, 0.0, 0.5);
384 let w = text_width("ab", 10.0);
385 assert!((b[0] - (100.0 - w / 2.0)).abs() < 1e-9);
386 assert!((b[2] - (100.0 + w / 2.0)).abs() < 1e-9);
387 assert!((b[1] - (50.0 - 6.0)).abs() < 1e-9);
388 assert!((b[3] - (50.0 + 6.0)).abs() < 1e-9);
389 }
390}