Skip to main content

ggplot_rs/
locale.rs

1//! Locale conventions for number and date labels.
2//!
3//! A [`Locale`] bundles the decimal / thousands marks, the currency and
4//! percent conventions and the month / weekday names of a region. It is used
5//!
6//! - explicitly, by the `label_*_locale` formatters in
7//!   [`crate::scale::format`] (e.g. [`label_currency_locale`]);
8//! - plot-wide, via [`GGPlot::locale`](crate::GGPlot::locale): every default
9//!   continuous axis and legend then labels its breaks with the locale's
10//!   marks, and date/datetime scales use its month names and date formats;
11//! - per scale, via `ScaleContinuous::with_locale` /
12//!   `ScaleDateTime::with_locale` (overrides the plot locale).
13//!
14//! Without a locale nothing changes: labels keep the `.` decimal mark, no
15//! thousands grouping on default axes, and ISO dates.
16//!
17//! ```
18//! use ggplot_rs::locale::Locale;
19//! use ggplot_rs::scale::format::label_currency_locale;
20//! let de = Locale::from_tag("de-DE");
21//! assert_eq!(label_currency_locale(&de, Some(0.01))(1234.5), "1.234,50 €");
22//! let us = Locale::en_us();
23//! assert_eq!(label_currency_locale(&us, Some(0.01))(1234.5), "$1,234.50");
24//! assert_eq!(de.strftime(1_792_022_400, "%-d. %b %Y"), "15. Okt. 2026");
25//! ```
26//!
27//! [`label_currency_locale`]: crate::scale::format::label_currency_locale
28
29/// Where a locale puts the currency symbol relative to the amount.
30#[derive(Clone, Copy, Debug, PartialEq, Eq)]
31pub enum CurrencyPosition {
32    /// Symbol before the amount (`$1,234.50`, `£5`).
33    Prefix,
34    /// Symbol after the amount (`1.234,50 €`).
35    Suffix,
36}
37
38/// Number and date formatting conventions of a locale.
39///
40/// Construct one with a preset ([`en_us`](Self::en_us), [`de_de`](Self::de_de),
41/// [`fr_fr`](Self::fr_fr), [`en_gb`](Self::en_gb)) or [`from_tag`](Self::from_tag),
42/// then adjust the public fields or use the `with_*` builders. The struct is
43/// `#[non_exhaustive]` so new conventions can be added without a breaking
44/// change.
45#[derive(Clone, Debug, PartialEq)]
46#[non_exhaustive]
47pub struct Locale {
48    /// Canonical BCP-47 tag of the preset, e.g. `"de-DE"`.
49    pub tag: String,
50    /// Decimal mark (`"."` / `","`).
51    pub decimal_mark: String,
52    /// Thousands separator (`","`, `"."`, `" "`, `"'"`; `""` disables grouping).
53    pub big_mark: String,
54    /// Group thousands only when the integer part has at least
55    /// `3 + min_grouping_digits` digits (CLDR's minimum grouping digits).
56    /// `1` (every preset) groups `1000` as `1.000`; `2` leaves `2020` alone
57    /// and groups from `10.000`.
58    pub min_grouping_digits: usize,
59    /// The locale's currency symbol (`"$"`, `"€"`, `"£"`).
60    pub currency_symbol: String,
61    /// Where the currency symbol goes.
62    pub currency_position: CurrencyPosition,
63    /// Text between amount and currency symbol (`""` or `" "`).
64    pub currency_spacing: String,
65    /// Suffix of percentages (`"%"` in English, `" %"` in German / French).
66    pub percent_suffix: String,
67    /// Abbreviated month names, January first (`%b`).
68    pub months_short: [String; 12],
69    /// Full month names, January first (`%B`).
70    pub months_long: [String; 12],
71    /// Abbreviated weekday names, Sunday first (`%a`).
72    pub days_short: [String; 7],
73    /// Full weekday names, Sunday first (`%A`).
74    pub days_long: [String; 7],
75    /// strftime format of a calendar date (default date-axis labels at day
76    /// resolution), e.g. `"%-d. %b %Y"` → `15. Okt. 2026`.
77    pub date_format: String,
78    /// strftime format of a month (default monthly date-axis labels), e.g.
79    /// `"%b %Y"` → `Okt. 2026`.
80    pub month_format: String,
81    /// strftime format of a time of day, appended to the date for datetimes
82    /// that are not at midnight, e.g. `"%H:%M"`.
83    pub time_format: String,
84}
85
86fn strings<const N: usize>(names: [&str; N]) -> [String; N] {
87    names.map(str::to_string)
88}
89
90const EN_MONTHS_SHORT: [&str; 12] = [
91    "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
92];
93const EN_MONTHS_LONG: [&str; 12] = [
94    "January",
95    "February",
96    "March",
97    "April",
98    "May",
99    "June",
100    "July",
101    "August",
102    "September",
103    "October",
104    "November",
105    "December",
106];
107const EN_DAYS_SHORT: [&str; 7] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
108const EN_DAYS_LONG: [&str; 7] = [
109    "Sunday",
110    "Monday",
111    "Tuesday",
112    "Wednesday",
113    "Thursday",
114    "Friday",
115    "Saturday",
116];
117
118impl Locale {
119    /// English (United States): `1,234.5`, `$1,234.50`, `50%`, `Oct 15, 2026`.
120    pub fn en_us() -> Self {
121        Locale {
122            tag: "en-US".into(),
123            decimal_mark: ".".into(),
124            big_mark: ",".into(),
125            min_grouping_digits: 1,
126            currency_symbol: "$".into(),
127            currency_position: CurrencyPosition::Prefix,
128            currency_spacing: String::new(),
129            percent_suffix: "%".into(),
130            months_short: strings(EN_MONTHS_SHORT),
131            months_long: strings(EN_MONTHS_LONG),
132            days_short: strings(EN_DAYS_SHORT),
133            days_long: strings(EN_DAYS_LONG),
134            date_format: "%b %-d, %Y".into(),
135            month_format: "%b %Y".into(),
136            time_format: "%H:%M".into(),
137        }
138    }
139
140    /// English (United Kingdom): `1,234.5`, `£1,234.50`, `50%`, `15 Oct 2026`.
141    pub fn en_gb() -> Self {
142        Locale {
143            tag: "en-GB".into(),
144            currency_symbol: "£".into(),
145            date_format: "%-d %b %Y".into(),
146            ..Self::en_us()
147        }
148    }
149
150    /// German (Germany): `1.234,5`, `1.234,50 €`, `50 %`, `15. Okt. 2026`.
151    pub fn de_de() -> Self {
152        Locale {
153            tag: "de-DE".into(),
154            decimal_mark: ",".into(),
155            big_mark: ".".into(),
156            min_grouping_digits: 1,
157            currency_symbol: "€".into(),
158            currency_position: CurrencyPosition::Suffix,
159            currency_spacing: " ".into(),
160            percent_suffix: " %".into(),
161            months_short: strings([
162                "Jan.", "Feb.", "März", "Apr.", "Mai", "Juni", "Juli", "Aug.", "Sept.", "Okt.",
163                "Nov.", "Dez.",
164            ]),
165            months_long: strings([
166                "Januar",
167                "Februar",
168                "März",
169                "April",
170                "Mai",
171                "Juni",
172                "Juli",
173                "August",
174                "September",
175                "Oktober",
176                "November",
177                "Dezember",
178            ]),
179            days_short: strings(["So.", "Mo.", "Di.", "Mi.", "Do.", "Fr.", "Sa."]),
180            days_long: strings([
181                "Sonntag",
182                "Montag",
183                "Dienstag",
184                "Mittwoch",
185                "Donnerstag",
186                "Freitag",
187                "Samstag",
188            ]),
189            date_format: "%-d. %b %Y".into(),
190            month_format: "%b %Y".into(),
191            time_format: "%H:%M".into(),
192        }
193    }
194
195    /// French (France): `1 234,5`, `1 234,50 €`, `50 %`, `15 oct. 2026`.
196    /// The thousands mark is a plain space (CLDR uses a narrow no-break space,
197    /// which many fonts lack); set [`big_mark`](Self::big_mark) to override.
198    pub fn fr_fr() -> Self {
199        Locale {
200            tag: "fr-FR".into(),
201            decimal_mark: ",".into(),
202            big_mark: " ".into(),
203            min_grouping_digits: 1,
204            currency_symbol: "€".into(),
205            currency_position: CurrencyPosition::Suffix,
206            currency_spacing: " ".into(),
207            percent_suffix: " %".into(),
208            months_short: strings([
209                "janv.", "févr.", "mars", "avr.", "mai", "juin", "juil.", "août", "sept.", "oct.",
210                "nov.", "déc.",
211            ]),
212            months_long: strings([
213                "janvier",
214                "février",
215                "mars",
216                "avril",
217                "mai",
218                "juin",
219                "juillet",
220                "août",
221                "septembre",
222                "octobre",
223                "novembre",
224                "décembre",
225            ]),
226            days_short: strings(["dim.", "lun.", "mar.", "mer.", "jeu.", "ven.", "sam."]),
227            days_long: strings([
228                "dimanche", "lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi",
229            ]),
230            date_format: "%-d %b %Y".into(),
231            month_format: "%b %Y".into(),
232            time_format: "%H:%M".into(),
233        }
234    }
235
236    /// The preset for a BCP-47-ish tag, if one matches: case-insensitive,
237    /// `-` or `_` separated, POSIX suffixes (`.UTF-8`, `@euro`) ignored.
238    /// `de*` → [`de_de`](Self::de_de), `fr*` → [`fr_fr`](Self::fr_fr),
239    /// `en-GB` / `en-UK` → [`en_gb`](Self::en_gb), other `en*` and `C` /
240    /// `POSIX` → [`en_us`](Self::en_us).
241    pub fn try_from_tag(tag: &str) -> Option<Self> {
242        let t = tag
243            .trim()
244            .split(['.', '@'])
245            .next()
246            .unwrap_or("")
247            .replace('_', "-")
248            .to_ascii_lowercase();
249        let mut parts = t.split('-');
250        let lang = parts.next().unwrap_or("");
251        // Skip a script subtag (e.g. "Latn") to find the region.
252        let region = parts.find(|p| p.len() != 4).unwrap_or("");
253        match (lang, region) {
254            ("de", _) => Some(Self::de_de()),
255            ("fr", _) => Some(Self::fr_fr()),
256            ("en", "gb" | "uk") => Some(Self::en_gb()),
257            ("en", _) | ("c", "") | ("posix", "") => Some(Self::en_us()),
258            _ => None,
259        }
260    }
261
262    /// Like [`try_from_tag`](Self::try_from_tag), falling back to
263    /// [`en_us`](Self::en_us) for unknown tags.
264    ///
265    /// ```
266    /// use ggplot_rs::locale::Locale;
267    /// assert_eq!(Locale::from_tag("de-DE").tag, "de-DE");
268    /// assert_eq!(Locale::from_tag("fr_FR.UTF-8").tag, "fr-FR");
269    /// assert_eq!(Locale::from_tag("en-GB").tag, "en-GB");
270    /// assert_eq!(Locale::from_tag("xx-YY").tag, "en-US");
271    /// ```
272    pub fn from_tag(tag: &str) -> Self {
273        Self::try_from_tag(tag).unwrap_or_else(Self::en_us)
274    }
275
276    /// Replace the decimal mark.
277    pub fn with_decimal_mark(mut self, mark: &str) -> Self {
278        self.decimal_mark = mark.to_string();
279        self
280    }
281
282    /// Replace the thousands mark (`""` disables grouping).
283    pub fn with_big_mark(mut self, mark: &str) -> Self {
284        self.big_mark = mark.to_string();
285        self
286    }
287
288    /// Replace the minimum grouping digits (see
289    /// [`min_grouping_digits`](Self::min_grouping_digits)).
290    pub fn with_min_grouping_digits(mut self, digits: usize) -> Self {
291        self.min_grouping_digits = digits;
292        self
293    }
294
295    /// Use another currency symbol, placed by this locale's convention:
296    /// `Locale::de_de().with_currency("CHF")` formats `1.234,50 CHF`.
297    pub fn with_currency(mut self, symbol: &str) -> Self {
298        self.currency_symbol = symbol.to_string();
299        self
300    }
301
302    /// `(prefix, suffix)` that place `symbol` by this locale's convention:
303    /// `("$", "")` for en-US, `("", " €")` for de-DE.
304    pub fn currency_affixes(&self, symbol: &str) -> (String, String) {
305        match self.currency_position {
306            CurrencyPosition::Prefix => {
307                (format!("{symbol}{}", self.currency_spacing), String::new())
308            }
309            CurrencyPosition::Suffix => {
310                (String::new(), format!("{}{symbol}", self.currency_spacing))
311            }
312        }
313    }
314
315    /// Re-render a number formatted the default way (`.` decimal mark, `,` or
316    /// no thousands grouping, optional leading `-`) with this locale's marks:
317    /// `"-1234.5"` / `"-1,234.5"` → `"-1.234,5"` in de-DE. Anything that is not
318    /// such a number (`NaN`, `inf`, text) is returned unchanged.
319    pub fn localize_number(&self, s: &str) -> String {
320        localize_number(
321            s,
322            &self.decimal_mark,
323            &self.big_mark,
324            self.min_grouping_digits,
325        )
326    }
327
328    /// Inverse of [`localize_number`](Self::localize_number) for parsing a
329    /// label back to a number: strips thousands marks and restores `.`.
330    pub fn parse_number(&self, s: &str) -> Option<f64> {
331        let mut t = s.trim().to_string();
332        if !self.big_mark.is_empty() {
333            t = t.replace(&self.big_mark, "");
334        }
335        if self.decimal_mark != "." {
336            t = t.replace(&self.decimal_mark, ".");
337        }
338        t.parse().ok()
339    }
340
341    /// Format a break value like the default axis labels (integers verbatim,
342    /// otherwise up to two decimals, trailing zeros trimmed) with this
343    /// locale's marks: `1234.5` → `"1.234,5"` in de-DE.
344    pub fn format_number(&self, v: f64) -> String {
345        crate::scale::util::format_number_locale(v, self)
346    }
347
348    /// Format epoch seconds (UTC) with a strftime-style format, using this
349    /// locale's month and weekday names. Supported: `%Y %y %m %-m %b %B %d
350    /// %-d %e %a %A %H %M %S %%`.
351    pub fn strftime(&self, epoch_secs: i64, fmt: &str) -> String {
352        crate::scale::datetime::strftime_locale(epoch_secs as f64, fmt, Some(self))
353    }
354
355    /// Format epoch seconds (UTC) as this locale's date ([`date_format`](Self::date_format)),
356    /// plus [`time_format`](Self::time_format) when not at midnight.
357    pub fn format_datetime(&self, epoch_secs: i64) -> String {
358        let fmt = if epoch_secs.rem_euclid(86_400) == 0 {
359            self.date_format.clone()
360        } else {
361            format!("{} {}", self.date_format, self.time_format)
362        };
363        self.strftime(epoch_secs, &fmt)
364    }
365}
366
367impl Default for Locale {
368    /// [`Locale::en_us`].
369    fn default() -> Self {
370        Self::en_us()
371    }
372}
373
374impl std::str::FromStr for Locale {
375    type Err = std::convert::Infallible;
376    /// [`Locale::from_tag`] (never fails; unknown tags give en-US).
377    fn from_str(s: &str) -> Result<Self, Self::Err> {
378        Ok(Self::from_tag(s))
379    }
380}
381
382/// Re-render a default-formatted number (`-1,234.5` or `-1234.5`) with the
383/// given marks; see [`Locale::localize_number`].
384pub(crate) fn localize_number(
385    s: &str,
386    decimal_mark: &str,
387    big_mark: &str,
388    min_grouping_digits: usize,
389) -> String {
390    let (sign, rest) = match s.strip_prefix('-') {
391        Some(r) => ("-", r),
392        None => ("", s),
393    };
394    let (int_part, dec_part) = match rest.split_once('.') {
395        Some((i, d)) => (i, Some(d)),
396        None => (rest, None),
397    };
398    let digits: String = int_part.chars().filter(|&c| c != ',').collect();
399    let is_number = !digits.is_empty()
400        && digits.chars().all(|c| c.is_ascii_digit())
401        && int_part.chars().all(|c| c.is_ascii_digit() || c == ',')
402        && dec_part.is_none_or(|d| d.chars().all(|c| c.is_ascii_digit()));
403    if !is_number {
404        return s.to_string();
405    }
406    let n = digits.len();
407    let mut out = String::with_capacity(s.len() + n / 3 * big_mark.len());
408    out.push_str(sign);
409    let group = !big_mark.is_empty() && n >= 3 + min_grouping_digits.max(1);
410    for (i, ch) in digits.chars().enumerate() {
411        if group && i > 0 && (n - i).is_multiple_of(3) {
412            out.push_str(big_mark);
413        }
414        out.push(ch);
415    }
416    if let Some(d) = dec_part {
417        out.push_str(decimal_mark);
418        out.push_str(d);
419    }
420    out
421}
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426
427    #[test]
428    fn localize_regroups_and_swaps_marks() {
429        let de = Locale::de_de();
430        assert_eq!(de.localize_number("1234.5"), "1.234,5");
431        assert_eq!(de.localize_number("-1,234,567.25"), "-1.234.567,25");
432        assert_eq!(de.localize_number("999"), "999");
433        assert_eq!(de.localize_number("0.5"), "0,5");
434        assert_eq!(de.localize_number("NaN"), "NaN");
435        assert_eq!(de.localize_number("-inf"), "-inf");
436        assert_eq!(de.localize_number(""), "");
437        let fr = Locale::fr_fr();
438        assert_eq!(fr.localize_number("1234567.5"), "1 234 567,5");
439        let no_group = Locale::de_de().with_big_mark("");
440        assert_eq!(no_group.localize_number("1,234.5"), "1234,5");
441        let min2 = Locale::de_de().with_min_grouping_digits(2);
442        assert_eq!(min2.localize_number("2020"), "2020");
443        assert_eq!(min2.localize_number("20200"), "20.200");
444    }
445
446    #[test]
447    fn parse_number_inverts_localize() {
448        let de = Locale::de_de();
449        assert_eq!(de.parse_number("1.234,5"), Some(1234.5));
450        assert_eq!(de.parse_number("-0,25"), Some(-0.25));
451        assert_eq!(Locale::fr_fr().parse_number("1 000"), Some(1000.0));
452        assert_eq!(Locale::en_us().parse_number("1,000.5"), Some(1000.5));
453    }
454
455    #[test]
456    fn tags_resolve_to_presets() {
457        for (tag, want) in [
458            ("de-DE", "de-DE"),
459            ("de", "de-DE"),
460            ("DE_at", "de-DE"),
461            ("de_DE.UTF-8", "de-DE"),
462            ("de_DE@euro", "de-DE"),
463            ("fr-FR", "fr-FR"),
464            ("fr-CA", "fr-FR"),
465            ("en-GB", "en-GB"),
466            ("en_gb", "en-GB"),
467            ("en-Latn-GB", "en-GB"),
468            ("en-US", "en-US"),
469            ("en", "en-US"),
470            ("C", "en-US"),
471            ("", "en-US"),
472            ("ja-JP", "en-US"),
473        ] {
474            assert_eq!(Locale::from_tag(tag).tag, want, "{tag:?}");
475        }
476        assert!(Locale::try_from_tag("ja-JP").is_none());
477        assert!(Locale::try_from_tag("").is_none());
478        assert_eq!("de-DE".parse::<Locale>().unwrap(), Locale::de_de());
479        assert_eq!(Locale::default(), Locale::en_us());
480    }
481
482    #[test]
483    fn currency_affixes_follow_placement() {
484        assert_eq!(
485            Locale::en_us().currency_affixes("$"),
486            ("$".into(), String::new())
487        );
488        assert_eq!(
489            Locale::de_de().currency_affixes("€"),
490            (String::new(), " €".into())
491        );
492        assert_eq!(
493            Locale::en_gb().currency_affixes("£"),
494            ("£".into(), String::new())
495        );
496    }
497
498    #[test]
499    fn dates_per_locale() {
500        let t = 1_792_022_400; // 2026-10-15 00:00 UTC (Thursday)
501        assert_eq!(Locale::de_de().format_datetime(t), "15. Okt. 2026");
502        assert_eq!(Locale::en_us().format_datetime(t), "Oct 15, 2026");
503        assert_eq!(Locale::en_gb().format_datetime(t), "15 Oct 2026");
504        assert_eq!(Locale::fr_fr().format_datetime(t), "15 oct. 2026");
505        assert_eq!(
506            Locale::de_de().format_datetime(t + 9 * 3600 + 30 * 60),
507            "15. Okt. 2026 09:30"
508        );
509        assert_eq!(
510            Locale::de_de().strftime(t, "%A, %-d. %B %Y"),
511            "Donnerstag, 15. Oktober 2026"
512        );
513        assert_eq!(Locale::fr_fr().strftime(t, "%a %-d %B"), "jeu. 15 octobre");
514        assert_eq!(Locale::en_us().strftime(t, "%a %A"), "Thu Thursday");
515        assert_eq!(Locale::de_de().strftime(t - 86_400 * 200, "%B"), "März");
516    }
517}