pub fn text_width(text: &str, size: f64) -> f64 {
let em: f64 = text
.chars()
.map(|ch| match ch {
' ' => 0.27,
'.' | ',' | ':' | ';' | '\'' | '|' | '!' | 'i' | 'j' | 'l' | 'I' => 0.27,
'f' | 't' | 'r' | '(' | ')' | '[' | ']' | '-' | '/' => 0.36,
'0'..='9' | '$' | '€' | '£' | '+' | '=' | '<' | '>' | '#' | '*' | '?' => 0.57,
'm' | 'w' => 0.8,
'M' | 'W' | '%' | '@' => 0.86,
'a'..='z' => 0.53,
'A'..='Z' | '&' => 0.66,
_ => 0.62,
})
.sum();
em * size
}
pub fn truncate_to_width(label: &str, max_width: f64, size: f64) -> Option<String> {
if text_width(label, size) <= max_width {
return None;
}
let chars: Vec<char> = label.chars().collect();
for keep in (0..chars.len()).rev() {
let candidate: String = chars[..keep].iter().collect::<String>() + "…";
if text_width(&candidate, size) <= max_width {
return Some(candidate);
}
}
Some("…".to_string())
}
pub fn wrap_text(text: &str, max_width: f64, size: f64) -> Vec<String> {
if text.is_empty() {
return vec![String::new()];
}
if !max_width.is_finite() || max_width <= 0.0 {
return vec![text.to_string()];
}
let mut lines: Vec<String> = Vec::new();
let mut cur = String::new();
for word in text.split_whitespace() {
let mut word = word.to_string();
while text_width(&word, size) > max_width {
let chars: Vec<char> = word.chars().collect();
if chars.len() <= 1 {
break;
}
let mut cut = chars.len() - 1;
while cut > 1 && text_width(&chars[..cut].iter().collect::<String>(), size) > max_width
{
cut -= 1;
}
if !cur.is_empty() {
lines.push(std::mem::take(&mut cur));
}
lines.push(chars[..cut].iter().collect());
word = chars[cut..].iter().collect();
}
if cur.is_empty() {
cur = word;
} else {
let candidate = format!("{cur} {word}");
if text_width(&candidate, size) <= max_width {
cur = candidate;
} else {
lines.push(std::mem::replace(&mut cur, word));
}
}
}
if !cur.is_empty() || lines.is_empty() {
lines.push(cur);
}
lines
}
pub fn wrapped_block_width(lines: &[String], size: f64) -> f64 {
lines
.iter()
.map(|l| text_width(l, size))
.fold(0.0, f64::max)
}
pub(crate) fn label_bbox(
text: &str,
size: f64,
x: f64,
y: f64,
angle: f64,
anchor: f64,
) -> [f64; 4] {
let w = text_width(text, size);
let h = size * 1.2;
let x0 = -w * anchor;
let (s, c) = angle.to_radians().sin_cos();
let mut b = [
f64::INFINITY,
f64::INFINITY,
f64::NEG_INFINITY,
f64::NEG_INFINITY,
];
for (dx, dy) in [
(x0, -h / 2.0),
(x0 + w, -h / 2.0),
(x0, h / 2.0),
(x0 + w, h / 2.0),
] {
let (px, py) = (x + dx * c - dy * s, y + dx * s + dy * c);
b = [b[0].min(px), b[1].min(py), b[2].max(px), b[3].max(py)];
}
b
}
pub(crate) fn rotated_label_rise_fall<'a>(
labels: impl Iterator<Item = &'a str>,
size: f64,
angle: f64,
anchor: f64,
) -> (f64, f64) {
let mut rise = 0.0_f64;
let mut fall = 0.0_f64;
for label in labels {
let b = label_bbox(label, size, 0.0, 0.0, angle, anchor);
rise = rise.max(-b[1]);
fall = fall.max(b[3]);
}
(rise.max(0.0), fall.max(0.0))
}
pub(crate) fn rotated_anchor_frac(angle: f64, dir: f64) -> f64 {
if dir * angle.to_radians().sin() < 0.0 {
1.0
} else {
0.0
}
}
pub(crate) fn axis_label_priority(n: usize) -> Vec<usize> {
if n == 0 {
return Vec::new();
}
if n == 1 {
return vec![1];
}
let mut out = vec![1, n];
bisect(2, n - 1, &mut out);
out
}
fn bisect(lo: usize, hi: usize, out: &mut Vec<usize>) {
if lo > hi {
return;
}
let mid = (lo + hi) / 2;
out.push(mid);
if mid > lo {
bisect(lo, mid - 1, out);
}
bisect(mid + 1, hi, out);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn priority_matches_r_ggplot2_4_0_3() {
assert_eq!(axis_label_priority(5), vec![1, 5, 3, 2, 4]);
assert_eq!(axis_label_priority(10), vec![1, 10, 5, 3, 2, 4, 7, 6, 8, 9]);
}
#[test]
fn priority_edge_cases() {
assert_eq!(axis_label_priority(0), Vec::<usize>::new());
assert_eq!(axis_label_priority(1), vec![1]);
assert_eq!(axis_label_priority(2), vec![1, 2]);
}
#[test]
fn truncate_to_width_keeps_short_labels_untouched() {
assert_eq!(truncate_to_width("ab", 1000.0, 10.0), None);
}
#[test]
fn truncate_to_width_shortens_with_an_ellipsis() {
let full = "a very long legend label indeed";
let w = text_width(full, 10.0);
let truncated = truncate_to_width(full, w / 2.0, 10.0).expect("should truncate");
assert!(truncated.ends_with('…'));
assert!(text_width(&truncated, 10.0) <= w / 2.0);
assert!(truncated.chars().count() < full.chars().count());
}
#[test]
fn truncate_to_width_falls_back_to_a_bare_ellipsis() {
assert_eq!(
truncate_to_width("hello", 0.01, 10.0),
Some("…".to_string())
);
}
#[test]
fn text_width_matches_consumer_em_table() {
assert_eq!(text_width("", 10.0), 0.0);
assert!((text_width("i", 10.0) - 2.7).abs() < 1e-9);
assert!((text_width("M", 10.0) - 8.6).abs() < 1e-9);
}
#[test]
fn wrap_text_breaks_at_whitespace_within_width() {
let lines = wrap_text("alpha beta gamma delta", 1000.0, 10.0);
assert_eq!(lines, vec!["alpha beta gamma delta".to_string()]);
}
#[test]
fn wrap_text_wraps_to_multiple_lines_when_narrow() {
let w = text_width("alpha beta", 10.0);
let lines = wrap_text("alpha beta gamma delta", w + 1.0, 10.0);
assert!(lines.len() >= 2, "expected wrapping, got {lines:?}");
for l in &lines {
assert!(
text_width(l, 10.0) <= w + 1.0 + 1e-6,
"line {l:?} overflows width"
);
}
assert_eq!(lines.join(" "), "alpha beta gamma delta");
}
#[test]
fn wrap_text_hard_breaks_a_single_overlong_word() {
let lines = wrap_text("supercalifragilisticexpialidocious", 40.0, 10.0);
assert!(lines.len() > 1);
assert_eq!(lines.concat(), "supercalifragilisticexpialidocious");
for l in &lines {
assert!(text_width(l, 10.0) <= 40.0 + 1e-6);
}
}
#[test]
fn wrap_text_hard_break_flushes_a_pending_line_first() {
let lines = wrap_text("ab supercalifragilisticexpialidocious", 40.0, 10.0);
assert_eq!(lines[0], "ab");
assert!(lines.len() > 2, "{lines:?}");
assert_eq!(lines[1..].concat(), "supercalifragilisticexpialidocious");
for l in &lines {
assert!(text_width(l, 10.0) <= 40.0 + 1e-6);
}
}
#[test]
fn wrap_text_width_too_narrow_for_even_one_character_still_terminates() {
let lines = wrap_text("i", 0.5, 10.0);
assert_eq!(lines, vec!["i".to_string()]);
}
#[test]
fn wrap_text_empty_input_is_one_empty_line() {
assert_eq!(wrap_text("", 100.0, 10.0), vec![String::new()]);
}
#[test]
fn wrap_text_non_positive_width_disables_wrapping() {
assert_eq!(
wrap_text("alpha beta gamma", 0.0, 10.0),
vec!["alpha beta gamma".to_string()]
);
assert_eq!(
wrap_text("alpha beta gamma", -5.0, 10.0),
vec!["alpha beta gamma".to_string()]
);
}
#[test]
fn wrapped_block_width_is_the_widest_line() {
let lines = vec!["short".to_string(), "a longer line here".to_string()];
let w = wrapped_block_width(&lines, 10.0);
assert_eq!(w, text_width("a longer line here", 10.0));
}
#[test]
fn label_bbox_unrotated_middle_anchor() {
let b = label_bbox("ab", 10.0, 100.0, 50.0, 0.0, 0.5);
let w = text_width("ab", 10.0);
assert!((b[0] - (100.0 - w / 2.0)).abs() < 1e-9);
assert!((b[2] - (100.0 + w / 2.0)).abs() < 1e-9);
assert!((b[1] - (50.0 - 6.0)).abs() < 1e-9);
assert!((b[3] - (50.0 + 6.0)).abs() < 1e-9);
}
}