Skip to main content

dre_core/
numbers.rs

1//! Number filters for every template: `number`, `percent`, `signed`, `currency` and `compact`,
2//! formatted for the run's `locale:`.
3//!
4//! DRE carries its own small table of locale conventions (decimal and group separators, where
5//! the currency symbol and percent sign go) instead of a full CLDR library: it covers the
6//! languages below, and a region changes the conventions only where the table says so.
7
8use minijinja::value::Kwargs;
9use minijinja::{Environment, Error, ErrorKind, Value};
10
11/// How numbers are written in one locale.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub struct Locale {
14    /// The tag as written (`de-DE`), for messages.
15    pub tag: &'static str,
16    decimal: char,
17    group: &'static str,
18    /// `€12,340` (false) or `12.340 €` (true).
19    currency_after: bool,
20    /// A space between the symbol and the amount when the symbol comes first (`€ 12.340`).
21    currency_space: bool,
22    /// `4,1 %` rather than `4.1%`.
23    percent_space: bool,
24}
25
26const NBSP: &str = "\u{a0}";
27const NNBSP: &str = "\u{202f}";
28
29const fn loc(
30    tag: &'static str,
31    decimal: char,
32    group: &'static str,
33    currency_after: bool,
34    currency_space: bool,
35    percent_space: bool,
36) -> Locale {
37    Locale {
38        tag,
39        decimal,
40        group,
41        currency_after,
42        currency_space,
43        percent_space,
44    }
45}
46
47/// Languages, then language-region pairs that differ from their language.
48const LOCALES: &[Locale] = &[
49    loc("en", '.', ",", false, false, false),
50    loc("de", ',', ".", true, false, true),
51    loc("de-CH", '.', "\u{2019}", false, true, false),
52    loc("fr", ',', NNBSP, true, false, true),
53    loc("fr-CH", ',', NNBSP, true, false, true),
54    loc("it", ',', ".", true, false, false),
55    loc("it-CH", '.', "\u{2019}", false, true, false),
56    loc("es", ',', ".", true, false, true),
57    loc("es-MX", '.', ",", false, false, false),
58    loc("es-US", '.', ",", false, false, false),
59    loc("nl", ',', ".", false, true, false),
60    loc("pt", ',', NBSP, true, false, false),
61    loc("pt-BR", ',', ".", false, true, false),
62    loc("sv", ',', NBSP, true, false, true),
63    loc("da", ',', ".", true, false, true),
64    loc("nb", ',', NBSP, true, false, true),
65    loc("no", ',', NBSP, true, false, true),
66    loc("fi", ',', NBSP, true, false, true),
67    loc("pl", ',', NBSP, true, false, false),
68    loc("cs", ',', NBSP, true, false, true),
69    loc("ja", '.', ",", false, false, false),
70    loc("zh", '.', ",", false, false, false),
71    loc("ko", '.', ",", false, false, false),
72];
73
74impl Default for Locale {
75    fn default() -> Self {
76        LOCALES[0]
77    }
78}
79
80impl Locale {
81    /// The conventions for a BCP 47 tag like `de`, `de-DE` or `pt_BR`: its language-region
82    /// entry, else its language's.
83    pub fn parse(tag: &str) -> Result<Locale, String> {
84        let norm = tag.replace('_', "-");
85        let mut parts = norm.split('-');
86        let lang = parts.next().unwrap_or_default().to_ascii_lowercase();
87        let region = parts.next().map(str::to_ascii_uppercase);
88        let valid = (2..=3).contains(&lang.len())
89            && lang.chars().all(|c| c.is_ascii_alphabetic())
90            && region
91                .as_ref()
92                .is_none_or(|r| (2..=3).contains(&r.len()) && r.chars().all(|c| c.is_ascii_alphanumeric()))
93            && parts.next().is_none();
94        let unknown = || {
95            let langs: Vec<&str> = LOCALES
96                .iter()
97                .map(|l| l.tag)
98                .filter(|t| !t.contains('-'))
99                .collect();
100            format!(
101                "unknown locale `{tag}`; use a language DRE knows ({}), optionally with a region (`de-DE`)",
102                langs.join(", ")
103            )
104        };
105        if !valid {
106            return Err(unknown());
107        }
108        if let Some(r) = &region
109            && let Some(l) = LOCALES.iter().find(|l| l.tag == format!("{lang}-{r}"))
110        {
111            return Ok(*l);
112        }
113        LOCALES
114            .iter()
115            .find(|l| l.tag == lang)
116            .copied()
117            .ok_or_else(unknown)
118    }
119
120    /// `x` rounded half away from zero to `decimals` places, with group and decimal separators.
121    /// Negative numbers get a leading `-`.
122    pub fn number(&self, x: f64, decimals: usize) -> String {
123        let scale = 10f64.powi(decimals as i32);
124        let rounded = (x.abs() * scale).round() / scale;
125        let text = format!("{rounded:.decimals$}");
126        let (int, frac) = match text.split_once('.') {
127            Some((i, f)) => (i, Some(f)),
128            None => (text.as_str(), None),
129        };
130        let mut grouped = String::new();
131        for (i, c) in int.chars().enumerate() {
132            if i > 0 && (int.len() - i).is_multiple_of(3) {
133                grouped.push_str(self.group);
134            }
135            grouped.push(c);
136        }
137        if let Some(f) = frac {
138            grouped.push(self.decimal);
139            grouped.push_str(f);
140        }
141        if x < 0.0 && rounded != 0.0 {
142            format!("-{grouped}")
143        } else {
144            grouped
145        }
146    }
147
148    pub fn percent(&self, x: f64, decimals: usize) -> String {
149        let n = self.number(x * 100.0, decimals);
150        if self.percent_space {
151            format!("{n}{NBSP}%")
152        } else {
153            format!("{n}%")
154        }
155    }
156
157    pub fn currency(&self, x: f64, code: &str, decimals: usize) -> String {
158        let symbol = currency_symbol(code);
159        let n = self.number(x.abs(), decimals);
160        let sign = if x < 0.0 && n.chars().any(|c| c.is_ascii_digit() && c != '0') {
161            "-"
162        } else {
163            ""
164        };
165        // A code with no symbol (`CHF`) is always followed or preceded by a space.
166        let spaced = self.currency_space || symbol.chars().all(|c| c.is_ascii_uppercase());
167        if self.currency_after {
168            format!("{sign}{n}{NBSP}{symbol}")
169        } else if spaced {
170            format!("{sign}{symbol}{NBSP}{n}")
171        } else {
172            format!("{sign}{symbol}{n}")
173        }
174    }
175
176    /// `1234567` → `1.2M`: thousands (`K`), millions (`M`), billions (`B`) and trillions (`T`),
177    /// trailing zeros dropped.
178    pub fn compact(&self, x: f64, decimals: usize) -> String {
179        const UNITS: &[(f64, &str)] = &[(1e12, "T"), (1e9, "B"), (1e6, "M"), (1e3, "K")];
180        let scale = 10f64.powi(decimals as i32);
181        for (size, unit) in UNITS {
182            let v = (x.abs() / size * scale).round() / scale;
183            if v >= 1.0 {
184                let n = self.number(v, decimals);
185                let n = trim_zeros(&n, self.decimal);
186                let sign = if x < 0.0 { "-" } else { "" };
187                return format!("{sign}{n}{unit}");
188            }
189        }
190        trim_zeros(&self.number(x, decimals), self.decimal)
191    }
192}
193
194fn trim_zeros(n: &str, decimal: char) -> String {
195    if n.contains(decimal) {
196        n.trim_end_matches('0').trim_end_matches(decimal).to_string()
197    } else {
198        n.to_string()
199    }
200}
201
202fn currency_symbol(code: &str) -> &str {
203    match code {
204        "EUR" => "€",
205        "USD" => "$",
206        "GBP" => "£",
207        "JPY" | "CNY" => "¥",
208        "INR" => "₹",
209        "KRW" => "₩",
210        "BRL" => "R$",
211        "AUD" => "A$",
212        "CAD" => "CA$",
213        "NZD" => "NZ$",
214        "MXN" => "MX$",
215        "SEK" | "NOK" | "DKK" => "kr",
216        "PLN" => "zł",
217        "CZK" => "Kč",
218        "ILS" => "₪",
219        "TRY" => "₺",
220        "ZAR" => "R",
221        other => other,
222    }
223}
224
225/// The number in `v`, or `None` for `none`/undefined (rendered as nothing).
226fn to_number(filter: &str, v: &Value) -> Result<Option<f64>, Error> {
227    if v.is_none() || v.is_undefined() {
228        return Ok(None);
229    }
230    if let Some(s) = v.as_str() {
231        return s.trim().parse::<f64>().map(Some).map_err(|_| {
232            Error::new(
233                ErrorKind::InvalidOperation,
234                format!("`{filter}` needs a number, but got the text {s:?}"),
235            )
236        });
237    }
238    f64::try_from(v.clone()).map(Some).map_err(|_| {
239        Error::new(
240            ErrorKind::InvalidOperation,
241            format!("`{filter}` needs a number, but got {} `{v}`", v.kind()),
242        )
243    })
244}
245
246fn decimals(kwargs: &Kwargs, positional: Option<u32>, default: usize) -> Result<usize, Error> {
247    let named: Option<u32> = kwargs.get("decimals")?;
248    let d = named.or(positional).map_or(default, |d| d as usize);
249    if d > 12 {
250        return Err(Error::new(
251            ErrorKind::InvalidOperation,
252            "`decimals` must be between 0 and 12",
253        ));
254    }
255    Ok(d)
256}
257
258/// Add the number filters, formatting for `locale`.
259pub fn register(env: &mut Environment<'static>, locale: Locale) {
260    env.add_filter(
261        "number",
262        move |v: Value, d: Option<u32>, kwargs: Kwargs| -> Result<String, Error> {
263            let d = decimals(&kwargs, d, 0)?;
264            kwargs.assert_all_used()?;
265            Ok(to_number("number", &v)?.map_or_else(String::new, |x| locale.number(x, d)))
266        },
267    );
268    env.add_filter(
269        "percent",
270        move |v: Value, d: Option<u32>, kwargs: Kwargs| -> Result<String, Error> {
271            let d = decimals(&kwargs, d, 1)?;
272            kwargs.assert_all_used()?;
273            Ok(to_number("percent", &v)?.map_or_else(String::new, |x| locale.percent(x, d)))
274        },
275    );
276    env.add_filter(
277        "currency",
278        move |v: Value, code: Option<String>, d: Option<u32>, kwargs: Kwargs| -> Result<String, Error> {
279            let code = match code {
280                Some(c) => Some(c),
281                None => kwargs.get::<Option<String>>("code")?,
282            };
283            let d = decimals(&kwargs, d, 0)?;
284            kwargs.assert_all_used()?;
285            let code = code.ok_or_else(|| {
286                Error::new(
287                    ErrorKind::MissingArgument,
288                    "`currency` needs a currency code, like `currency('EUR')`",
289                )
290            })?;
291            if code.len() != 3 || !code.chars().all(|c| c.is_ascii_uppercase()) {
292                return Err(Error::new(
293                    ErrorKind::InvalidOperation,
294                    format!(
295                        "`currency('{code}')`: the code must be three capital letters (ISO 4217), like `EUR`"
296                    ),
297                ));
298            }
299            Ok(to_number("currency", &v)?.map_or_else(String::new, |x| locale.currency(x, &code, d)))
300        },
301    );
302    env.add_filter(
303        "compact",
304        move |v: Value, d: Option<u32>, kwargs: Kwargs| -> Result<String, Error> {
305            let d = decimals(&kwargs, d, 1)?;
306            kwargs.assert_all_used()?;
307            Ok(to_number("compact", &v)?.map_or_else(String::new, |x| locale.compact(x, d)))
308        },
309    );
310    // A number gets `+` or `−`; text another filter made (`0.04 | percent | signed`) gets `+`
311    // unless it's negative or zero.
312    env.add_filter(
313        "signed",
314        move |v: Value, d: Option<u32>, kwargs: Kwargs| -> Result<String, Error> {
315            let d = decimals(&kwargs, d, 0)?;
316            kwargs.assert_all_used()?;
317            let text = match v.as_str() {
318                Some(s) => s.to_string(),
319                None => match to_number("signed", &v)? {
320                    Some(x) => locale.number(x, d),
321                    None => return Ok(String::new()),
322                },
323            };
324            Ok(signed(&text))
325        },
326    );
327}
328
329fn signed(text: &str) -> String {
330    let t = text.trim_start();
331    if let Some(rest) = t.strip_prefix('-').or_else(|| t.strip_prefix('\u{2212}')) {
332        return format!("\u{2212}{rest}");
333    }
334    if t.starts_with('+') || !t.chars().any(|c| c.is_ascii_digit() && c != '0') {
335        return t.to_string();
336    }
337    format!("+{t}")
338}