From cc7020654e0672ed2fedbbd0ecf1edc17c1aef47 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 23 Oct 2026 05:39:08 +0000 Subject: [PATCH] i18n: the C locale is A4, metric and ungrouped; a chosen digit system stays in the locale --- crates/i18n/README.md | 10 +++--- crates/i18n/src/region.rs | 76 +++++++++++++++++++++++++++++---------- 2 files changed, 64 insertions(+), 22 deletions(-) diff --git a/crates/i18n/README.md b/crates/i18n/README.md index 85f75c8b..3cb0098a 100644 --- a/crates/i18n/README.md +++ b/crates/i18n/README.md @@ -74,10 +74,12 @@ The app keeps its `tl!` macro, its language setting (`"auto"` → `I18N.system_l sets them; otherwise the number locale's CLDR data decides. `Region::system()` reads it once: Windows' regional format with the user's changes (`HKCU\Control Panel\International`), Apple's current locale (`en_PL`, `en_GB@rg=uszzzz`), `LC_ALL` or else `LC_NUMERIC`, `LC_TIME`, - `LC_MEASUREMENT` and `LC_PAPER` each on its own over `LANG` on Unix (`C` there is `en-US`; - `LANG=C` gives way to the preferred language), the preferred language on the web and Android. - `Region::from_setting` takes the app's saved choice (`auto` or a tag like `pl-PL`). Paper and - measurement follow CLDR's territory lists; tags may carry `-u-ms-`, `-u-hc-` and `-u-rg-`. + `LC_MEASUREMENT` and `LC_PAPER` each on its own over `LANG` on Unix (`C` there is English on A4, + metric, with a `.` decimal and no grouping; `LANG=C` gives way to the preferred language), the + preferred language on the web and Android. `Region::from_setting` takes the app's saved choice + (`auto` or a tag like `pl-PL`). Paper and measurement follow CLDR's territory lists; tags may + carry `-u-ms-`, `-u-hc-`, `-u-rg-` and `-u-nu-` (Apple's `@numbers=`), and a chosen digit + system stays in the number locale. Ported from WordCraft's `Region`. - **Pseudo locale** (`pseudo`, debug builds only, never in `languages()`): every lookup, `get` included, comes back as `⟦…⟧`, line by line, so text drawn without the marks never went through diff --git a/crates/i18n/src/region.rs b/crates/i18n/src/region.rs index 72342836..72cd80d4 100644 --- a/crates/i18n/src/region.rs +++ b/crates/i18n/src/region.rs @@ -69,11 +69,18 @@ impl Default for Region { impl Region { /// The region a BCP 47 or POSIX locale tag names (`pl-PL`, `de_AT.UTF-8@euro`, `zh-Hans-CN`, /// `en-US-u-ms-metric`, Apple's `en_US@rg=plzzzz`). The Unicode extensions `ms` (measurement), - /// `hc` (clock) and `rg` (region override) are read. An empty or unreadable tag, `C` and - /// `POSIX` are `en-US`; a bare `en` is the United States, any other bare language has no - /// territory. + /// `hc` (clock), `rg` (region override) and `nu` (digits; Apple's `@numbers=`) are read, and + /// `nu` stays in the number and time locales (`ar-EG-u-nu-latn`). `C` and `POSIX` are the C + /// locale: English, A4, metric, a `.` decimal and no grouping. An empty or unreadable tag is + /// `en-US`; a bare `en` is the United States, any other bare language has no territory. pub fn from_tag(tag: &str) -> Region { - Region::from_parts(&Tag::parse(tag).unwrap_or_default()) + match Tag::parse(tag) { + Some(tag) => Region::from_parts(&tag), + None if is_c_locale(tag) => { + Region { measurement: Measurement::Metric, paper: Paper::A4, decimal: Some('.'), grouping: Some(None), ..Region::default() } + } + None => Region::default(), + } } /// The region for an app's saved region setting: `""` or `auto` (any case) is @@ -123,11 +130,13 @@ struct Tag { territory: String, measurement: Option, hour_cycle: Option, + /// The `nu` keyword (`latn`, `arab`); empty when the tag has none. + digits: String, } impl Default for Tag { fn default() -> Self { - Tag { language: "en".into(), script: String::new(), territory: "US".into(), measurement: None, hour_cycle: None } + Tag { language: "en".into(), script: String::new(), territory: "US".into(), measurement: None, hour_cycle: None, digits: String::new() } } } @@ -137,7 +146,7 @@ impl Tag { let tag: String = tag.chars().take(64).collect(); let (tag, modifier) = tag.split_once('@').unwrap_or((&tag, "")); let tag = tag.split('.').next().unwrap_or_default().trim(); - if tag.eq_ignore_ascii_case("c") || tag.eq_ignore_ascii_case("posix") { + if is_c_locale(tag) { return None; } let mut parts = tag.split(['-', '_']); @@ -145,8 +154,14 @@ impl Tag { if !(2..=3).contains(&language.len()) || !language.chars().all(|c| c.is_ascii_alphabetic()) { return None; } - let mut out = - Tag { language: language.to_ascii_lowercase(), script: String::new(), territory: String::new(), measurement: None, hour_cycle: None }; + let mut out = Tag { + language: language.to_ascii_lowercase(), + script: String::new(), + territory: String::new(), + measurement: None, + hour_cycle: None, + digits: String::new(), + }; let mut extension = None; for p in parts.by_ref() { // A singleton starts the extensions (`-u-…`, `-x-…`). @@ -189,6 +204,7 @@ impl Tag { ("ms", "ussystem") => out.measurement = Some(Measurement::Us), ("hc", "h11" | "h12") => out.hour_cycle = Some(HourCycle::H12), ("hc", "h23" | "h24") => out.hour_cycle = Some(HourCycle::H23), + ("nu" | "numbers", v) if (3..=8).contains(&v.len()) && v.chars().all(|c| c.is_ascii_alphanumeric()) => out.digits = v.into(), ("rg", v) if v.len() == 6 && v.ends_with("zzzz") && v.get(..2).is_some_and(is_territory) => { out.territory = v.get(..2).unwrap_or_default().to_ascii_uppercase(); } @@ -202,7 +218,8 @@ impl Tag { } fn bcp47(&self) -> String { - [self.language.as_str(), &self.script, &self.territory].into_iter().filter(|p| !p.is_empty()).collect::>().join("-") + let tag = [self.language.as_str(), &self.script, &self.territory].into_iter().filter(|p| !p.is_empty()).collect::>().join("-"); + if self.digits.is_empty() { tag } else { format!("{tag}-u-nu-{}", self.digits) } } } @@ -216,16 +233,15 @@ fn title_case(p: &str) -> String { chars.next().map(|c| c.to_ascii_uppercase().to_string() + chars.as_str()).unwrap_or_default() } -/// Whether a locale variable names the C locale (`C`, `C.UTF-8`, `POSIX`). -#[cfg(any(test, all(unix, not(any(target_vendor = "apple", target_os = "android")))))] -fn is_c_locale(v: &str) -> bool { - let v = v.trim(); - v.eq_ignore_ascii_case("c") || v.eq_ignore_ascii_case("posix") || v.starts_with("C.") +/// Whether a locale tag names the C locale (`C`, `C.UTF-8`, `POSIX`). +fn is_c_locale(tag: &str) -> bool { + let tag = tag.split(['@', '.']).next().unwrap_or_default().trim(); + tag.eq_ignore_ascii_case("c") || tag.eq_ignore_ascii_case("posix") } /// The region from the POSIX locale variables `var` reads. An empty variable is unset; `C` in -/// `LC_ALL` or an `LC_*` variable is the C locale, written as `en-US`. `LANG` set to `C` or unset -/// gives way to the platform's preferred locale. +/// `LC_ALL` or an `LC_*` variable is the C locale's conventions. `LANG` set to `C` or unset gives +/// way to the platform's preferred locale. #[cfg(any(test, all(unix, not(any(target_vendor = "apple", target_os = "android")))))] fn from_posix(var: impl Fn(&str) -> Option, platform: impl FnOnce() -> Option) -> Region { let var = |name: &str| var(name).filter(|v| !v.trim().is_empty()); @@ -361,11 +377,33 @@ mod tests { assert_eq!(Region::from_tag("es-419").numbers, "es-419"); assert_eq!(Region::from_tag("fr").numbers, "fr"); assert_eq!(Region::from_tag("en").numbers, "en-US", "bare English is the US"); - for junk in ["", "C", "POSIX", "C.UTF-8", "1234", "x", "--", "ünïcode-ÄÄ", &"a".repeat(10_000)] { + for junk in ["", "1234", "x", "--", "ünïcode-ÄÄ", &"a".repeat(10_000)] { assert_eq!(Region::from_tag(junk), Region::default(), "{junk:?}"); } } + /// glibc's C locale: A4 and metric (`C-paper.c`, `C-measurement.c`), `.` and no grouping. + #[test] + fn the_c_locale_is_a4_metric_and_ungrouped() { + for c in ["C", "POSIX", "C.UTF-8", "c.utf8", " C "] { + let r = Region::from_tag(c); + assert_eq!( + (r.numbers.as_str(), r.paper, r.measurement, r.decimal, r.grouping), + ("en-US", Paper::A4, Measurement::Metric, Some('.'), Some(None)), + "{c:?}" + ); + } + } + + #[test] + fn a_chosen_digit_system_stays_in_the_locale() { + assert_eq!(Region::from_tag("ar-EG-u-nu-latn").numbers, "ar-EG-u-nu-latn"); + let r = Region::from_tag("ar_EG@numbers=latn"); + assert_eq!((r.numbers.as_str(), r.time.as_str()), ("ar-EG-u-nu-latn", "ar-EG-u-nu-latn")); + assert_eq!(Region::from_tag("en-US-u-nu-arab-ms-metric").numbers, "en-US-u-nu-arab"); + assert_eq!(Region::from_tag("ar-EG-u-nu-x").numbers, "ar-EG", "not a numbering system"); + } + #[test] fn unicode_extensions_and_apple_keywords() { let r = Region::from_tag("en-US-u-ms-metric-hc-h23"); @@ -411,7 +449,9 @@ mod tests { // `C` in an LC_* variable is the C locale's conventions, not an unset variable. let r = from_posix(env(&[("LANG", "de_DE.UTF-8"), ("LC_NUMERIC", "C"), ("LC_TIME", "C.UTF-8")]), none); assert_eq!((r.numbers.as_str(), r.time.as_str(), r.paper), ("en-US", "en-US", Paper::A4)); - assert_eq!(from_posix(env(&[("LANG", "de_DE.UTF-8"), ("LC_ALL", "C")]), none), Region::default()); + assert_eq!(from_posix(env(&[("LANG", "de_DE.UTF-8"), ("LC_ALL", "C")]), none), Region::from_tag("C")); + let r = from_posix(env(&[("LANG", "en_US.UTF-8"), ("LC_PAPER", "C"), ("LC_MEASUREMENT", "C.UTF-8")]), none); + assert_eq!((r.paper, r.measurement, r.decimal), (Paper::A4, Measurement::Metric, None)); assert_eq!(from_posix(env(&[("LANG", "C"), ("LC_NUMERIC", "")]), || Some("it-IT".into())).numbers, "it-IT"); }