Skip to main content

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/// Characters after which a hard break in [`wrap_text`] is preferred over a
56/// blind character-count cut — the separators a URL or path is built from
57/// (sipemu/ggplot-rs#264: a bare URL has no whitespace at all, so without
58/// this it fell back to slicing mid-word, e.g. `"eodatenzentr"` / `"um.de"`).
59const WRAP_HARD_BREAK_AFTER: [char; 7] = ['/', '.', '_', '-', '?', '&', '='];
60
61/// How many of `chars` (from the start) [`wrap_text`]'s hard-break should
62/// keep on the first of the two pieces it's about to split an overlong
63/// word into — width-based reduction from the end, then nudged back to
64/// just after the last `/ . _ - ? & =` separator that still leaves a
65/// non-trivial chunk (see [`wrap_text`]'s own doc comment). Pulled out so
66/// [`crate::geom::annotation_scale`]'s run-aware wrap (sipemu/ggplot-rs#267)
67/// can reuse the exact same decision instead of a second copy that could
68/// silently drift from this one. `chars` must be non-empty; a single char
69/// always keeps that one char (`chars.len()`), same as `wrap_text`'s own
70/// `chars.len() <= 1` bail-out.
71pub(crate) fn hard_break_cut(chars: &[char], max_width: f64, size: f64) -> usize {
72    if chars.len() <= 1 {
73        return chars.len();
74    }
75    let mut cut = chars.len() - 1;
76    while cut > 1 && text_width(&chars[..cut].iter().collect::<String>(), size) > max_width {
77        cut -= 1;
78    }
79    if let Some(break_at) = chars[..cut]
80        .iter()
81        .rposition(|c| WRAP_HARD_BREAK_AFTER.contains(c))
82    {
83        let candidate = break_at + 1;
84        if candidate > 0 && (candidate as f64) >= (cut as f64) / 3.0 {
85            cut = candidate;
86        }
87    }
88    cut
89}
90
91/// Word-wraps `text` to lines no wider than `max_width` px at font `size`,
92/// measured with [`text_width`] (ggtext / `str_wrap` behaviour: break at
93/// whitespace; a single word wider than `max_width` on its own is hard-split
94/// so it never overflows the box). Returns `vec![String::new()]` for empty
95/// input and otherwise always at least one line, so a caller can take
96/// `lines.len()` as the wrapped block's line count without a special case.
97///
98/// `max_width <= 0.0` (or non-finite) disables wrapping — each word still
99/// splits only when it alone would overflow a *positive* width, so a
100/// degenerate width never panics or infinite-loops; it returns `text`
101/// unsplit as a single line (matching "no wrap requested").
102pub fn wrap_text(text: &str, max_width: f64, size: f64) -> Vec<String> {
103    if text.is_empty() {
104        return vec![String::new()];
105    }
106    if !max_width.is_finite() || max_width <= 0.0 {
107        return vec![text.to_string()];
108    }
109    let mut lines: Vec<String> = Vec::new();
110    let mut cur = String::new();
111    for word in text.split_whitespace() {
112        let mut word = word.to_string();
113        // A single word that alone overflows max_width: hard-break it by
114        // character rather than let it overflow the box.
115        while text_width(&word, size) > max_width {
116            let chars: Vec<char> = word.chars().collect();
117            if chars.len() <= 1 {
118                break;
119            }
120            let cut = hard_break_cut(&chars, max_width, size);
121            if !cur.is_empty() {
122                lines.push(std::mem::take(&mut cur));
123            }
124            lines.push(chars[..cut].iter().collect());
125            word = chars[cut..].iter().collect();
126        }
127        if cur.is_empty() {
128            cur = word;
129        } else {
130            let candidate = format!("{cur} {word}");
131            if text_width(&candidate, size) <= max_width {
132                cur = candidate;
133            } else {
134                lines.push(std::mem::replace(&mut cur, word));
135            }
136        }
137    }
138    if !cur.is_empty() || lines.is_empty() {
139        lines.push(cur);
140    }
141    lines
142}
143
144/// The widest wrapped line's width (px) at font `size` — the natural width
145/// of a wrapped text block, e.g. for a [`GeomLabel`](crate::geom::text::GeomLabel)
146/// background box sized to fit its wrapped text.
147pub fn wrapped_block_width(lines: &[String], size: f64) -> f64 {
148    lines
149        .iter()
150        .map(|l| text_width(l, size))
151        .fold(0.0, f64::max)
152}
153
154/// Estimated bounding box `[x0, y0, x1, y1]` of a label anchored at
155/// `(x, y)`, set at font `size`, rotated `angle` degrees (SVG convention,
156/// clockwise), with `anchor` in `0.0` (text starts at `x`) `..=1.0` (text
157/// ends at `x`) — `0.5` centres it, matching `TextAnchor::{Start,Middle,
158/// End}`. Ported from the consumer's `TickLabel::bbox`.
159pub(crate) fn label_bbox(
160    text: &str,
161    size: f64,
162    x: f64,
163    y: f64,
164    angle: f64,
165    anchor: f64,
166) -> [f64; 4] {
167    let w = text_width(text, size);
168    let h = size * 1.2;
169    let x0 = -w * anchor;
170    let (s, c) = angle.to_radians().sin_cos();
171    let mut b = [
172        f64::INFINITY,
173        f64::INFINITY,
174        f64::NEG_INFINITY,
175        f64::NEG_INFINITY,
176    ];
177    for (dx, dy) in [
178        (x0, -h / 2.0),
179        (x0 + w, -h / 2.0),
180        (x0, h / 2.0),
181        (x0 + w, h / 2.0),
182    ] {
183        let (px, py) = (x + dx * c - dy * s, y + dx * s + dy * c);
184        b = [b[0].min(px), b[1].min(py), b[2].max(px), b[3].max(py)];
185    }
186    b
187}
188
189/// For rotated bottom-axis tick labels, the extra vertical *rise* (above
190/// the un-rotated anchor point) and *fall* (below it) the rotated bounding
191/// boxes need, maxed over `labels` (sipemu/ggplot-rs#162). A label
192/// end-anchored at a point just below the panel and rotated toward the
193/// panel (e.g. the usual 45° "reads up-and-to-the-right" tilt) swings its
194/// far end — `rise` — back up by roughly `width · sin(angle)`; a label
195/// rotated away from the panel (e.g. 270°, hanging straight down) has no
196/// rise at all, only `fall`.
197///
198/// Both `GGPlot::layout_built` (to reserve enough panel-bottom margin that
199/// `fall` stays on-canvas) and `draw_x_axis` (to place the rotation anchor
200/// `rise` further from the axis line than the flat-label offset, so the
201/// rotated label never swings back over the panel) need the *same*
202/// numbers — this is their one shared source, so the two can never drift
203/// apart.
204pub(crate) fn rotated_label_rise_fall<'a>(
205    labels: impl Iterator<Item = &'a str>,
206    size: f64,
207    angle: f64,
208    anchor: f64,
209) -> (f64, f64) {
210    let mut rise = 0.0_f64;
211    let mut fall = 0.0_f64;
212    for label in labels {
213        let b = label_bbox(label, size, 0.0, 0.0, angle, anchor);
214        rise = rise.max(-b[1]);
215        fall = fall.max(b[3]);
216    }
217    (rise.max(0.0), fall.max(0.0))
218}
219
220/// Which end of a rotated axis-tick label should pin to the tick
221/// (sipemu/ggplot-rs#163): `1.0` (`TextAnchor::End`, the label's *last*
222/// character at the tick) or `0.0` (`TextAnchor::Start`, its *first*
223/// character at the tick) — whichever keeps the label's *far* end swinging
224/// away from the panel instead of into it.
225///
226/// `dir` is `draw_x_axis`'s own tick direction: `+1.0` for a bottom axis
227/// (the panel sits above the axis line), `-1.0` for a top axis (the panel
228/// sits below it). The right anchor depends on *both* the rotation's
229/// direction and which side the panel is on — not the angle alone: a
230/// counter-clockwise (SVG-negative) angle swings its far end away from the
231/// panel when its *last* character is pinned, when the panel is above
232/// (bottom axis); a clockwise (SVG-positive) angle needs the opposite —
233/// its *first* character pinned — for the same effect. Both flip for a top
234/// axis, where "away from the panel" points the other way. Concretely:
235/// `End` exactly when `dir * sin(angle) < 0`.
236///
237/// Picking the wrong one for a given sign pushes the whole label away from
238/// the tick instead (0.22.1's bug, on a bottom axis): the tick-anchored
239/// end stays near the tick but the free end swings into the previous
240/// category's space (or, with the direction only half-fixed, the anchor
241/// itself gets pushed far from the tick to clear the panel on a top axis)
242/// instead of the near-axis end simply staying at the tick either way.
243///
244/// Only meaningful when the caller already knows the label is rotated
245/// (`angle.abs() > 10.0`); the flat case uses `0.5` (`TextAnchor::Middle`)
246/// unconditionally, never this function.
247pub(crate) fn rotated_anchor_frac(angle: f64, dir: f64) -> f64 {
248    if dir * angle.to_radians().sin() < 0.0 {
249        1.0
250    } else {
251        0.0
252    }
253}
254
255/// ggplot2's `check.overlap` draw order (its internal `label_priority`):
256/// always keep the first and last breaks, then recursively bisect the
257/// remaining range (midpoint first, then its two halves), so thinning drops
258/// the *most crowded middle* first rather than every other label. R parity
259/// (ggplot2 4.0.3): `axis_label_priority(5)` is `1 5 3 2 4`;
260/// `axis_label_priority(10)` is `1 10 5 3 2 4 7 6 8 9` (1-indexed).
261pub(crate) fn axis_label_priority(n: usize) -> Vec<usize> {
262    if n == 0 {
263        return Vec::new();
264    }
265    if n == 1 {
266        return vec![1];
267    }
268    let mut out = vec![1, n];
269    bisect(2, n - 1, &mut out);
270    out
271}
272
273/// 1-indexed inclusive range `[lo, hi]`: midpoint first (floor division),
274/// then the left half, then the right half.
275fn bisect(lo: usize, hi: usize, out: &mut Vec<usize>) {
276    if lo > hi {
277        return;
278    }
279    let mid = (lo + hi) / 2;
280    out.push(mid);
281    if mid > lo {
282        bisect(lo, mid - 1, out);
283    }
284    bisect(mid + 1, hi, out);
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290
291    #[test]
292    fn priority_matches_r_ggplot2_4_0_3() {
293        assert_eq!(axis_label_priority(5), vec![1, 5, 3, 2, 4]);
294        assert_eq!(axis_label_priority(10), vec![1, 10, 5, 3, 2, 4, 7, 6, 8, 9]);
295    }
296
297    #[test]
298    fn priority_edge_cases() {
299        assert_eq!(axis_label_priority(0), Vec::<usize>::new());
300        assert_eq!(axis_label_priority(1), vec![1]);
301        assert_eq!(axis_label_priority(2), vec![1, 2]);
302    }
303
304    #[test]
305    fn truncate_to_width_keeps_short_labels_untouched() {
306        assert_eq!(truncate_to_width("ab", 1000.0, 10.0), None);
307    }
308
309    #[test]
310    fn truncate_to_width_shortens_with_an_ellipsis() {
311        let full = "a very long legend label indeed";
312        let w = text_width(full, 10.0);
313        let truncated = truncate_to_width(full, w / 2.0, 10.0).expect("should truncate");
314        assert!(truncated.ends_with('…'));
315        assert!(text_width(&truncated, 10.0) <= w / 2.0);
316        assert!(truncated.chars().count() < full.chars().count());
317    }
318
319    #[test]
320    fn truncate_to_width_falls_back_to_a_bare_ellipsis() {
321        // Even "…" alone doesn't fit a near-zero width — still the best
322        // truncation can do, not an empty string.
323        assert_eq!(
324            truncate_to_width("hello", 0.01, 10.0),
325            Some("…".to_string())
326        );
327    }
328
329    #[test]
330    fn text_width_matches_consumer_em_table() {
331        assert_eq!(text_width("", 10.0), 0.0);
332        assert!((text_width("i", 10.0) - 2.7).abs() < 1e-9);
333        assert!((text_width("M", 10.0) - 8.6).abs() < 1e-9);
334    }
335
336    #[test]
337    fn wrap_text_breaks_at_whitespace_within_width() {
338        let lines = wrap_text("alpha beta gamma delta", 1000.0, 10.0);
339        assert_eq!(lines, vec!["alpha beta gamma delta".to_string()]);
340    }
341
342    #[test]
343    fn wrap_text_wraps_to_multiple_lines_when_narrow() {
344        let w = text_width("alpha beta", 10.0);
345        let lines = wrap_text("alpha beta gamma delta", w + 1.0, 10.0);
346        assert!(lines.len() >= 2, "expected wrapping, got {lines:?}");
347        for l in &lines {
348            assert!(
349                text_width(l, 10.0) <= w + 1.0 + 1e-6,
350                "line {l:?} overflows width"
351            );
352        }
353        assert_eq!(lines.join(" "), "alpha beta gamma delta");
354    }
355
356    #[test]
357    fn wrap_text_hard_breaks_a_single_overlong_word() {
358        let lines = wrap_text("supercalifragilisticexpialidocious", 40.0, 10.0);
359        assert!(lines.len() > 1);
360        assert_eq!(lines.concat(), "supercalifragilisticexpialidocious");
361        for l in &lines {
362            assert!(text_width(l, 10.0) <= 40.0 + 1e-6);
363        }
364    }
365
366    #[test]
367    fn wrap_text_hard_break_flushes_a_pending_line_first() {
368        // "ab" fits its own line (`cur` is non-empty) before the second
369        // word triggers a hard break — the hard-break path must flush
370        // `cur` as its own line rather than lose or merge it.
371        let lines = wrap_text("ab supercalifragilisticexpialidocious", 40.0, 10.0);
372        assert_eq!(lines[0], "ab");
373        assert!(lines.len() > 2, "{lines:?}");
374        assert_eq!(lines[1..].concat(), "supercalifragilisticexpialidocious");
375        for l in &lines {
376            assert!(text_width(l, 10.0) <= 40.0 + 1e-6);
377        }
378    }
379
380    #[test]
381    fn wrap_text_prefers_url_separators_over_a_blind_char_cut() {
382        // A bare URL (no whitespace at all, #264's repro) should break
383        // after `/ . _ - ? & =`, not wherever a raw character-count cut
384        // happens to land.
385        let url =
386            "https://sgx.geodatenzentrum.de/web_public/gdz/datenquellen/datenquellen_vg_nuts.pdf";
387        let lines = wrap_text(url, 80.0, 7.0);
388        assert!(lines.len() > 1, "expected wrapping, got {lines:?}");
389        assert_eq!(lines.concat(), url);
390        for l in &lines {
391            assert!(
392                text_width(l, 7.0) <= 80.0 + 1e-6,
393                "line {l:?} overflows width"
394            );
395        }
396        // Every line but the last should end right after one of the
397        // preferred separators, not mid-token.
398        for l in &lines[..lines.len() - 1] {
399            let last = l.chars().last().unwrap();
400            assert!(
401                WRAP_HARD_BREAK_AFTER.contains(&last),
402                "line {l:?} did not break after a preferred separator"
403            );
404        }
405    }
406
407    #[test]
408    fn wrap_text_still_hard_breaks_when_no_separator_exists() {
409        // No `/ . _ - ? & =` anywhere: falls back to the plain
410        // character-count cut, same as before #264.
411        let lines = wrap_text("supercalifragilisticexpialidocious", 40.0, 10.0);
412        assert!(lines.len() > 1);
413        assert_eq!(lines.concat(), "supercalifragilisticexpialidocious");
414    }
415
416    #[test]
417    fn wrap_text_ignores_a_separator_too_close_to_the_start() {
418        // A single `-` right after the first character shouldn't force a
419        // one-character line; the width-based cut should stand instead.
420        let lines = wrap_text("a-supercalifragilisticexpialidocious", 40.0, 10.0);
421        assert_ne!(lines[0], "a-", "separator too close to start was used");
422        assert_eq!(lines.concat(), "a-supercalifragilisticexpialidocious");
423    }
424
425    #[test]
426    fn wrap_text_width_too_narrow_for_even_one_character_still_terminates() {
427        // A width narrower than any single character: hard-breaking can't
428        // cut further (`chars.len() <= 1`), so it gives up and keeps that
429        // one character whole rather than looping forever.
430        let lines = wrap_text("i", 0.5, 10.0);
431        assert_eq!(lines, vec!["i".to_string()]);
432    }
433
434    #[test]
435    fn wrap_text_empty_input_is_one_empty_line() {
436        assert_eq!(wrap_text("", 100.0, 10.0), vec![String::new()]);
437    }
438
439    #[test]
440    fn wrap_text_non_positive_width_disables_wrapping() {
441        assert_eq!(
442            wrap_text("alpha beta gamma", 0.0, 10.0),
443            vec!["alpha beta gamma".to_string()]
444        );
445        assert_eq!(
446            wrap_text("alpha beta gamma", -5.0, 10.0),
447            vec!["alpha beta gamma".to_string()]
448        );
449    }
450
451    #[test]
452    fn wrapped_block_width_is_the_widest_line() {
453        let lines = vec!["short".to_string(), "a longer line here".to_string()];
454        let w = wrapped_block_width(&lines, 10.0);
455        assert_eq!(w, text_width("a longer line here", 10.0));
456    }
457
458    #[test]
459    fn label_bbox_unrotated_middle_anchor() {
460        let b = label_bbox("ab", 10.0, 100.0, 50.0, 0.0, 0.5);
461        let w = text_width("ab", 10.0);
462        assert!((b[0] - (100.0 - w / 2.0)).abs() < 1e-9);
463        assert!((b[2] - (100.0 + w / 2.0)).abs() < 1e-9);
464        assert!((b[1] - (50.0 - 6.0)).abs() < 1e-9);
465        assert!((b[3] - (50.0 + 6.0)).abs() < 1e-9);
466    }
467}