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
139fn format_accuracy(v: f64, accuracy: Option<f64>) -> String {
141 match accuracy {
142 Some(acc) if acc > 0.0 => {
143 let rounded = (v / acc).round() * acc;
144 let decimals = (-acc.log10().floor()).max(0.0) as usize;
145 let s = format!("{rounded:.decimals$}");
146 match s.split_once('.') {
148 Some((int_part, dec_part)) => format!("{}.{dec_part}", add_commas(int_part)),
149 None => add_commas(&s),
150 }
151 }
152 _ => label_comma(v),
153 }
154}
155
156pub fn label_number(
161 accuracy: Option<f64>,
162 prefix: &str,
163 suffix: &str,
164 scale: f64,
165) -> impl Fn(f64) -> String + Send + Sync {
166 let prefix = prefix.to_string();
167 let suffix = suffix.to_string();
168 move |v: f64| {
169 let (sign, body) = split_sign(format_accuracy(v * scale, accuracy));
172 let sign = if is_zero_number(&body) { "" } else { sign };
173 format!("{sign}{prefix}{body}{suffix}")
174 }
175}
176
177use crate::locale::{localize_number, Locale};
185
186#[allow(clippy::too_many_arguments)]
189fn number_body(
190 v: f64,
191 accuracy: Option<f64>,
192 prefix: &str,
193 suffix: &str,
194 scale: f64,
195 decimal_mark: &str,
196 big_mark: &str,
197 min_grouping_digits: usize,
198) -> String {
199 let (sign, body) = split_sign(format_accuracy(v * scale, accuracy));
200 let sign = if is_zero_number(&body) { "" } else { sign };
201 let body = localize_number(&body, decimal_mark, big_mark, min_grouping_digits);
202 format!("{sign}{prefix}{body}{suffix}")
203}
204
205pub fn label_number_marks(
214 accuracy: Option<f64>,
215 prefix: &str,
216 suffix: &str,
217 scale: f64,
218 decimal_mark: &str,
219 big_mark: &str,
220) -> impl Fn(f64) -> String + Send + Sync {
221 let prefix = prefix.to_string();
222 let suffix = suffix.to_string();
223 let dm = decimal_mark.to_string();
224 let bm = big_mark.to_string();
225 move |v: f64| number_body(v, accuracy, &prefix, &suffix, scale, &dm, &bm, 1)
226}
227
228pub fn label_number_locale(
237 locale: &Locale,
238 accuracy: Option<f64>,
239 prefix: &str,
240 suffix: &str,
241) -> impl Fn(f64) -> String + Send + Sync {
242 let prefix = prefix.to_string();
243 let suffix = suffix.to_string();
244 let loc = locale.clone();
245 move |v: f64| {
246 number_body(
247 v,
248 accuracy,
249 &prefix,
250 &suffix,
251 1.0,
252 &loc.decimal_mark,
253 &loc.big_mark,
254 loc.min_grouping_digits,
255 )
256 }
257}
258
259pub fn label_comma_marks(
262 decimal_mark: &str,
263 big_mark: &str,
264) -> impl Fn(f64) -> String + Send + Sync {
265 let dm = decimal_mark.to_string();
266 let bm = big_mark.to_string();
267 move |v: f64| localize_number(&label_comma(v), &dm, &bm, 1)
268}
269
270pub fn label_comma_locale(locale: &Locale) -> impl Fn(f64) -> String + Send + Sync {
272 let loc = locale.clone();
273 move |v: f64| loc.localize_number(&label_comma(v))
274}
275
276fn percent_body(
280 v: f64,
281 accuracy: Option<f64>,
282 decimal_mark: &str,
283 big_mark: &str,
284 min_grouping_digits: usize,
285 suffix: &str,
286) -> String {
287 match accuracy {
288 Some(_) => number_body(
289 v,
290 accuracy,
291 "",
292 suffix,
293 100.0,
294 decimal_mark,
295 big_mark,
296 min_grouping_digits,
297 ),
298 None => {
299 let pct = v * 100.0;
300 let body = if (pct - pct.round()).abs() < 1e-10 {
301 format!("{}", pct.round() as i64)
302 } else {
303 format!("{pct:.1}")
304 };
305 let body = localize_number(&body, decimal_mark, big_mark, min_grouping_digits);
306 format!("{body}{suffix}")
307 }
308 }
309}
310
311pub fn label_percent_marks(
321 accuracy: Option<f64>,
322 decimal_mark: &str,
323 big_mark: &str,
324 suffix: &str,
325) -> impl Fn(f64) -> String + Send + Sync {
326 let dm = decimal_mark.to_string();
327 let bm = big_mark.to_string();
328 let suffix = suffix.to_string();
329 move |v: f64| percent_body(v, accuracy, &dm, &bm, 1, &suffix)
330}
331
332pub fn label_percent_locale(
335 locale: &Locale,
336 accuracy: Option<f64>,
337) -> impl Fn(f64) -> String + Send + Sync {
338 let loc = locale.clone();
339 move |v: f64| {
340 percent_body(
341 v,
342 accuracy,
343 &loc.decimal_mark,
344 &loc.big_mark,
345 loc.min_grouping_digits,
346 &loc.percent_suffix,
347 )
348 }
349}
350
351pub fn label_currency_marks(
359 prefix: &str,
360 suffix: &str,
361 big_mark: &str,
362 decimal_mark: &str,
363 accuracy: Option<f64>,
364) -> impl Fn(f64) -> String + Send + Sync {
365 let prefix = prefix.to_string();
366 let suffix = suffix.to_string();
367 let bm = big_mark.to_string();
368 let dm = decimal_mark.to_string();
369 move |v: f64| currency_marks(v, &prefix, &suffix, &bm, &dm, 1, accuracy)
370}
371
372pub fn label_currency_locale(
376 locale: &Locale,
377 accuracy: Option<f64>,
378) -> impl Fn(f64) -> String + Send + Sync {
379 let (prefix, suffix) = locale.currency_affixes(&locale.currency_symbol);
380 let loc = locale.clone();
381 move |v: f64| {
382 currency_marks(
383 v,
384 &prefix,
385 &suffix,
386 &loc.big_mark,
387 &loc.decimal_mark,
388 loc.min_grouping_digits,
389 accuracy,
390 )
391 }
392}
393
394fn currency_marks(
396 v: f64,
397 prefix: &str,
398 suffix: &str,
399 big_mark: &str,
400 decimal_mark: &str,
401 min_grouping_digits: usize,
402 accuracy: Option<f64>,
403) -> String {
404 let (_, body) = split_sign(format_accuracy(v.abs(), accuracy));
405 let sign = if v < 0.0 && !is_zero_number(&body) {
406 "-"
407 } else {
408 ""
409 };
410 let body = localize_number(&body, decimal_mark, big_mark, min_grouping_digits);
411 format!("{sign}{prefix}{body}{suffix}")
412}
413
414pub fn label_si() -> impl Fn(f64) -> String + Send + Sync {
416 |v: f64| {
417 if v == 0.0 {
418 return "0".to_string();
419 }
420 let a = v.abs();
421 let (div, suffix) = if a >= 1e12 {
422 (1e12, "T")
423 } else if a >= 1e9 {
424 (1e9, "G")
425 } else if a >= 1e6 {
426 (1e6, "M")
427 } else if a >= 1e3 {
428 (1e3, "k")
429 } else if a >= 1.0 {
430 (1.0, "")
431 } else if a >= 1e-3 {
432 (1e-3, "m")
433 } else if a >= 1e-6 {
434 (1e-6, "µ")
435 } else {
436 (1e-9, "n")
437 };
438 let scaled = v / div;
439 let s = format!("{scaled:.1}");
440 let s = s.trim_end_matches('0').trim_end_matches('.');
441 format!("{s}{suffix}")
442 }
443}
444
445pub fn label_ordinal() -> impl Fn(f64) -> String + Send + Sync {
447 |v: f64| {
448 let n = v.round() as i64;
449 let suffix = match (n.rem_euclid(10), n.rem_euclid(100)) {
450 (1, r) if r != 11 => "st",
451 (2, r) if r != 12 => "nd",
452 (3, r) if r != 13 => "rd",
453 _ => "th",
454 };
455 format!("{n}{suffix}")
456 }
457}
458
459pub fn label_bytes(binary: bool) -> impl Fn(f64) -> String + Send + Sync {
462 let (base, units): (f64, &[&str]) = if binary {
463 (1024.0, &["B", "KiB", "MiB", "GiB", "TiB"])
464 } else {
465 (1000.0, &["B", "kB", "MB", "GB", "TB"])
466 };
467 move |v: f64| {
468 let a = v.abs();
469 if a < base {
470 return format!("{} {}", v.round() as i64, units[0]);
471 }
472 let mut val = a;
473 let mut i = 0;
474 while val >= base && i < units.len() - 1 {
475 val /= base;
476 i += 1;
477 }
478 let s = format!("{val:.1}");
479 let s = s.trim_end_matches('0').trim_end_matches('.');
480 let sign = if v < 0.0 { "-" } else { "" };
481 format!("{sign}{s} {}", units[i])
482 }
483}
484
485#[cfg(test)]
486mod tests {
487 use super::*;
488
489 #[test]
490 fn test_label_comma() {
491 assert_eq!(label_comma(1000.0), "1,000");
492 assert_eq!(label_comma(1234567.0), "1,234,567");
493 assert_eq!(label_comma(42.0), "42");
494 assert_eq!(label_comma(-5000.0), "-5,000");
495 }
496
497 #[test]
498 fn test_label_percent() {
499 assert_eq!(label_percent(0.5), "50%");
500 assert_eq!(label_percent(0.0), "0%");
501 assert_eq!(label_percent(1.0), "100%");
502 assert_eq!(label_percent(0.123), "12.3%");
503 }
504
505 #[test]
506 fn test_label_dollar() {
507 assert_eq!(label_dollar(1000.0), "$1,000");
508 assert_eq!(label_dollar(0.0), "$0");
509 assert_eq!(label_dollar(-500.0), "-$500");
510 }
511
512 #[test]
513 fn test_label_scientific() {
514 assert_eq!(label_scientific(12345.0), "1.23e4");
515 assert_eq!(label_scientific(0.0), "0");
516 assert_eq!(label_scientific(100.0), "1e2");
517 }
518
519 #[test]
520 fn test_label_si() {
521 let f = label_si();
522 assert_eq!(f(1500.0), "1.5k");
523 assert_eq!(f(2_300_000.0), "2.3M");
524 assert_eq!(f(5e9), "5G");
525 assert_eq!(f(0.0), "0");
526 assert_eq!(f(0.0005), "500µ");
527 assert_eq!(f(-4000.0), "-4k");
528 }
529
530 #[test]
531 fn test_label_number() {
532 let f = label_number(Some(0.1), "", " kg", 1.0);
533 assert_eq!(f(4.16), "4.2 kg");
534 let pct = label_number(Some(1.0), "", "%", 100.0);
535 assert_eq!(pct(0.25), "25%");
536 let money = label_number(None, "€", "", 1.0);
537 assert_eq!(money(1500.0), "€1,500");
538 }
539
540 #[test]
541 fn label_number_is_sign_aware() {
542 let eur = label_number(None, "€", "", 1.0);
543 assert_eq!(eur(-5.0), "-€5");
544 assert_eq!(eur(-1234.5), "-€1,234.5");
545 let f = label_number(Some(1.0), "€", "", 1.0);
546 assert_eq!(f(-0.2), "€0", "rounds to zero: no sign");
547 assert_eq!(f(1_234_567.0), "€1,234,567");
548 let g = label_number(Some(0.01), "", "", 1.0);
550 assert_eq!(g(1234.5), "1,234.50");
551 assert_eq!(g(-98765.4321), "-98,765.43");
552 }
553
554 #[test]
555 fn label_currency_marks_and_signs() {
556 let eur = label_currency("€", "", ".", Some(1.0));
557 assert_eq!(eur(-1_234_567.0), "-€1.234.567");
558 assert_eq!(eur(0.0), "€0");
559 let chf = label_currency("", " CHF", "'", Some(0.01));
560 assert_eq!(chf(9876.5), "9'876.50 CHF");
561 assert_eq!(chf(-0.001), "0.00 CHF");
562 let plain = label_currency("$", "", "", None);
563 assert_eq!(plain(1234.5), "$1234.5");
564 let usd = label_currency("$", "", ",", None);
565 for v in [0.0, 1.0, -500.0, 1234.5, -1_000_000.0, 0.125] {
566 assert_eq!(usd(v), label_dollar(v), "label_dollar parity at {v}");
567 }
568 assert_eq!(label_dollar(-1234.0), "-$1,234");
569 assert_eq!(usd(f64::NAN), "$NaN");
570 assert_eq!(usd(f64::NEG_INFINITY), "-$inf");
571 }
572
573 #[test]
574 fn marks_variants_match_plain_formatters_with_default_marks() {
575 let num = label_number_marks(Some(0.01), "€", "", 1.0, ".", ",");
576 let plain = label_number(Some(0.01), "€", "", 1.0);
577 let comma = label_comma_marks(".", ",");
578 let pct = label_percent_marks(None, ".", "", "%"); let cur = label_currency_marks("$", "", ",", ".", None);
580 let us = Locale::en_us();
581 let pct_us = label_percent_locale(&us, None);
582 let comma_us = label_comma_locale(&us);
583 for v in [
584 0.0, 1.0, -1.0, 0.125, -0.004, 999.0, 1000.0, -1234.5, 98765.4321, 1e9, -2.5e12,
585 ] {
586 assert_eq!(num(v), plain(v), "label_number at {v}");
587 assert_eq!(comma(v), label_comma(v), "label_comma at {v}");
588 assert_eq!(comma_us(v), label_comma(v), "label_comma en-US at {v}");
589 assert_eq!(pct(v), label_percent(v), "label_percent at {v}");
590 assert_eq!(cur(v), label_dollar(v), "label_dollar at {v}");
591 if v.abs() < 10.0 {
592 assert_eq!(pct_us(v), label_percent(v), "en-US percent at {v}");
594 }
595 }
596 }
597
598 #[test]
599 fn currency_per_locale() {
600 let acc = Some(0.01);
601 assert_eq!(
602 label_currency_locale(&Locale::de_de(), acc)(1234.5),
603 "1.234,50 €"
604 );
605 assert_eq!(
606 label_currency_locale(&Locale::en_us(), acc)(1234.5),
607 "$1,234.50"
608 );
609 assert_eq!(
610 label_currency_locale(&Locale::en_gb(), acc)(1234.5),
611 "£1,234.50"
612 );
613 assert_eq!(
614 label_currency_locale(&Locale::fr_fr(), acc)(1234.5),
615 "1 234,50 €"
616 );
617 assert_eq!(
619 label_currency_locale(&Locale::de_de(), acc)(-1234.5),
620 "-1.234,50 €"
621 );
622 assert_eq!(
623 label_currency_locale(&Locale::en_us(), acc)(-1234.5),
624 "-$1,234.50"
625 );
626 assert_eq!(
628 label_currency_locale(&Locale::de_de(), acc)(-0.001),
629 "0,00 €"
630 );
631 let de0 = label_currency_locale(&Locale::de_de(), Some(1.0));
633 assert_eq!(de0(1_234_567.5), "1.234.568 €");
634 assert_eq!(de0(999.4), "999 €");
635 let de_trim = label_currency_locale(&Locale::de_de(), None);
636 assert_eq!(de_trim(1234.5), "1.234,5 €");
637 assert_eq!(de_trim(2_000_000.0), "2.000.000 €");
638 let chf = label_currency_locale(&Locale::de_de().with_currency("CHF"), acc);
639 assert_eq!(chf(1234.5), "1.234,50 CHF");
640 }
641
642 #[test]
643 fn numbers_per_locale() {
644 let cases: [(Locale, [&str; 5]); 4] = [
645 (
646 Locale::en_us(),
647 ["1,234.57", "-1,234.57", "0.10", "1,234,567,890.00", "0.00"],
648 ),
649 (
650 Locale::en_gb(),
651 ["1,234.57", "-1,234.57", "0.10", "1,234,567,890.00", "0.00"],
652 ),
653 (
654 Locale::de_de(),
655 ["1.234,57", "-1.234,57", "0,10", "1.234.567.890,00", "0,00"],
656 ),
657 (
658 Locale::fr_fr(),
659 ["1 234,57", "-1 234,57", "0,10", "1 234 567 890,00", "0,00"],
660 ),
661 ];
662 for (loc, want) in cases {
663 let f = label_number_locale(&loc, Some(0.01), "", "");
664 let got = [
665 f(1234.567),
666 f(-1234.567),
667 f(0.1),
668 f(1_234_567_890.0),
669 f(-0.004),
670 ];
671 assert_eq!(got.map(|s| s), want.map(String::from), "{}", loc.tag);
672 }
673 let de = Locale::de_de();
674 assert_eq!(label_comma_locale(&de)(-1_234_567.0), "-1.234.567");
675 assert_eq!(label_comma_locale(&de)(1234.567), "1.234,57");
676 assert_eq!(label_comma_locale(&Locale::fr_fr())(1234.5), "1 234,5");
677 let pre = label_number_locale(&de, None, "€", "");
678 assert_eq!(pre(-5.0), "-€5");
679 }
680
681 #[test]
682 fn percent_per_locale() {
683 let de = label_percent_locale(&Locale::de_de(), None);
684 assert_eq!(de(0.5), "50 %");
685 assert_eq!(de(0.123), "12,3 %");
686 assert_eq!(de(-0.25), "-25 %");
687 assert_eq!(de(12.345), "1.234,5 %");
688 let fr = label_percent_locale(&Locale::fr_fr(), Some(0.01));
689 assert_eq!(fr(0.12345), "12,35 %");
690 let us = label_percent_locale(&Locale::en_us(), None);
691 assert_eq!(us(0.123), "12.3%");
692 assert_eq!(
693 label_percent_locale(&Locale::en_gb(), Some(1.0))(0.506),
694 "51%"
695 );
696 }
697
698 #[test]
699 fn test_label_ordinal() {
700 let f = label_ordinal();
701 assert_eq!(f(1.0), "1st");
702 assert_eq!(f(2.0), "2nd");
703 assert_eq!(f(3.0), "3rd");
704 assert_eq!(f(4.0), "4th");
705 assert_eq!(f(11.0), "11th");
706 assert_eq!(f(22.0), "22nd");
707 }
708
709 #[test]
710 fn test_label_bytes() {
711 let f = label_bytes(false);
712 assert_eq!(f(500.0), "500 B");
713 assert_eq!(f(1500.0), "1.5 kB");
714 assert_eq!(f(2_000_000.0), "2 MB");
715 let b = label_bytes(true);
716 assert_eq!(b(1024.0), "1 KiB");
717 }
718}