Skip to main content

day_localization/
decimal.rs

1// Copyright © The Daybrite Project
2// SPDX-License-Identifier: MPL-2.0
3
4//! Locale-aware decimal formatting (docs/localization.md "Numbers outside a message"), backed by
5//! icu4x's `DecimalFormatter`, the same engine the Fluent `NUMBER()` builtin uses, reached
6//! without going through a message.
7//!
8//! A bare number rendered with `format!("{v}")` carries an English decimal point and no digit
9//! grouping, which is wrong in most locales and unreadable past four digits in any of them. Chart
10//! axes, table columns and readouts all need the grouped form and none of them have a message to
11//! hang it on, so the formatter is public here rather than reachable only through `tr()`.
12//!
13//! Formatters are cached per (locale, fraction digits) in a thread-local, the way `collate` caches
14//! collators; on any failure the value degrades to `format!` rather than erroring.
15
16use std::cell::RefCell;
17use std::collections::HashMap;
18
19use icu_decimal::DecimalFormatter;
20use icu_decimal::input::{Decimal, FloatPrecision};
21use icu_decimal::options::{DecimalFormatterOptions, GroupingStrategy};
22
23day_reactive::tls_slots! {
24    decimal;
25    /// `None` = construction failed for that locale (negative-cached to avoid re-trying per call).
26    static FORMATTERS: RefCell<HashMap<(String, bool), Option<DecimalFormatter>>> =
27        RefCell::new(HashMap::new());
28
29}
30
31fn with_formatter<R>(
32    locale: &str,
33    grouping: bool,
34    f: impl FnOnce(Option<&DecimalFormatter>) -> R,
35) -> R {
36    FORMATTERS.with(|c| {
37        let mut map = c.borrow_mut();
38        let entry = map
39            .entry((locale.to_string(), grouping))
40            .or_insert_with(|| {
41                let parsed: icu_locale_core::Locale = locale.parse().ok()?;
42                let mut opts = DecimalFormatterOptions::default();
43                opts.grouping_strategy = Some(if grouping {
44                    GroupingStrategy::Auto
45                } else {
46                    GroupingStrategy::Never
47                });
48                DecimalFormatter::try_new((&parsed).into(), opts).ok()
49            });
50        f(entry.as_ref())
51    })
52}
53
54/// Render `v` in `locale`, rounded to `fraction_digits` and grouped by that locale's own rule
55/// (untracked). `1234.5` is `1,234.5` in `en`, `1 234,5` in `fr`, `1.234,5` in `de`.
56///
57/// Grouping follows the locale's CLDR rule rather than a fixed every-three: `en-IN` groups the
58/// lakh way, and several locales suppress grouping for four-digit numbers entirely.
59pub fn format_decimal_in(locale: &str, v: f64, fraction_digits: usize) -> String {
60    if !v.is_finite() {
61        return format!("{v}");
62    }
63    let Ok(mut d) = Decimal::try_from_f64(v, FloatPrecision::RoundTrip) else {
64        return format!("{v:.*}", fraction_digits);
65    };
66    let places = fraction_digits.min(20) as i16;
67    d.round(-places);
68    // `round` extends the range with trailing zeros; padding back to the same place keeps exactly
69    // the digits asked for, so a column of values stays aligned.
70    d.trim_end();
71    d.pad_end(-places);
72    with_formatter(locale, true, |f| match f {
73        Some(f) => f.format_to_string(&d),
74        None => format!("{v:.*}", fraction_digits),
75    })
76}
77
78/// Render `v` in the current locale (tracked, like [`crate::compare`]), so a label inside a
79/// reactive closure re-renders when the locale switches.
80pub fn format_decimal(v: f64, fraction_digits: usize) -> String {
81    let locale = crate::locale().get();
82    format_decimal_in(&locale, v, fraction_digits)
83}
84
85/// Where a locale puts the percent sign, and what separates it from the number: CLDR's standard
86/// percent pattern for the language, as (before, after).
87///
88/// icu4x's percent formatter is still experimental (outside the stable components this crate
89/// depends on), so the placement is this table of CLDR patterns, keyed by language, for the
90/// locales whose pattern is not a bare trailing `%`. The number itself is always the stable
91/// `DecimalFormatter`'s, so grouping, the decimal mark and the digit system are exact; Arabic-
92/// Indic digits take the Arabic percent sign.
93pub(crate) fn percent_affixes(locale: &str, number: &str) -> (&'static str, &'static str) {
94    let lang = locale.split(['-', '_']).next().unwrap_or("");
95    let region = locale.split(['-', '_']).nth(1).unwrap_or("");
96    if number
97        .chars()
98        .any(|c| ('\u{0660}'..='\u{0669}').contains(&c))
99    {
100        return ("", "\u{066A}");
101    }
102    match lang {
103        // A narrow no-break space: `50 %`.
104        "fr" if region != "CH" => ("", "\u{202F}%"),
105        // A no-break space: `50 %`. Swiss and Liechtenstein German write `50%`.
106        "de" if !matches!(region, "CH" | "LI") => ("", "\u{A0}%"),
107        "es" | "ca" | "sv" | "nb" | "nn" | "no" | "da" | "fi" | "ru" | "cs" | "sk" => {
108            ("", "\u{A0}%")
109        }
110        // The sign first: `%50`, and in Basque `% 50`.
111        "tr" => ("%", ""),
112        "eu" => ("%\u{A0}", ""),
113        _ => ("", "%"),
114    }
115}
116
117/// Render the fraction `v` as a percentage in `locale` (`0.5` is `50%` in `en`, `50 %` in `fr`,
118/// `%50` in `tr`), rounded to `fraction_digits` places of the percentage and grouped by the
119/// locale's rule (untracked).
120pub fn format_percent_in(locale: &str, v: f64, fraction_digits: usize) -> String {
121    let number = format_decimal_in(locale, v * 100.0, fraction_digits);
122    if !v.is_finite() {
123        return number;
124    }
125    let (before, after) = percent_affixes(locale, &number);
126    format!("{before}{number}{after}")
127}
128
129/// [`format_percent_in`] in the current locale (tracked), so a label inside a reactive closure
130/// re-renders when the locale switches.
131pub fn format_percent(v: f64, fraction_digits: usize) -> String {
132    let locale = crate::locale().get();
133    format_percent_in(&locale, v, fraction_digits)
134}
135
136#[cfg(test)]
137mod tests {
138    use super::{format_decimal_in, format_percent_in};
139
140    #[test]
141    fn a_percentage_puts_its_sign_where_the_locale_does() {
142        assert_eq!(format_percent_in("en", 0.5, 0), "50%");
143        assert_eq!(format_percent_in("en", 0.1234, 1), "12.3%");
144        assert_eq!(format_percent_in("fr", 0.5, 0), "50\u{202F}%");
145        assert_eq!(format_percent_in("fr", 0.1234, 1), "12,3\u{202F}%");
146        assert_eq!(format_percent_in("de", 0.5, 0), "50\u{A0}%");
147        assert_eq!(format_percent_in("de-CH", 0.5, 0), "50%");
148        assert_eq!(format_percent_in("tr", 0.5, 0), "%50");
149        assert_eq!(format_percent_in("ja", 0.5, 0), "50%");
150        // Grouped like any other number, and negative on the number's side of the sign.
151        assert_eq!(format_percent_in("en", 12.5, 0), "1,250%");
152        assert_eq!(format_percent_in("en", -0.25, 0), "-25%");
153        assert_eq!(format_percent_in("en", f64::NAN, 0), "NaN");
154    }
155
156    #[test]
157    fn grouping_and_separators_follow_the_locale() {
158        assert_eq!(format_decimal_in("en", 1_234_567.0, 0), "1,234,567");
159        assert_eq!(format_decimal_in("de", 1_234_567.0, 0), "1.234.567");
160        // fr uses a comma for the decimal mark and a narrow no-break space between groups; the
161        // exact space is CLDR's to choose, so the assertion asks what kind it is, not which.
162        let fr = format_decimal_in("fr", 1234.5, 1);
163        assert!(
164            fr.ends_with("234,5"),
165            "fr marks the decimal with a comma: {fr}"
166        );
167        let sep = fr
168            .chars()
169            .nth(1)
170            .expect("a grouped four-digit number has a separator");
171        assert!(sep.is_whitespace(), "fr groups with a space: {fr:?}");
172    }
173
174    #[test]
175    fn fraction_digits_are_exact_in_both_directions() {
176        assert_eq!(format_decimal_in("en", 2.0, 2), "2.00");
177        assert_eq!(format_decimal_in("en", 2.345, 1), "2.3");
178        assert_eq!(format_decimal_in("en", 0.0, 0), "0");
179    }
180
181    #[test]
182    fn an_unparseable_locale_still_renders_the_number() {
183        // Nothing here is allowed to swallow the value: an unusable locale falls back to root
184        // data or to `format!`, and either way the digits come out.
185        let s = format_decimal_in("@@@", 1500.0, 0);
186        assert!(s.contains('1') && s.contains("500"), "{s}");
187    }
188
189    #[test]
190    fn non_finite_values_do_not_panic() {
191        assert_eq!(format_decimal_in("en", f64::NAN, 0), "NaN");
192        assert_eq!(format_decimal_in("en", f64::INFINITY, 0), "inf");
193    }
194}