1use std::sync::Arc;
6
7pub fn label_comma(v: f64) -> String {
9 if v == v.round() && v.abs() < 1e15 {
10 let s = format!("{}", v as i64);
11 add_commas(&s)
12 } else {
13 let s = format!("{:.2}", v);
14 let s = s.trim_end_matches('0').trim_end_matches('.');
15 if let Some((int_part, dec_part)) = s.split_once('.') {
16 format!("{}.{}", add_commas(int_part), dec_part)
17 } else {
18 add_commas(s)
19 }
20 }
21}
22
23pub fn label_percent(v: f64) -> String {
25 let pct = v * 100.0;
26 if (pct - pct.round()).abs() < 1e-10 {
27 format!("{}%", pct.round() as i64)
28 } else {
29 format!("{:.1}%", pct)
30 }
31}
32
33pub fn label_dollar(v: f64) -> String {
36 currency(v, "$", "", ",", None)
37}
38
39pub fn label_currency(
53 prefix: &str,
54 suffix: &str,
55 big_mark: &str,
56 accuracy: Option<f64>,
57) -> impl Fn(f64) -> String + Send + Sync {
58 let prefix = prefix.to_string();
59 let suffix = suffix.to_string();
60 let big_mark = big_mark.to_string();
61 move |v: f64| currency(v, &prefix, &suffix, &big_mark, accuracy)
62}
63
64fn currency(v: f64, prefix: &str, suffix: &str, big_mark: &str, accuracy: Option<f64>) -> String {
66 let (_, body) = split_sign(format_accuracy(v.abs(), accuracy));
69 let sign = if v < 0.0 && !is_zero_number(&body) {
70 "-"
71 } else {
72 ""
73 };
74 let body = if big_mark == "," {
75 body
76 } else {
77 body.replace(',', big_mark)
78 };
79 format!("{sign}{prefix}{body}{suffix}")
80}
81
82fn split_sign(s: String) -> (&'static str, String) {
84 match s.strip_prefix('-') {
85 Some(rest) => ("-", rest.to_string()),
86 None => ("", s),
87 }
88}
89
90fn is_zero_number(s: &str) -> bool {
92 s.chars().all(|c| matches!(c, '0' | '.' | ','))
93}
94
95pub fn label_scientific(v: f64) -> String {
97 if v == 0.0 {
98 return "0".to_string();
99 }
100 let exp = v.abs().log10().floor() as i32;
101 let mantissa = v / 10f64.powi(exp);
102 if (mantissa - mantissa.round()).abs() < 1e-10 {
103 format!("{}e{}", mantissa.round() as i64, exp)
104 } else {
105 let s = format!("{:.2}e{}", mantissa, exp);
106 if let Some((m, e)) = s.split_once('e') {
108 let m = m.trim_end_matches('0').trim_end_matches('.');
109 format!("{m}e{e}")
110 } else {
111 s
112 }
113 }
114}
115
116fn add_commas(s: &str) -> String {
117 let negative = s.starts_with('-');
118 let digits = if negative { &s[1..] } else { s };
119 let mut result = String::new();
120 for (i, ch) in digits.chars().rev().enumerate() {
121 if i > 0 && i % 3 == 0 {
122 result.push(',');
123 }
124 result.push(ch);
125 }
126 let formatted: String = result.chars().rev().collect();
127 if negative {
128 format!("-{formatted}")
129 } else {
130 formatted
131 }
132}
133
134pub type LabelFormatter = Arc<dyn Fn(f64) -> String + Send + Sync>;
138
139#[derive(Clone)]
151pub(crate) struct UnwindSafeFormatter(pub(crate) LabelFormatter);
152
153impl std::panic::UnwindSafe for UnwindSafeFormatter {}
154impl std::panic::RefUnwindSafe for UnwindSafeFormatter {}
155
156impl UnwindSafeFormatter {
157 pub(crate) fn call(&self, v: f64) -> String {
158 (self.0)(v)
159 }
160}
161
162fn format_accuracy(v: f64, accuracy: Option<f64>) -> String {
164 match accuracy {
165 Some(acc) if acc > 0.0 => {
166 let rounded = (v / acc).round() * acc;
167 let decimals = (-acc.log10().floor()).max(0.0) as usize;
168 let s = format!("{rounded:.decimals$}");
169 match s.split_once('.') {
171 Some((int_part, dec_part)) => format!("{}.{dec_part}", add_commas(int_part)),
172 None => add_commas(&s),
173 }
174 }
175 _ => label_comma(v),
176 }
177}
178
179pub fn label_number(
184 accuracy: Option<f64>,
185 prefix: &str,
186 suffix: &str,
187 scale: f64,
188) -> impl Fn(f64) -> String + Send + Sync {
189 let prefix = prefix.to_string();
190 let suffix = suffix.to_string();
191 move |v: f64| {
192 let (sign, body) = split_sign(format_accuracy(v * scale, accuracy));
195 let sign = if is_zero_number(&body) { "" } else { sign };
196 format!("{sign}{prefix}{body}{suffix}")
197 }
198}
199
200use crate::locale::{localize_number, Locale};
208
209#[allow(clippy::too_many_arguments)]
212fn number_body(
213 v: f64,
214 accuracy: Option<f64>,
215 prefix: &str,
216 suffix: &str,
217 scale: f64,
218 decimal_mark: &str,
219 big_mark: &str,
220 min_grouping_digits: usize,
221) -> String {
222 let (sign, body) = split_sign(format_accuracy(v * scale, accuracy));
223 let sign = if is_zero_number(&body) { "" } else { sign };
224 let body = localize_number(&body, decimal_mark, big_mark, min_grouping_digits);
225 format!("{sign}{prefix}{body}{suffix}")
226}
227
228pub fn label_number_marks(
237 accuracy: Option<f64>,
238 prefix: &str,
239 suffix: &str,
240 scale: f64,
241 decimal_mark: &str,
242 big_mark: &str,
243) -> impl Fn(f64) -> String + Send + Sync {
244 let prefix = prefix.to_string();
245 let suffix = suffix.to_string();
246 let dm = decimal_mark.to_string();
247 let bm = big_mark.to_string();
248 move |v: f64| number_body(v, accuracy, &prefix, &suffix, scale, &dm, &bm, 1)
249}
250
251pub fn label_number_locale(
260 locale: &Locale,
261 accuracy: Option<f64>,
262 prefix: &str,
263 suffix: &str,
264) -> impl Fn(f64) -> String + Send + Sync {
265 let prefix = prefix.to_string();
266 let suffix = suffix.to_string();
267 let loc = locale.clone();
268 move |v: f64| {
269 number_body(
270 v,
271 accuracy,
272 &prefix,
273 &suffix,
274 1.0,
275 &loc.decimal_mark,
276 &loc.big_mark,
277 loc.min_grouping_digits,
278 )
279 }
280}
281
282pub fn label_comma_marks(
285 decimal_mark: &str,
286 big_mark: &str,
287) -> impl Fn(f64) -> String + Send + Sync {
288 let dm = decimal_mark.to_string();
289 let bm = big_mark.to_string();
290 move |v: f64| localize_number(&label_comma(v), &dm, &bm, 1)
291}
292
293pub fn label_comma_locale(locale: &Locale) -> impl Fn(f64) -> String + Send + Sync {
295 let loc = locale.clone();
296 move |v: f64| loc.localize_number(&label_comma(v))
297}
298
299fn percent_body(
303 v: f64,
304 accuracy: Option<f64>,
305 decimal_mark: &str,
306 big_mark: &str,
307 min_grouping_digits: usize,
308 suffix: &str,
309) -> String {
310 match accuracy {
311 Some(_) => number_body(
312 v,
313 accuracy,
314 "",
315 suffix,
316 100.0,
317 decimal_mark,
318 big_mark,
319 min_grouping_digits,
320 ),
321 None => {
322 let pct = v * 100.0;
323 let body = if (pct - pct.round()).abs() < 1e-10 {
324 format!("{}", pct.round() as i64)
325 } else {
326 format!("{pct:.1}")
327 };
328 let body = localize_number(&body, decimal_mark, big_mark, min_grouping_digits);
329 format!("{body}{suffix}")
330 }
331 }
332}
333
334pub fn label_percent_marks(
344 accuracy: Option<f64>,
345 decimal_mark: &str,
346 big_mark: &str,
347 suffix: &str,
348) -> impl Fn(f64) -> String + Send + Sync {
349 let dm = decimal_mark.to_string();
350 let bm = big_mark.to_string();
351 let suffix = suffix.to_string();
352 move |v: f64| percent_body(v, accuracy, &dm, &bm, 1, &suffix)
353}
354
355pub fn label_percent_locale(
358 locale: &Locale,
359 accuracy: Option<f64>,
360) -> impl Fn(f64) -> String + Send + Sync {
361 let loc = locale.clone();
362 move |v: f64| {
363 percent_body(
364 v,
365 accuracy,
366 &loc.decimal_mark,
367 &loc.big_mark,
368 loc.min_grouping_digits,
369 &loc.percent_suffix,
370 )
371 }
372}
373
374pub fn label_currency_marks(
382 prefix: &str,
383 suffix: &str,
384 big_mark: &str,
385 decimal_mark: &str,
386 accuracy: Option<f64>,
387) -> impl Fn(f64) -> String + Send + Sync {
388 let prefix = prefix.to_string();
389 let suffix = suffix.to_string();
390 let bm = big_mark.to_string();
391 let dm = decimal_mark.to_string();
392 move |v: f64| currency_marks(v, &prefix, &suffix, &bm, &dm, 1, accuracy)
393}
394
395pub fn label_currency_locale(
399 locale: &Locale,
400 accuracy: Option<f64>,
401) -> impl Fn(f64) -> String + Send + Sync {
402 let (prefix, suffix) = locale.currency_affixes(&locale.currency_symbol);
403 let loc = locale.clone();
404 move |v: f64| {
405 currency_marks(
406 v,
407 &prefix,
408 &suffix,
409 &loc.big_mark,
410 &loc.decimal_mark,
411 loc.min_grouping_digits,
412 accuracy,
413 )
414 }
415}
416
417fn currency_marks(
419 v: f64,
420 prefix: &str,
421 suffix: &str,
422 big_mark: &str,
423 decimal_mark: &str,
424 min_grouping_digits: usize,
425 accuracy: Option<f64>,
426) -> String {
427 let (_, body) = split_sign(format_accuracy(v.abs(), accuracy));
428 let sign = if v < 0.0 && !is_zero_number(&body) {
429 "-"
430 } else {
431 ""
432 };
433 let body = localize_number(&body, decimal_mark, big_mark, min_grouping_digits);
434 format!("{sign}{prefix}{body}{suffix}")
435}
436
437pub fn label_si() -> impl Fn(f64) -> String + Send + Sync {
439 |v: f64| {
440 if v == 0.0 {
441 return "0".to_string();
442 }
443 let a = v.abs();
444 let (div, suffix) = if a >= 1e12 {
445 (1e12, "T")
446 } else if a >= 1e9 {
447 (1e9, "G")
448 } else if a >= 1e6 {
449 (1e6, "M")
450 } else if a >= 1e3 {
451 (1e3, "k")
452 } else if a >= 1.0 {
453 (1.0, "")
454 } else if a >= 1e-3 {
455 (1e-3, "m")
456 } else if a >= 1e-6 {
457 (1e-6, "µ")
458 } else {
459 (1e-9, "n")
460 };
461 let scaled = v / div;
462 let s = format!("{scaled:.1}");
463 let s = s.trim_end_matches('0').trim_end_matches('.');
464 format!("{s}{suffix}")
465 }
466}
467
468pub fn label_ordinal() -> impl Fn(f64) -> String + Send + Sync {
470 |v: f64| {
471 let n = v.round() as i64;
472 let suffix = match (n.rem_euclid(10), n.rem_euclid(100)) {
473 (1, r) if r != 11 => "st",
474 (2, r) if r != 12 => "nd",
475 (3, r) if r != 13 => "rd",
476 _ => "th",
477 };
478 format!("{n}{suffix}")
479 }
480}
481
482pub fn label_bytes(binary: bool) -> impl Fn(f64) -> String + Send + Sync {
485 let (base, units): (f64, &[&str]) = if binary {
486 (1024.0, &["B", "KiB", "MiB", "GiB", "TiB"])
487 } else {
488 (1000.0, &["B", "kB", "MB", "GB", "TB"])
489 };
490 move |v: f64| {
491 let a = v.abs();
492 if a < base {
493 return format!("{} {}", v.round() as i64, units[0]);
494 }
495 let mut val = a;
496 let mut i = 0;
497 while val >= base && i < units.len() - 1 {
498 val /= base;
499 i += 1;
500 }
501 let s = format!("{val:.1}");
502 let s = s.trim_end_matches('0').trim_end_matches('.');
503 let sign = if v < 0.0 { "-" } else { "" };
504 format!("{sign}{s} {}", units[i])
505 }
506}
507
508#[cfg(test)]
509mod tests {
510 use super::*;
511
512 #[test]
513 fn test_label_comma() {
514 assert_eq!(label_comma(1000.0), "1,000");
515 assert_eq!(label_comma(1234567.0), "1,234,567");
516 assert_eq!(label_comma(42.0), "42");
517 assert_eq!(label_comma(-5000.0), "-5,000");
518 }
519
520 #[test]
521 fn test_label_percent() {
522 assert_eq!(label_percent(0.5), "50%");
523 assert_eq!(label_percent(0.0), "0%");
524 assert_eq!(label_percent(1.0), "100%");
525 assert_eq!(label_percent(0.123), "12.3%");
526 }
527
528 #[test]
529 fn test_label_dollar() {
530 assert_eq!(label_dollar(1000.0), "$1,000");
531 assert_eq!(label_dollar(0.0), "$0");
532 assert_eq!(label_dollar(-500.0), "-$500");
533 }
534
535 #[test]
536 fn test_label_scientific() {
537 assert_eq!(label_scientific(12345.0), "1.23e4");
538 assert_eq!(label_scientific(0.0), "0");
539 assert_eq!(label_scientific(100.0), "1e2");
540 }
541
542 #[test]
543 fn test_label_si() {
544 let f = label_si();
545 assert_eq!(f(1500.0), "1.5k");
546 assert_eq!(f(2_300_000.0), "2.3M");
547 assert_eq!(f(5e9), "5G");
548 assert_eq!(f(0.0), "0");
549 assert_eq!(f(0.0005), "500µ");
550 assert_eq!(f(-4000.0), "-4k");
551 }
552
553 #[test]
554 fn test_label_number() {
555 let f = label_number(Some(0.1), "", " kg", 1.0);
556 assert_eq!(f(4.16), "4.2 kg");
557 let pct = label_number(Some(1.0), "", "%", 100.0);
558 assert_eq!(pct(0.25), "25%");
559 let money = label_number(None, "€", "", 1.0);
560 assert_eq!(money(1500.0), "€1,500");
561 }
562
563 #[test]
564 fn label_number_is_sign_aware() {
565 let eur = label_number(None, "€", "", 1.0);
566 assert_eq!(eur(-5.0), "-€5");
567 assert_eq!(eur(-1234.5), "-€1,234.5");
568 let f = label_number(Some(1.0), "€", "", 1.0);
569 assert_eq!(f(-0.2), "€0", "rounds to zero: no sign");
570 assert_eq!(f(1_234_567.0), "€1,234,567");
571 let g = label_number(Some(0.01), "", "", 1.0);
573 assert_eq!(g(1234.5), "1,234.50");
574 assert_eq!(g(-98765.4321), "-98,765.43");
575 }
576
577 #[test]
578 fn label_currency_marks_and_signs() {
579 let eur = label_currency("€", "", ".", Some(1.0));
580 assert_eq!(eur(-1_234_567.0), "-€1.234.567");
581 assert_eq!(eur(0.0), "€0");
582 let chf = label_currency("", " CHF", "'", Some(0.01));
583 assert_eq!(chf(9876.5), "9'876.50 CHF");
584 assert_eq!(chf(-0.001), "0.00 CHF");
585 let plain = label_currency("$", "", "", None);
586 assert_eq!(plain(1234.5), "$1234.5");
587 let usd = label_currency("$", "", ",", None);
588 for v in [0.0, 1.0, -500.0, 1234.5, -1_000_000.0, 0.125] {
589 assert_eq!(usd(v), label_dollar(v), "label_dollar parity at {v}");
590 }
591 assert_eq!(label_dollar(-1234.0), "-$1,234");
592 assert_eq!(usd(f64::NAN), "$NaN");
593 assert_eq!(usd(f64::NEG_INFINITY), "-$inf");
594 }
595
596 #[test]
597 fn marks_variants_match_plain_formatters_with_default_marks() {
598 let num = label_number_marks(Some(0.01), "€", "", 1.0, ".", ",");
599 let plain = label_number(Some(0.01), "€", "", 1.0);
600 let comma = label_comma_marks(".", ",");
601 let pct = label_percent_marks(None, ".", "", "%"); let cur = label_currency_marks("$", "", ",", ".", None);
603 let us = Locale::en_us();
604 let pct_us = label_percent_locale(&us, None);
605 let comma_us = label_comma_locale(&us);
606 for v in [
607 0.0, 1.0, -1.0, 0.125, -0.004, 999.0, 1000.0, -1234.5, 98765.4321, 1e9, -2.5e12,
608 ] {
609 assert_eq!(num(v), plain(v), "label_number at {v}");
610 assert_eq!(comma(v), label_comma(v), "label_comma at {v}");
611 assert_eq!(comma_us(v), label_comma(v), "label_comma en-US at {v}");
612 assert_eq!(pct(v), label_percent(v), "label_percent at {v}");
613 assert_eq!(cur(v), label_dollar(v), "label_dollar at {v}");
614 if v.abs() < 10.0 {
615 assert_eq!(pct_us(v), label_percent(v), "en-US percent at {v}");
617 }
618 }
619 }
620
621 #[test]
622 fn currency_per_locale() {
623 let acc = Some(0.01);
624 assert_eq!(
625 label_currency_locale(&Locale::de_de(), acc)(1234.5),
626 "1.234,50 €"
627 );
628 assert_eq!(
629 label_currency_locale(&Locale::en_us(), acc)(1234.5),
630 "$1,234.50"
631 );
632 assert_eq!(
633 label_currency_locale(&Locale::en_gb(), acc)(1234.5),
634 "£1,234.50"
635 );
636 assert_eq!(
637 label_currency_locale(&Locale::fr_fr(), acc)(1234.5),
638 "1 234,50 €"
639 );
640 assert_eq!(
642 label_currency_locale(&Locale::de_de(), acc)(-1234.5),
643 "-1.234,50 €"
644 );
645 assert_eq!(
646 label_currency_locale(&Locale::en_us(), acc)(-1234.5),
647 "-$1,234.50"
648 );
649 assert_eq!(
651 label_currency_locale(&Locale::de_de(), acc)(-0.001),
652 "0,00 €"
653 );
654 let de0 = label_currency_locale(&Locale::de_de(), Some(1.0));
656 assert_eq!(de0(1_234_567.5), "1.234.568 €");
657 assert_eq!(de0(999.4), "999 €");
658 let de_trim = label_currency_locale(&Locale::de_de(), None);
659 assert_eq!(de_trim(1234.5), "1.234,5 €");
660 assert_eq!(de_trim(2_000_000.0), "2.000.000 €");
661 let chf = label_currency_locale(&Locale::de_de().with_currency("CHF"), acc);
662 assert_eq!(chf(1234.5), "1.234,50 CHF");
663 }
664
665 #[test]
666 fn numbers_per_locale() {
667 let cases: [(Locale, [&str; 5]); 4] = [
668 (
669 Locale::en_us(),
670 ["1,234.57", "-1,234.57", "0.10", "1,234,567,890.00", "0.00"],
671 ),
672 (
673 Locale::en_gb(),
674 ["1,234.57", "-1,234.57", "0.10", "1,234,567,890.00", "0.00"],
675 ),
676 (
677 Locale::de_de(),
678 ["1.234,57", "-1.234,57", "0,10", "1.234.567.890,00", "0,00"],
679 ),
680 (
681 Locale::fr_fr(),
682 ["1 234,57", "-1 234,57", "0,10", "1 234 567 890,00", "0,00"],
683 ),
684 ];
685 for (loc, want) in cases {
686 let f = label_number_locale(&loc, Some(0.01), "", "");
687 let got = [
688 f(1234.567),
689 f(-1234.567),
690 f(0.1),
691 f(1_234_567_890.0),
692 f(-0.004),
693 ];
694 assert_eq!(got.map(|s| s), want.map(String::from), "{}", loc.tag);
695 }
696 let de = Locale::de_de();
697 assert_eq!(label_comma_locale(&de)(-1_234_567.0), "-1.234.567");
698 assert_eq!(label_comma_locale(&de)(1234.567), "1.234,57");
699 assert_eq!(label_comma_locale(&Locale::fr_fr())(1234.5), "1 234,5");
700 let pre = label_number_locale(&de, None, "€", "");
701 assert_eq!(pre(-5.0), "-€5");
702 }
703
704 #[test]
705 fn percent_per_locale() {
706 let de = label_percent_locale(&Locale::de_de(), None);
707 assert_eq!(de(0.5), "50 %");
708 assert_eq!(de(0.123), "12,3 %");
709 assert_eq!(de(-0.25), "-25 %");
710 assert_eq!(de(12.345), "1.234,5 %");
711 let fr = label_percent_locale(&Locale::fr_fr(), Some(0.01));
712 assert_eq!(fr(0.12345), "12,35 %");
713 let us = label_percent_locale(&Locale::en_us(), None);
714 assert_eq!(us(0.123), "12.3%");
715 assert_eq!(
716 label_percent_locale(&Locale::en_gb(), Some(1.0))(0.506),
717 "51%"
718 );
719 }
720
721 #[test]
722 fn test_label_ordinal() {
723 let f = label_ordinal();
724 assert_eq!(f(1.0), "1st");
725 assert_eq!(f(2.0), "2nd");
726 assert_eq!(f(3.0), "3rd");
727 assert_eq!(f(4.0), "4th");
728 assert_eq!(f(11.0), "11th");
729 assert_eq!(f(22.0), "22nd");
730 }
731
732 #[test]
733 fn test_label_bytes() {
734 let f = label_bytes(false);
735 assert_eq!(f(500.0), "500 B");
736 assert_eq!(f(1500.0), "1.5 kB");
737 assert_eq!(f(2_000_000.0), "2 MB");
738 let b = label_bytes(true);
739 assert_eq!(b(1024.0), "1 KiB");
740 }
741}