From 903dbd5fa342c4df1cd9fe81982b0440ae007f4b Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 04:38:22 +0000 Subject: [PATCH 01/43] i18n: scripts, direction and regional Han per language tag scripts(tag) lists the ISO 15924 scripts a language's interface text is written in, direction(tag) follows the main script, and han(tag) gives the regional Han forms (SC, TC, HK, JP, KR) for picking a CJK face. I18n::rtl uses direction. --- crates/i18n/README.md | 8 +- crates/i18n/src/lib.rs | 11 +- crates/i18n/src/scripts.rs | 363 +++++++++++++++++++++++++++++++++++++ 3 files changed, 375 insertions(+), 7 deletions(-) create mode 100644 crates/i18n/src/scripts.rs diff --git a/crates/i18n/README.md b/crates/i18n/README.md index df3379712..cde3621d5 100644 --- a/crates/i18n/README.md +++ b/crates/i18n/README.md @@ -60,8 +60,12 @@ The app keeps its `tl!` macro, its language setting (`"auto"` → `I18N.system_l `plural_form_counts` holds the same counts. - **Names:** a language's name comes from its shared catalog's `@id language.name` row, else its code; English is "English". -- **Right to left:** `rtl` is true for `ar`, `ckb`, `dv`, `fa`, `he`, `ps`, `sd`, `ug`, `ur` and - `yi`; putting text into display order is the app's job. +- **Scripts and direction:** for any language tag, `scripts` lists the ISO 15924 scripts its + interface text is written in (`ja` → Hani, Hira, Kana; `sr-latn` → Latn), `direction` is right + to left when the main script is (Arabic, Hebrew, Syriac, Thaana), and `han` gives the regional + forms of its Han characters for picking a CJK face: `zh-CN`/`zh-SG`/`zh-Hans` → SC, `zh-TW`/ + `zh-Hant` → TC, `zh-HK`/`zh-MO` → HK, `ja` → JP, `ko` → KR. `I18n::rtl` is `direction` of the + language's code; putting text into display order is the app's job. - **System locale:** `system_lang` matches the system's list once and caches it: on desktop Unix `LANGUAGE`, `LC_ALL`, `LC_MESSAGES` and `LANG`, read raw; elsewhere `sys-locale`'s (macOS and Windows preferred languages, the browser's on the web). `fr_CA.UTF-8` → `fr`, `zh-TW` → diff --git a/crates/i18n/src/lib.rs b/crates/i18n/src/lib.rs index 7fa7a4efe..84295a163 100644 --- a/crates/i18n/src/lib.rs +++ b/crates/i18n/src/lib.rs @@ -6,6 +6,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod catalog; +mod scripts; use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::sync::{Mutex, OnceLock, PoisonError}; @@ -13,6 +14,8 @@ use std::sync::{Mutex, OnceLock, PoisonError}; use intl_pluralrules::{PluralCategory, PluralRuleType, PluralRules}; use unic_langid::LanguageIdentifier; +pub use scripts::{Direction, Han, Script, direction, han, scripts}; + /// The catalogs an app embeds, one entry per locale: (locale, shared catalog, the app's catalog). /// A missing catalog is `""`. pub type Embedded = [(&'static str, &'static str, &'static str)]; @@ -199,12 +202,10 @@ impl I18n { self.locale(lang).and_then(|l| l.rows.get("@id")?.get("language.name")).map_or_else(|| self.code(lang), String::as_str) } - /// Whether the language is written right to left. Putting text into display order is the - /// app's job. + /// Whether the language is written right to left ([`direction`]). Putting text into display + /// order is the app's job. pub fn rtl(&self, lang: Lang) -> bool { - let code = self.code(lang); - let language = code.split('-').next().unwrap_or(code); - matches!(language, "ar" | "ckb" | "dv" | "fa" | "he" | "ps" | "sd" | "ug" | "ur" | "yi") + direction(self.code(lang)) == Direction::Rtl } /// The language for a system locale tag (`fr_CA.UTF-8`, `zh-Hant-MO`); `C` and `POSIX` are diff --git a/crates/i18n/src/scripts.rs b/crates/i18n/src/scripts.rs new file mode 100644 index 000000000..ecd2f8ddf --- /dev/null +++ b/crates/i18n/src/scripts.rs @@ -0,0 +1,363 @@ +//! The scripts a language is written in, its direction, and which regional forms its Han +//! characters take, for picking fonts and laying out the interface. + +/// A writing system, by its ISO 15924 code. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum Script { + Arab, + Armn, + Beng, + Cyrl, + Deva, + Ethi, + Geor, + Grek, + Gujr, + Guru, + Hang, + Hani, + Hebr, + Hira, + Kana, + Khmr, + Knda, + Laoo, + Latn, + Mlym, + Mong, + Mymr, + Orya, + Sinh, + Syrc, + Taml, + Telu, + Thaa, + Thai, + Tibt, +} + +impl Script { + /// The ISO 15924 code, `Latn`. + pub fn code(self) -> &'static str { + match self { + Script::Arab => "Arab", + Script::Armn => "Armn", + Script::Beng => "Beng", + Script::Cyrl => "Cyrl", + Script::Deva => "Deva", + Script::Ethi => "Ethi", + Script::Geor => "Geor", + Script::Grek => "Grek", + Script::Gujr => "Gujr", + Script::Guru => "Guru", + Script::Hang => "Hang", + Script::Hani => "Hani", + Script::Hebr => "Hebr", + Script::Hira => "Hira", + Script::Kana => "Kana", + Script::Khmr => "Khmr", + Script::Knda => "Knda", + Script::Laoo => "Laoo", + Script::Latn => "Latn", + Script::Mlym => "Mlym", + Script::Mong => "Mong", + Script::Mymr => "Mymr", + Script::Orya => "Orya", + Script::Sinh => "Sinh", + Script::Syrc => "Syrc", + Script::Taml => "Taml", + Script::Telu => "Telu", + Script::Thaa => "Thaa", + Script::Thai => "Thai", + Script::Tibt => "Tibt", + } + } + + /// Whether text in this script runs right to left. + pub fn rtl(self) -> bool { + matches!(self, Script::Arab | Script::Hebr | Script::Syrc | Script::Thaa) + } +} + +/// Which way a language's text, and an interface in it, runs. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Direction { + Ltr, + Rtl, +} + +/// The regional glyph forms Han characters take, as in the Noto CJK face names (SC, TC, HK, JP, +/// KR). The same code point is drawn differently in each, so a font stack picks its Han face by +/// this, not by script. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Han { + /// Simplified Chinese, mainland China and Singapore (SC). + Simplified, + /// Traditional Chinese, Taiwan (TC). + Traditional, + /// Traditional Chinese, Hong Kong and Macau (HK). + HongKong, + /// Japanese kanji (JP). + Japanese, + /// Korean hanja (KR). + Korean, +} + +/// The scripts a language tag's interface text is written in (`ja` → Hani, Hira, Kana; `sr` → +/// Cyrl; `sr-latn` → Latn), the main one first. Any case and glibc form (`sr_RS.UTF-8@latin`). A +/// script subtag wins over the language's usual script. Latin product names and units appear in +/// every language, so Latn is listed only where it is the language's own script. A language not +/// in the table is taken to be written in Latin. +// ponytail: the languages a Craft app ships or could plausibly ship; add a row with the locale. +pub fn scripts(tag: &str) -> &'static [Script] { + use Script::*; + let tag = Subtags::parse(tag); + if let Some(s) = tag.script.as_deref().and_then(subtag_scripts) { + return s; + } + match tag.language.as_str() { + "ja" => &[Hani, Hira, Kana], + "ko" => &[Hang, Hani], + "zh" | "yue" | "wuu" => &[Hani], + "be" | "bg" | "ba" | "ce" | "cv" | "kk" | "ky" | "mk" | "mn" | "ru" | "sah" | "sr" | "tg" | "tt" | "uk" => &[Cyrl], + "el" => &[Grek], + "ar" | "ckb" | "fa" | "ks" | "ps" | "sd" | "ug" | "ur" => &[Arab], + "he" | "yi" => &[Hebr], + "syr" => &[Syrc], + "dv" => &[Thaa], + "ka" => &[Geor], + "hy" => &[Armn], + "hi" | "mr" | "ne" | "sa" | "kok" | "mai" | "bho" => &[Deva], + "bn" | "as" | "mni" => &[Beng], + "pa" => &[Guru], + "gu" => &[Gujr], + "or" => &[Orya], + "ta" => &[Taml], + "te" => &[Telu], + "kn" => &[Knda], + "ml" => &[Mlym], + "si" => &[Sinh], + "th" => &[Thai], + "lo" => &[Laoo], + "km" => &[Khmr], + "my" => &[Mymr], + "am" | "ti" => &[Ethi], + "bo" | "dz" => &[Tibt], + _ => &[Latn], + } +} + +/// The direction of a language tag's text: right to left when its main script is. +pub fn direction(tag: &str) -> Direction { + match scripts(tag).first() { + Some(s) if s.rtl() => Direction::Rtl, + _ => Direction::Ltr, + } +} + +/// The forms a language tag's Han characters take: `zh-CN`, `zh-SG`, `zh-Hans` and plain `zh` → +/// Simplified; `zh-TW` and `zh-Hant` → Traditional; `zh-HK`, `zh-MO` and `zh-Hant-HK` → HongKong; +/// `ja` → Japanese; `ko` → Korean. `None` for a language not written with Han, which leaves the +/// choice to the font stack. +pub fn han(tag: &str) -> Option { + let tag = Subtags::parse(tag); + let hong_kong = matches!(tag.region.as_deref(), Some("hk" | "mo")); + match (tag.language.as_str(), tag.script.as_deref()) { + ("ja", _) => Some(Han::Japanese), + ("ko", _) => Some(Han::Korean), + ("zh" | "yue" | "wuu", Some("hans")) => Some(Han::Simplified), + ("zh" | "yue" | "wuu", Some("hant")) => Some(if hong_kong { Han::HongKong } else { Han::Traditional }), + // Cantonese is written in Traditional characters with Hong Kong forms unless it says otherwise. + ("yue", _) => Some(if tag.region.as_deref() == Some("cn") { Han::Simplified } else { Han::HongKong }), + ("zh" | "wuu", _) => Some(match tag.region.as_deref() { + Some("tw") => Han::Traditional, + _ if hong_kong => Han::HongKong, + _ => Han::Simplified, + }), + _ => None, + } +} + +/// The scripts a script subtag stands for, including the CLDR unions (`jpan`, `kore`). `None` for +/// one not in [`Script`], so the language's own scripts apply. +fn subtag_scripts(subtag: &str) -> Option<&'static [Script]> { + use Script::*; + Some(match subtag { + "hans" | "hant" | "hani" => &[Hani], + "jpan" => &[Hani, Hira, Kana], + "kore" => &[Hang, Hani], + "arab" => &[Arab], + "armn" => &[Armn], + "beng" => &[Beng], + "cyrl" => &[Cyrl], + "deva" => &[Deva], + "ethi" => &[Ethi], + "geor" => &[Geor], + "grek" => &[Grek], + "gujr" => &[Gujr], + "guru" => &[Guru], + "hang" => &[Hang], + "hebr" => &[Hebr], + "khmr" => &[Khmr], + "knda" => &[Knda], + "laoo" => &[Laoo], + "latn" => &[Latn], + "mlym" => &[Mlym], + "mong" => &[Mong], + "mymr" => &[Mymr], + "orya" => &[Orya], + "sinh" => &[Sinh], + "syrc" => &[Syrc], + "taml" => &[Taml], + "telu" => &[Telu], + "thaa" => &[Thaa], + "thai" => &[Thai], + "tibt" => &[Tibt], + _ => return None, + }) +} + +/// A language tag's language, script and region subtags, lowercase. +struct Subtags { + language: String, + script: Option, + region: Option, +} + +impl Subtags { + /// `zh-Hant-HK`, `zh_TW.UTF-8` or `sr_RS@latin` (glibc's script modifier). + fn parse(tag: &str) -> Subtags { + let tag = tag.trim().to_ascii_lowercase(); + let base = tag.split(['.', '@']).next().unwrap_or("").replace('_', "-"); + let mut parts = base.split('-').filter(|p| !p.is_empty()); + let language = parts.next().unwrap_or("").to_owned(); + let mut script = match tag.split_once('@').map(|(_, m)| m) { + Some("latin") => Some("latn".to_owned()), + Some("cyrillic") => Some("cyrl".to_owned()), + _ => None, + }; + let mut region = None; + for part in parts { + if part.len() == 4 && part.bytes().all(|b| b.is_ascii_alphabetic()) { + script.get_or_insert_with(|| part.to_owned()); + } else if super::is_region(part) { + region = Some(part.to_owned()); + break; + } + } + Subtags { language, script, region } + } +} + +#[cfg(test)] +mod tests { + use super::Script::*; + use super::*; + + /// Every locale the craft-i18n catalogs or an app ship, as the apps spell them. + #[test] + fn shipped_locales() { + for (tag, want, han_forms) in [ + ("en", &[Latn][..], None), + ("ar", &[Arab], None), + ("bg", &[Cyrl], None), + ("cs", &[Latn], None), + ("da", &[Latn], None), + ("de", &[Latn], None), + ("el", &[Grek], None), + ("es", &[Latn], None), + ("es-419", &[Latn], None), + ("et", &[Latn], None), + ("fi", &[Latn], None), + ("fr", &[Latn], None), + ("hr", &[Latn], None), + ("hu", &[Latn], None), + ("id", &[Latn], None), + ("it", &[Latn], None), + ("ja", &[Hani, Hira, Kana], Some(Han::Japanese)), + ("ka", &[Geor], None), + ("kk", &[Cyrl], None), + ("ko", &[Hang, Hani], Some(Han::Korean)), + ("lt", &[Latn], None), + ("nl", &[Latn], None), + ("pl", &[Latn], None), + ("pt", &[Latn], None), + ("pt-br", &[Latn], None), + ("pt_BR", &[Latn], None), + ("ru", &[Cyrl], None), + ("sl", &[Latn], None), + ("sr", &[Cyrl], None), + ("sr-latn", &[Latn], None), + ("sv", &[Latn], None), + ("te", &[Telu], None), + ("tr", &[Latn], None), + ("uk", &[Cyrl], None), + ("vi", &[Latn], None), + ("zh", &[Hani], Some(Han::Simplified)), + ("zh-cn", &[Hani], Some(Han::Simplified)), + ("zh-CN", &[Hani], Some(Han::Simplified)), + ("zh-hans", &[Hani], Some(Han::Simplified)), + ("zh-Hans", &[Hani], Some(Han::Simplified)), + ("zh-hant", &[Hani], Some(Han::Traditional)), + ] { + assert_eq!(scripts(tag), want, "{tag}"); + assert_eq!(han(tag), han_forms, "{tag}"); + let rtl = tag == "ar"; + assert_eq!(direction(tag), if rtl { Direction::Rtl } else { Direction::Ltr }, "{tag}"); + } + } + + #[test] + fn regional_han() { + for (tag, want) in [ + ("zh-TW", Han::Traditional), + ("zh_TW.UTF-8", Han::Traditional), + ("zh-Hant-TW", Han::Traditional), + ("zh-HK", Han::HongKong), + ("zh-MO", Han::HongKong), + ("zh-Hant-HK", Han::HongKong), + ("zh-hant-hk", Han::HongKong), + ("zh-Hans-HK", Han::Simplified), + ("zh-SG", Han::Simplified), + ("zh-Hans-SG", Han::Simplified), + ("yue", Han::HongKong), + ("yue-Hans", Han::Simplified), + ("ja-JP", Han::Japanese), + ("ko_KR.UTF-8", Han::Korean), + ] { + assert_eq!(han(tag), Some(want), "{tag}"); + } + assert_eq!(han("en"), None); + assert_eq!(han(""), None); + } + + #[test] + fn script_subtag_wins() { + for (tag, want) in [ + ("sr-Latn-RS", &[Latn][..]), + ("sr_RS@latin", &[Latn]), + ("sr-Cyrl", &[Cyrl]), + ("sr_RS.UTF-8", &[Cyrl]), + ("az-Cyrl", &[Cyrl]), + ("pa-Arab", &[Arab]), + ("pa", &[Guru]), + ("mn-Mong", &[Mong]), + ("sd-Deva", &[Deva]), + ("fr-Zyyy", &[Latn]), + ] { + assert_eq!(scripts(tag), want, "{tag}"); + } + assert_eq!(direction("pa-Arab"), Direction::Rtl); + assert_eq!(direction("sd-Deva"), Direction::Ltr); + assert_eq!(direction("he_IL.UTF-8"), Direction::Rtl); + } + + /// The usual scripts tag matching relies on agree with the table. + #[test] + fn agrees_with_usual_script() { + for language in ["be", "mn", "sr", "zh", "az", "bs", "ha", "uz", "ks", "sd", "pa"] { + let usual = crate::usual_script(language).and_then(subtag_scripts); + assert_eq!(usual, Some(scripts(language)), "{language}"); + } + } +} From e896187c6c3a88c759035edaf4e6900f358bdc20 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 04:40:00 +0000 Subject: [PATCH 02/43] i18n: isolate inserted values in fmt and trn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fmt wraps each value in FSI…PDI and trn wraps {n} in LRI…PDI, so a value's direction can't reorder the message around it. ltr() isolates numbers and paths left to right; a value that already is one isolate goes in unchanged, and a stray PDI can't close the isolate early. fmt_plain and strip_isolates are for text that leaves the interface. --- crates/i18n/README.md | 7 ++ crates/i18n/src/lib.rs | 142 +++++++++++++++++++++++++++++++++++------ 2 files changed, 131 insertions(+), 18 deletions(-) diff --git a/crates/i18n/README.md b/crates/i18n/README.md index df3379712..a34ce1830 100644 --- a/crates/i18n/README.md +++ b/crates/i18n/README.md @@ -49,6 +49,13 @@ The app keeps its `tl!` macro, its language setting (`"auto"` → `I18N.system_l (one context along the locale chain, so `pt-br` can return a `pt` row, but never the plain row or English: a key cap's name under `key`, or "is there a translation?"), and `fmt` for `{name}` placeholders, filled in one pass. +- **Isolation:** `fmt` wraps each inserted value in a first-strong isolate (FSI…PDI), and `trn` + wraps `{n}` in a left-to-right one (LRI…PDI), so a Latin file name in an Arabic sentence, or a + Hebrew name in an English one, can't reorder the words around it. Wrap numbers, paths and codes + in `ltr` before passing them; a value that already is one isolate goes in as it is, and a stray + PDI in a value can't close its isolate early. egui draws the marks as nothing. For text that + leaves the interface (file names, logs, terminals, text parsed later), use `fmt_plain` or + `strip_isolates`. - **Bad rows** (malformed, an unknown `@` context, a wrong plural form count, a row defined twice, of which the first is kept) and bad locales (`en`, `pseudo`, a code embedded twice in any case) are skipped and returned by `I18n::new` for the app to log; craft-i18n's CI rejects them before diff --git a/crates/i18n/src/lib.rs b/crates/i18n/src/lib.rs index 7fa7a4efe..a394aef56 100644 --- a/crates/i18n/src/lib.rs +++ b/crates/i18n/src/lib.rs @@ -158,8 +158,9 @@ impl I18n { self.find(lang, &[("@id", id), ("", label)]).unwrap_or(label) } - /// A count's wording in `lang`, `{n}` filled in. `one` and `other` are the English forms; - /// they are also the key of the `@plural` row, and the fallback. + /// A count's wording in `lang`, `{n}` filled in as a left-to-right isolate ([`ltr`]). `one` + /// and `other` are the English forms; they are also the key of the `@plural` row, and the + /// fallback. pub fn trn(&self, lang: Lang, n: u64, one: &str, other: &str) -> String { let key = format!("{one}|{other}"); let chain = self.locale(lang).map_or(&[][..], |l| l.chain.as_slice()); @@ -175,7 +176,7 @@ impl I18n { forms.split('|').nth(index) }) .unwrap_or(if n == 1 { one } else { other }); - let text = fmt(text, &[("n", &n.to_string())]); + let text = fmt(text, &[("n", <r(&n.to_string()))]); if lang == Lang::PSEUDO { return marked(&text); } @@ -261,9 +262,88 @@ impl I18n { } } -/// Fill `{name}` placeholders from `args` in one pass. Unknown placeholders stay as written, and -/// inserted values are never read as templates, so a file name containing `{n}` stays intact. +/// Fill `{name}` placeholders from `args` in one pass, each value wrapped in a first-strong +/// isolate (FSI…PDI), so its direction can't reorder the sentence around it: a Latin file name +/// in an Arabic message stays where the translator put it. A value that already is one isolate +/// goes in as it is; pass numbers, paths and codes through [`ltr`]. Unknown placeholders stay as +/// written, and inserted values are never read as templates, so a file name containing `{n}` +/// stays intact. pub fn fmt(template: &str, args: &[(&str, &str)]) -> String { + fill(template, args, true) +} + +/// [`fmt`] without isolates, for text that goes where the marks would get in the way: file +/// names, logs, terminals, text compared or parsed later. +pub fn fmt_plain(template: &str, args: &[(&str, &str)]) -> String { + fill(template, args, false) +} + +const LRI: char = '\u{2066}'; +const RLI: char = '\u{2067}'; +const FSI: char = '\u{2068}'; +const PDI: char = '\u{2069}'; + +/// `value` as a first-strong isolate (FSI…PDI): it takes the direction of its first strong +/// letter and can't reorder the text around it. What [`fmt`] does to each value. +pub fn isolate(value: &str) -> String { + wrap(FSI, value) +} + +/// `value` as a left-to-right isolate (LRI…PDI), for numbers, paths, file sizes, timecodes and +/// codes, which read left to right in every language. In an RTL first-strong isolate, the +/// segments of `/home/مستخدم/file.txt` would run right to left. +pub fn ltr(value: &str) -> String { + wrap(LRI, value) +} + +/// `text` without isolate marks (LRI, RLI, FSI, PDI): a [`fmt`] or [`I18n::trn`] result made fit +/// for a file name, a log or a comparison. +pub fn strip_isolates(text: &str) -> String { + text.chars().filter(|c| !matches!(*c, LRI | RLI | FSI | PDI)).collect() +} + +/// `value` between `initiator` and PDI. A stray PDI in the value would close the isolate early, so +/// it is dropped; isolates the value leaves open are closed. +fn wrap(initiator: char, value: &str) -> String { + let mut out = String::with_capacity(value.len() + 6); + out.push(initiator); + let mut open = 0_usize; + for c in value.chars() { + match c { + LRI | RLI | FSI => open += 1, + PDI if open == 0 => continue, + PDI => open -= 1, + _ => {} + } + out.push(c); + } + for _ in 0..=open { + out.push(PDI); + } + out +} + +/// Whether `value` is exactly one isolate: an initiator, then the PDI that closes it last. +fn is_one_isolate(value: &str) -> bool { + let mut depth = 0_usize; + let mut chars = value.chars().peekable(); + while let Some(c) = chars.next() { + match c { + LRI | RLI | FSI => depth += 1, + PDI if depth > 0 => { + depth -= 1; + if depth == 0 { + return chars.peek().is_none(); + } + } + _ if depth == 0 => return false, + _ => {} + } + } + false +} + +fn fill(template: &str, args: &[(&str, &str)], isolated: bool) -> String { let mut out = String::with_capacity(template.len()); let mut rest = template; while let Some((before, after_open)) = rest.split_once('{') { @@ -274,7 +354,8 @@ pub fn fmt(template: &str, args: &[(&str, &str)]) -> String { break; }; match args.iter().find(|(k, _)| *k == name) { - Some((_, value)) => out.push_str(value), + Some((_, value)) if !isolated || value.is_empty() || is_one_isolate(value) => out.push_str(value), + Some((_, value)) => out.push_str(&isolate(value)), None => { out.push('{'); out.push_str(name); @@ -485,17 +566,42 @@ mod tests { #[test] fn fmt_fills_once() { - assert_eq!(fmt("{a} to {b}", &[("a", "x"), ("b", "{a}")]), "x to {a}"); + assert_eq!(fmt_plain("{a} to {b}", &[("a", "x"), ("b", "{a}")]), "x to {a}"); assert_eq!(fmt("{z} {", &[]), "{z} {"); } + #[test] + fn inserted_values_are_isolated() { + let saved = fmt("تم حفظ {name} في {dir}.", &[("name", "Report 2.pdf"), ("dir", <r("/home/سارة/docs"))]); + assert_eq!(saved, "تم حفظ \u{2068}Report 2.pdf\u{2069} في \u{2066}/home/سارة/docs\u{2069}."); + assert_eq!(strip_isolates(&saved), "تم حفظ Report 2.pdf في /home/سارة/docs."); + assert_eq!(fmt_plain("{a}", &[("a", "x")]), "x"); + assert_eq!(fmt("[{a}]", &[("a", "")]), "[]"); + } + + #[test] + fn a_value_cannot_escape_its_isolate() { + assert_eq!(isolate("a\u{2069}b"), "\u{2068}ab\u{2069}"); + assert_eq!(isolate("a\u{2067}b"), "\u{2068}a\u{2067}b\u{2069}\u{2069}"); + let one = ltr("12"); + assert_eq!(fmt("{n}", &[("n", &one)]), one); + // Two isolates side by side are not one, so they get wrapped. + assert_eq!(fmt("{v}", &[("v", "\u{2066}a\u{2069}\u{2066}b\u{2069}")]), "\u{2068}\u{2066}a\u{2069}\u{2066}b\u{2069}\u{2069}"); + } + + #[test] + fn counts_are_left_to_right_isolates() { + let i = all(P).0; + assert_eq!(i.trn(lang(&i, "fr"), 2, "{n} page", "{n} pages"), "\u{2066}2\u{2069} pages"); + } + #[test] fn one_other() { let (i, problems) = all(P); assert!(problems.is_empty(), "{problems:?}"); let fr = lang(&i, "fr"); - assert_eq!(i.trn(fr, 0, "{n} page", "{n} pages"), "0 page"); - assert_eq!(i.trn(fr, 2, "{n} page", "{n} pages"), "2 pages"); + assert_eq!(strip_isolates(&i.trn(fr, 0, "{n} page", "{n} pages")), "0 page"); + assert_eq!(strip_isolates(&i.trn(fr, 2, "{n} page", "{n} pages")), "2 pages"); } #[test] @@ -503,27 +609,27 @@ mod tests { let i = all(P).0; let ru = lang(&i, "ru"); for (n, want) in [(1, "1 файл"), (3, "3 файла"), (5, "5 файлов"), (11, "11 файлов"), (21, "21 файл")] { - assert_eq!(i.trn(ru, n, "{n} file", "{n} files"), want); + assert_eq!(strip_isolates(&i.trn(ru, n, "{n} file", "{n} files")), want); } } #[test] fn one_form_ja() { let i = all(P).0; - assert_eq!(i.trn(lang(&i, "ja"), 7, "{n} file", "{n} files"), "7 個"); + assert_eq!(strip_isolates(&i.trn(lang(&i, "ja"), 7, "{n} file", "{n} files")), "7 個"); } #[test] fn english_fallback() { let i = all(P).0; - assert_eq!(i.trn(Lang::EN, 1, "{n} file", "{n} files"), "1 file"); - assert_eq!(i.trn(lang(&i, "fr"), 2, "{n} x", "{n} xs"), "2 xs"); + assert_eq!(strip_isolates(&i.trn(Lang::EN, 1, "{n} file", "{n} files")), "1 file"); + assert_eq!(strip_isolates(&i.trn(lang(&i, "fr"), 2, "{n} x", "{n} xs")), "2 xs"); } #[test] fn beyond_sweep() { let i = all(P).0; - assert_eq!(i.trn(lang(&i, "fr"), 1_000_000, "{n} page", "{n} pages"), "1000000 pages"); + assert_eq!(strip_isolates(&i.trn(lang(&i, "fr"), 1_000_000, "{n} page", "{n} pages")), "1000000 pages"); } static L: &Embedded = @@ -590,7 +696,7 @@ mod tests { assert_eq!(i.t(Lang::PSEUDO, "A\nB"), "⟦A⟧\n⟦B⟧"); assert_eq!(i.tc(Lang::PSEUDO, "menu", "Open"), "⟦Open⟧"); assert_eq!(i.tid(Lang::PSEUDO, "file.save", "Save"), "⟦Save⟧"); - assert_eq!(i.trn(Lang::PSEUDO, 2, "{n} x", "{n} xs"), "⟦2 xs⟧"); + assert_eq!(strip_isolates(&i.trn(Lang::PSEUDO, 2, "{n} x", "{n} xs")), "⟦2 xs⟧"); assert!(!i.languages().contains(&Lang::PSEUDO)); assert_eq!(i.from_code("pseudo"), cfg!(debug_assertions).then_some(Lang::PSEUDO)); assert_eq!(i.get(Lang::PSEUDO, "key", "Esc"), Some("⟦Esc⟧")); @@ -669,9 +775,9 @@ mod tests { #[test] fn european_portuguese_zero_is_plural() { let i = all(PT).0; - assert_eq!(i.trn(lang(&i, "pt"), 0, "{n} file", "{n} files"), "0 ficheiros"); - assert_eq!(i.trn(lang(&i, "pt"), 1, "{n} file", "{n} files"), "1 ficheiro"); - assert_eq!(i.trn(lang(&i, "pt-br"), 0, "{n} file", "{n} files"), "0 arquivo"); + assert_eq!(strip_isolates(&i.trn(lang(&i, "pt"), 0, "{n} file", "{n} files")), "0 ficheiros"); + assert_eq!(strip_isolates(&i.trn(lang(&i, "pt"), 1, "{n} file", "{n} files")), "1 ficheiro"); + assert_eq!(strip_isolates(&i.trn(lang(&i, "pt-br"), 0, "{n} file", "{n} files")), "0 arquivo"); } /// The same counts as craft-i18n's checker (`plural_form_counts`), which rejects a catalog From ce70b2ede876a87f2abc8af95e39e6ce801fd224 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 04:41:41 +0000 Subject: [PATCH 03/43] i18n: formatting region apart from the interface language Region holds the number and time locales, metric or US measurement, A4 or Letter, the decimal separator, and the grouping separator and clock when the system sets them. Region::system reads Windows' regional format with the user's changes, Apple's current locale, the POSIX LC_* variables each on their own, and the preferred language on the web and Android. Region::from_setting takes an app's saved choice ("auto" or a tag). Ported from WordCraft's Region (storytold/wordcraft#857, #1494). Co-authored-by: Dmitrii Khlapov Co-authored-by: Brandon Thomas --- Cargo.lock | 2 + crates/i18n/Cargo.toml | 8 + crates/i18n/README.md | 10 + crates/i18n/src/lib.rs | 3 + crates/i18n/src/region.rs | 464 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 487 insertions(+) create mode 100644 crates/i18n/src/region.rs diff --git a/Cargo.lock b/Cargo.lock index 4d9de4289..11eebe644 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -657,8 +657,10 @@ name = "craft-i18n" version = "0.1.0" dependencies = [ "intl_pluralrules", + "objc2-foundation 0.3.2", "sys-locale", "unic-langid", + "windows-registry", ] [[package]] diff --git a/crates/i18n/Cargo.toml b/crates/i18n/Cargo.toml index 45e21775e..bfff93ce2 100644 --- a/crates/i18n/Cargo.toml +++ b/crates/i18n/Cargo.toml @@ -15,5 +15,13 @@ sys-locale = "0.3" [target.'cfg(target_arch = "wasm32")'.dependencies] sys-locale = { version = "0.3", features = ["js"] } +# The regional format (Region::system); safe registry reads, MIT OR Apache-2.0. +[target.'cfg(windows)'.dependencies] +windows-registry = "0.6" + +# The current locale (Region::system); already in the apps' Cargo.lock through eframe. +[target.'cfg(target_vendor = "apple")'.dependencies] +objc2-foundation = { version = "0.3.2", default-features = false, features = ["std", "NSDateFormatter", "NSFormatter", "NSLocale", "NSString"] } + [lints] workspace = true diff --git a/crates/i18n/README.md b/crates/i18n/README.md index df3379712..d916bbbb8 100644 --- a/crates/i18n/README.md +++ b/crates/i18n/README.md @@ -68,6 +68,16 @@ The app keeps its `tl!` macro, its language setting (`"auto"` → `I18N.system_l `zh-hant`, `pt-PT` → `pt-br` when that is the only Portuguese, `C`/`POSIX` → English. A script is never crossed: `sr_RS@latin` and `sr-Latn-RS` pick `sr-latn`, never `sr`, while `sr-Cyrl-RS` picks `sr`. +- **Formatting region:** `Region` holds how numbers, times and measurements are written, apart + from the interface language: the number and time locales (BCP 47), metric or US measurement, A4 + or Letter, the decimal separator, and the grouping separator and clock when the system sets + them. `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, 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-`. 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 translation. `looked_up()` lists the source texts looked up so far, for an audit against a diff --git a/crates/i18n/src/lib.rs b/crates/i18n/src/lib.rs index 7fa7a4efe..9e407419f 100644 --- a/crates/i18n/src/lib.rs +++ b/crates/i18n/src/lib.rs @@ -6,6 +6,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod catalog; +mod region; use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::sync::{Mutex, OnceLock, PoisonError}; @@ -13,6 +14,8 @@ use std::sync::{Mutex, OnceLock, PoisonError}; use intl_pluralrules::{PluralCategory, PluralRuleType, PluralRules}; use unic_langid::LanguageIdentifier; +pub use region::{HourCycle, Measurement, Paper, Region}; + /// The catalogs an app embeds, one entry per locale: (locale, shared catalog, the app's catalog). /// A missing catalog is `""`. pub type Embedded = [(&'static str, &'static str, &'static str)]; diff --git a/crates/i18n/src/region.rs b/crates/i18n/src/region.rs new file mode 100644 index 000000000..f9bb47206 --- /dev/null +++ b/crates/i18n/src/region.rs @@ -0,0 +1,464 @@ +//! The formatting region: how numbers, times and measurements are written, apart from the +//! interface language. An English interface in Poland still writes `2,54 cm` on A4. +//! +//! The territory lists come from Unicode CLDR's supplemental `measurementData` (Unicode License): +//! `paperSize` US-Letter and `measurementSystem` US. The decimal separators follow CLDR's number +//! symbols for the common locales; the system's own separator wins where the platform has one. + +use std::sync::OnceLock; + +/// Territories whose default paper is US Letter (CLDR `paperSize type="US-Letter"`). +const LETTER: &[&str] = &["BZ", "CA", "CL", "CO", "CR", "GT", "MX", "NI", "PA", "PH", "PR", "SV", "US", "VE"]; + +/// Territories that measure in inches (CLDR `measurementSystem type="US"`). +const INCHES: &[&str] = &["LR", "MM", "US"]; + +/// Languages whose numbers use a decimal comma in most of their territories. +const COMMA_LANGUAGES: &[&str] = &[ + "af", "az", "be", "bg", "bs", "ca", "cs", "da", "de", "el", "es", "et", "eu", "fi", "fo", "fr", "gl", "hr", "hu", "hy", "id", "is", "it", "ka", + "kk", "ky", "lb", "lt", "lv", "mk", "nb", "nl", "nn", "no", "pl", "pt", "ro", "ru", "sk", "sl", "sq", "sr", "sv", "tr", "uk", "uz", "vi", +]; + +/// Territories where those languages use a decimal point after all. +const POINT_EXCEPTIONS: &[(&str, &[&str])] = + &[("de", &["CH", "LI"]), ("it", &["CH"]), ("es", &["CU", "DO", "GT", "HN", "MX", "NI", "PA", "PE", "PR", "SV", "US", "419"])]; + +/// Territories where English is written with a decimal comma: most of continental Europe, South +/// Africa and much of South America. +const COMMA_TERRITORIES: &[&str] = &[ + "AR", "AT", "BE", "BG", "BO", "BR", "CL", "CO", "CZ", "DE", "DK", "EC", "EE", "ES", "FI", "FR", "GR", "HR", "HU", "ID", "IS", "IT", "LT", "LU", + "LV", "NL", "NO", "PL", "PT", "PY", "RO", "RS", "RU", "SE", "SI", "SK", "TR", "UA", "UY", "VE", "VN", "ZA", +]; + +/// How lengths, distances and weights are measured. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Measurement { + /// Centimetres, kilometres and kilograms (CLDR `metric` and `UK`). + Metric, + /// Inches, miles and pounds (CLDR `US`). + Us, +} + +/// The paper a new document starts on. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Paper { + A4, + Letter, +} + +/// A clock the user chose, where the system says so apart from the locale. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum HourCycle { + /// 1 to 12 with AM and PM. + H12, + /// 0 to 23. + H23, +} + +/// The conventions numbers, times and measurements follow. Read it with [`Region::system`] or, +/// for the user's saved choice, [`Region::from_setting`]; the fields are public so an app can +/// keep a separate choice of, say, measurement. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Region { + /// The locale numbers are written in, BCP 47 (`pl-PL`, `sr-Latn-RS`). + pub numbers: String, + /// The locale dates and times are written in, BCP 47. + pub time: String, + pub measurement: Measurement, + pub paper: Paper, + /// The decimal separator (`,` in Poland). + pub decimal: char, + /// The grouping separator the system sets (Windows, macOS); `None` follows the locale. + pub grouping: Option, + /// The clock the system sets (Windows, macOS); `None` follows the locale. + pub hour_cycle: Option, +} + +impl Default for Region { + /// `en-US`: decimal point, Letter, inches. + fn default() -> Self { + Region::from_parts(&Tag::default()) + } +} + +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. + pub fn from_tag(tag: &str) -> Region { + Region::from_parts(&Tag::parse(tag).unwrap_or_default()) + } + + /// The region for an app's saved region setting: `""` or `auto` (any case) is + /// [`Region::system`], anything else a locale tag for [`Region::from_tag`]. + pub fn from_setting(setting: &str) -> Region { + let setting = setting.trim(); + if setting.is_empty() || setting.eq_ignore_ascii_case("auto") { Region::system() } else { Region::from_tag(setting) } + } + + /// The system's region, read once, then cached: + /// + /// - Windows: the regional format and its customised separators, measurement system, paper + /// and short time format (Settings › Time & language › Language & region). + /// - macOS and iOS: the current locale (its identifier carries the Region setting), its + /// separators and metric setting, and its hour format. + /// - Linux and the BSDs: `LC_ALL`, else each of `LC_NUMERIC`, `LC_TIME`, `LC_MEASUREMENT` and + /// `LC_PAPER` on its own, else `LANG`. + /// - The web and Android: the preferred language, the only locale they expose. + pub fn system() -> Region { + static SYSTEM: OnceLock = OnceLock::new(); + SYSTEM.get_or_init(read_system).clone() + } + + fn from_parts(tag: &Tag) -> Region { + let territory = tag.territory.as_str(); + let language = tag.language.as_str(); + let measurement = tag.measurement.unwrap_or(if INCHES.contains(&territory) { Measurement::Us } else { Measurement::Metric }); + let decimal = if COMMA_LANGUAGES.contains(&language) { + if POINT_EXCEPTIONS.iter().any(|(l, ts)| *l == language && ts.contains(&territory)) { '.' } else { ',' } + } else if language == "en" && COMMA_TERRITORIES.contains(&territory) { + ',' + } else { + '.' + }; + Region { + numbers: tag.bcp47(), + time: tag.bcp47(), + measurement, + paper: if LETTER.contains(&territory) { Paper::Letter } else { Paper::A4 }, + decimal, + grouping: None, + hour_cycle: tag.hour_cycle, + } + } +} + +/// The parts of a locale tag the region uses. +#[derive(Clone, Debug, PartialEq, Eq)] +struct Tag { + /// Lower case (`pl`). + language: String, + /// Title case (`Latn`); empty when the tag has none. + script: String, + /// Upper case (`PL`, or a UN M.49 number like `419`); empty when the tag has none. + territory: String, + measurement: Option, + hour_cycle: Option, +} + +impl Default for Tag { + fn default() -> Self { + Tag { language: "en".into(), script: String::new(), territory: "US".into(), measurement: None, hour_cycle: None } + } +} + +impl Tag { + fn parse(tag: &str) -> Option { + // Locale tags are short; anything long is junk and only its start is read. + 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") { + return None; + } + let mut parts = tag.split(['-', '_']); + let language = parts.next().unwrap_or_default(); + 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 extension = None; + for p in parts.by_ref() { + // A singleton starts the extensions (`-u-…`, `-x-…`). + if p.len() == 1 { + extension = Some(p.to_ascii_lowercase()); + break; + } + if out.territory.is_empty() && out.script.is_empty() && p.len() == 4 && p.chars().all(|c| c.is_ascii_alphabetic()) { + out.script = title_case(p); + } else if out.territory.is_empty() && is_territory(p) { + out.territory = p.to_ascii_uppercase(); + } + } + let mut keywords: Vec<(String, String)> = Vec::new(); + if extension.as_deref() == Some("u") { + let mut key = String::new(); + for p in parts { + if p.len() == 1 { + break; + } + if p.len() == 2 { + key = p.to_ascii_lowercase(); + } else if !key.is_empty() { + keywords.push((std::mem::take(&mut key), p.to_ascii_lowercase())); + } + } + } + // Apple's and ICU's `@rg=plzzzz;ms=metric` keywords, and glibc's `@latin` script modifier. + for m in modifier.split(';') { + match m.split_once('=') { + Some((k, v)) => keywords.push((k.trim().to_ascii_lowercase(), v.trim().to_ascii_lowercase())), + None if m.eq_ignore_ascii_case("latin") => out.script = "Latn".into(), + None if m.eq_ignore_ascii_case("cyrillic") => out.script = "Cyrl".into(), + None => {} + } + } + for (key, value) in keywords { + match (key.as_str(), value.as_str()) { + ("ms", "metric" | "uksystem") => out.measurement = Some(Measurement::Metric), + ("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), + ("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(); + } + _ => {} + } + } + if out.territory.is_empty() && out.language == "en" { + out.territory = "US".into(); + } + Some(out) + } + + fn bcp47(&self) -> String { + [self.language.as_str(), &self.script, &self.territory].into_iter().filter(|p| !p.is_empty()).collect::>().join("-") + } +} + +fn is_territory(p: &str) -> bool { + (p.len() == 2 && p.chars().all(|c| c.is_ascii_alphabetic())) || (p.len() == 3 && p.chars().all(|c| c.is_ascii_digit())) +} + +fn title_case(p: &str) -> String { + let lower = p.to_ascii_lowercase(); + let mut chars = lower.chars(); + chars.next().map(|c| c.to_ascii_uppercase().to_string() + chars.as_str()).unwrap_or_default() +} + +/// A locale variable that names a locale (not empty, `C` or `POSIX`). +#[cfg(any(test, all(unix, not(any(target_vendor = "apple", target_os = "android")))))] +fn usable_locale(v: &str) -> bool { + let v = v.trim(); + !v.is_empty() && !v.eq_ignore_ascii_case("c") && !v.eq_ignore_ascii_case("posix") && !v.starts_with("C.") +} + +/// The region from the POSIX locale variables `var` reads and, when neither `LC_ALL` nor the +/// variable for a convention nor `LANG` is set, 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| usable_locale(v)); + if let Some(all) = var("LC_ALL") { + return Region::from_tag(&all); + } + let lang = var("LANG").or_else(platform).unwrap_or_default(); + let tag = |name: &str| var(name).unwrap_or_else(|| lang.clone()); + let time = Region::from_tag(&tag("LC_TIME")); + Region { + time: time.time, + hour_cycle: time.hour_cycle, + measurement: Region::from_tag(&tag("LC_MEASUREMENT")).measurement, + paper: Region::from_tag(&tag("LC_PAPER")).paper, + ..Region::from_tag(&tag("LC_NUMERIC")) + } +} + +/// The region from the values under `HKEY_CURRENT_USER\Control Panel\International` that `value` +/// reads, where Windows keeps the regional format and the user's changes to it. `LocaleName` is +/// the format (`pl-PL`), else the platform's preferred locale. +#[cfg(any(test, windows))] +fn from_windows(value: impl Fn(&str) -> Option, platform: impl FnOnce() -> Option) -> Region { + let mut region = Region::from_tag(&value("LocaleName").or_else(platform).unwrap_or_default()); + if let Some(c) = value("sDecimal").as_deref().and_then(single_char) { + region.decimal = c; + } + if let Some(s) = value("sThousand") { + region.grouping = single_char(&s); + } + match value("iMeasure").as_deref().map(str::trim) { + Some("0") => region.measurement = Measurement::Metric, + Some("1") => region.measurement = Measurement::Us, + _ => {} + } + // DMPAPER_LETTER and DMPAPER_A4; other sizes keep the territory's. + match value("iPaperSize").as_deref().map(str::trim) { + Some("1") => region.paper = Paper::Letter, + Some("9") => region.paper = Paper::A4, + _ => {} + } + region.hour_cycle = value("sShortTime").as_deref().and_then(hour_cycle_of).or(region.hour_cycle); + region +} + +/// The region from Apple's current locale: its identifier (`en_PL`, `en_GB@rg=uszzzz`), its +/// metric setting and separators, and the hour format its `j` skeleton gives (`HH`, `h a`). +#[cfg(any(test, target_vendor = "apple"))] +fn from_apple(identifier: &str, metric: bool, decimal: &str, grouping: &str, hours: Option<&str>) -> Region { + let mut region = Region::from_tag(identifier); + region.measurement = if metric { Measurement::Metric } else { Measurement::Us }; + if let Some(c) = single_char(decimal) { + region.decimal = c; + } + region.grouping = single_char(grouping); + region.hour_cycle = hours.and_then(hour_cycle_of).or(region.hour_cycle); + region +} + +/// A separator that is one character; Windows allows up to three, which the region can't carry. +#[cfg(any(test, windows, target_vendor = "apple"))] +fn single_char(s: &str) -> Option { + let mut chars = s.chars(); + let c = chars.next()?; + chars.next().is_none().then_some(c) +} + +/// The clock a time pattern (`HH:mm`, `h:mm tt`, `h 'Uhr'`) uses; quoted literals are skipped. +#[cfg(any(test, windows, target_vendor = "apple"))] +fn hour_cycle_of(pattern: &str) -> Option { + let mut quoted = false; + for c in pattern.chars() { + match c { + '\'' => quoted = !quoted, + 'H' | 'k' if !quoted => return Some(HourCycle::H23), + 'h' | 'K' if !quoted => return Some(HourCycle::H12), + _ => {} + } + } + None +} + +#[cfg(windows)] +fn read_system() -> Region { + let key = windows_registry::CURRENT_USER.open(r"Control Panel\International").ok(); + from_windows(|name| key.as_ref()?.get_string(name).ok(), sys_locale::get_locale) +} + +#[cfg(target_vendor = "apple")] +fn read_system() -> Region { + use objc2_foundation::{NSDateFormatter, NSLocale, NSString}; + let locale = NSLocale::currentLocale(); + let hours = NSDateFormatter::dateFormatFromTemplate_options_locale(&NSString::from_str("j"), 0, Some(&locale)).map(|f| f.to_string()); + from_apple( + &locale.localeIdentifier().to_string(), + locale.usesMetricSystem(), + &locale.decimalSeparator().to_string(), + &locale.groupingSeparator().to_string(), + hours.as_deref(), + ) +} + +#[cfg(all(unix, not(any(target_vendor = "apple", target_os = "android"))))] +fn read_system() -> Region { + from_posix(|name| std::env::var(name).ok(), sys_locale::get_locale) +} + +#[cfg(not(any(windows, unix)))] +fn read_system() -> Region { + Region::from_tag(&sys_locale::get_locale().unwrap_or_default()) +} + +#[cfg(target_os = "android")] +fn read_system() -> Region { + Region::from_tag(&sys_locale::get_locale().unwrap_or_default()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn conventions(r: &Region) -> (Paper, Measurement, char) { + (r.paper, r.measurement, r.decimal) + } + + #[test] + fn reads_locale_tags() { + assert_eq!(Region::from_tag("pl-PL").numbers, "pl-PL"); + assert_eq!(Region::from_tag("de_AT.UTF-8@euro").numbers, "de-AT"); + assert_eq!(Region::from_tag("zh-hans-cn").numbers, "zh-Hans-CN"); + assert_eq!(Region::from_tag("sr_RS@latin").numbers, "sr-Latn-RS"); + 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)] { + assert_eq!(Region::from_tag(junk), Region::default(), "{junk:?}"); + } + } + + #[test] + fn unicode_extensions_and_apple_keywords() { + let r = Region::from_tag("en-US-u-ms-metric-hc-h23"); + assert_eq!((r.numbers.as_str(), r.measurement, r.hour_cycle), ("en-US", Measurement::Metric, Some(HourCycle::H23))); + assert_eq!(Region::from_tag("en-GB-u-hc-h12").hour_cycle, Some(HourCycle::H12)); + assert_eq!(Region::from_tag("de-DE-x-ms-ussystem").measurement, Measurement::Metric, "private use isn't read"); + let r = Region::from_tag("en_GB@rg=uszzzz"); + assert_eq!((r.numbers.as_str(), r.paper, r.measurement), ("en-US", Paper::Letter, Measurement::Us)); + assert_eq!(Region::from_tag("en_US@rg=plzzzz;ms=metric").numbers, "en-PL"); + } + + /// Paper and measurement follow the territory, numbers the language and territory. + #[test] + fn conventions_follow_the_territory() { + assert_eq!(conventions(&Region::from_tag("pl-PL")), (Paper::A4, Measurement::Metric, ',')); + assert_eq!(conventions(&Region::from_tag("en-US")), (Paper::Letter, Measurement::Us, '.')); + assert_eq!(conventions(&Region::from_tag("fr-CA")), (Paper::Letter, Measurement::Metric, ',')); + assert_eq!(conventions(&Region::from_tag("en-DE")), (Paper::A4, Measurement::Metric, ',')); + assert_eq!(conventions(&Region::from_tag("en-GB")), (Paper::A4, Measurement::Metric, '.')); + assert_eq!(Region::from_tag("de-CH").decimal, '.'); + assert_eq!(Region::from_tag("es-MX").paper, Paper::Letter); + assert_eq!(Region::from_tag("ja-JP").paper, Paper::A4); + } + + #[test] + fn a_saved_setting_overrides_the_system() { + assert_eq!(Region::from_setting(" pl-PL "), Region::from_tag("pl-PL")); + assert_eq!(Region::from_setting("AUTO"), Region::system()); + assert_eq!(Region::from_setting(""), Region::system()); + } + + #[test] + fn posix_variables_set_each_convention() { + let env = |pairs: &'static [(&'static str, &'static str)]| move |n: &str| pairs.iter().find(|(k, _)| *k == n).map(|(_, v)| v.to_string()); + let none = || None; + let r = from_posix(env(&[("LANG", "en_US.UTF-8"), ("LC_NUMERIC", "pl_PL.UTF-8"), ("LC_TIME", "en_GB.UTF-8"), ("LC_PAPER", "de_DE")]), none); + assert_eq!((r.numbers.as_str(), r.time.as_str(), r.decimal), ("pl-PL", "en-GB", ',')); + assert_eq!((r.paper, r.measurement), (Paper::A4, Measurement::Us), "paper from LC_PAPER, measurement from LANG"); + assert_eq!(from_posix(env(&[("LANG", "it_IT.UTF-8"), ("LC_ALL", "en_US"), ("LC_PAPER", "de_DE")]), none).paper, Paper::Letter); + assert_eq!(from_posix(env(&[("LANG", "en_US"), ("LC_MEASUREMENT", "it_IT")]), none).measurement, Measurement::Metric); + assert_eq!(from_posix(env(&[("LANG", "C.UTF-8"), ("LC_ALL", "")]), || Some("it-IT".into())).numbers, "it-IT"); + assert_eq!(from_posix(env(&[]), none), Region::default()); + } + + #[test] + fn windows_regional_format_and_its_changes() { + let reg = |pairs: &'static [(&'static str, &'static str)]| move |n: &str| pairs.iter().find(|(k, _)| *k == n).map(|(_, v)| v.to_string()); + let r = from_windows( + reg(&[("LocaleName", "pl-PL"), ("sDecimal", ","), ("sThousand", "\u{a0}"), ("iMeasure", "0"), ("sShortTime", "HH:mm")]), + || None, + ); + assert_eq!((r.numbers.as_str(), r.decimal, r.grouping, r.hour_cycle), ("pl-PL", ',', Some('\u{a0}'), Some(HourCycle::H23))); + // An English format in the US with the user's metric and A4 choices and a decimal comma. + let r = from_windows( + reg(&[("LocaleName", "en-US"), ("sDecimal", ","), ("iMeasure", "0"), ("iPaperSize", "9"), ("sShortTime", "h:mm tt")]), + || None, + ); + assert_eq!((r.decimal, r.measurement, r.paper, r.hour_cycle), (',', Measurement::Metric, Paper::A4, Some(HourCycle::H12))); + let r = from_windows(reg(&[("sDecimal", "dec"), ("sThousand", "")]), || Some("de-DE".into())); + assert_eq!((r.numbers.as_str(), r.decimal, r.grouping), ("de-DE", ',', None), "a separator longer than one character keeps the locale's"); + } + + #[test] + fn apple_locale_values() { + let r = from_apple("en_PL", true, ",", "\u{a0}", Some("HH")); + assert_eq!( + (r.numbers.as_str(), r.paper, r.decimal, r.grouping, r.hour_cycle), + ("en-PL", Paper::A4, ',', Some('\u{a0}'), Some(HourCycle::H23)) + ); + let r = from_apple("en_US", false, ".", ",", Some("h a")); + assert_eq!((r.measurement, r.hour_cycle), (Measurement::Us, Some(HourCycle::H12))); + assert_eq!(from_apple("de_DE", true, ",", ".", Some("H 'Uhr'")).hour_cycle, Some(HourCycle::H23)); + assert_eq!(hour_cycle_of("'h' mm"), None, "quoted letters are text"); + } +} From 90a5999a1dfb1b94722bb7557e0c79bf898e79d4 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 04:44:16 +0000 Subject: [PATCH 04/43] i18n: parse_number for typed and pasted numbers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reads any Unicode decimal digits, the locale's decimal mark plus ٫, grouping by the locale's other mark, spaces, apostrophes or ٬ (threes or Indian 2-2-3), and -/−/+ signs, ignoring bidi marks. Input that fits no single reading is an error, not a guess. --- crates/i18n/README.md | 6 + crates/i18n/src/lib.rs | 3 + crates/i18n/src/number.rs | 316 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 325 insertions(+) create mode 100644 crates/i18n/src/number.rs diff --git a/crates/i18n/README.md b/crates/i18n/README.md index df3379712..6bb44dc25 100644 --- a/crates/i18n/README.md +++ b/crates/i18n/README.md @@ -62,6 +62,12 @@ The app keeps its `tl!` macro, its language setting (`"auto"` → `I18N.system_l code; English is "English". - **Right to left:** `rtl` is true for `ar`, `ckb`, `dv`, `fa`, `he`, `ps`, `sd`, `ug`, `ur` and `yi`; putting text into display order is the app's job. +- **Reading numbers:** `parse_number(text, locale)` reads what people type or paste: any Unicode + decimal digits (ASCII, Arabic-Indic, Persian, Devanagari, Bengali, Thai, full-width, …) from one + set; the locale's decimal mark (`.` or `,`) and `٫` everywhere; grouping by the locale's other + mark, spaces (NBSP, narrow NBSP), `'`, `’` or `٬`, in threes or the Indian 2-2-3; a leading `-`, + `−` or `+`; bidi marks ignored. Input that doesn't fit is an error, never a guess: `1,234` is + 1234 in English, 1.234 in German and an error for a locale whose decimal mark isn't known. - **System locale:** `system_lang` matches the system's list once and caches it: on desktop Unix `LANGUAGE`, `LC_ALL`, `LC_MESSAGES` and `LANG`, read raw; elsewhere `sys-locale`'s (macOS and Windows preferred languages, the browser's on the web). `fr_CA.UTF-8` → `fr`, `zh-TW` → diff --git a/crates/i18n/src/lib.rs b/crates/i18n/src/lib.rs index 7fa7a4efe..4ceaf2c2c 100644 --- a/crates/i18n/src/lib.rs +++ b/crates/i18n/src/lib.rs @@ -6,6 +6,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod catalog; +mod number; use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::sync::{Mutex, OnceLock, PoisonError}; @@ -13,6 +14,8 @@ use std::sync::{Mutex, OnceLock, PoisonError}; use intl_pluralrules::{PluralCategory, PluralRuleType, PluralRules}; use unic_langid::LanguageIdentifier; +pub use number::{NumberError, parse_number}; + /// The catalogs an app embeds, one entry per locale: (locale, shared catalog, the app's catalog). /// A missing catalog is `""`. pub type Embedded = [(&'static str, &'static str, &'static str)]; diff --git a/crates/i18n/src/number.rs b/crates/i18n/src/number.rs new file mode 100644 index 000000000..dfeb2e748 --- /dev/null +++ b/crates/i18n/src/number.rs @@ -0,0 +1,316 @@ +//! Reading numbers people type or paste, in any digits and with their locale's separators. + +use std::fmt; + +/// Why [`parse_number`] rejected a text. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum NumberError { + /// No digits: empty, or only spaces and a sign. + Empty, + /// A character that isn't a digit, sign, decimal mark or grouping mark. + Unexpected(char), + /// Digits from more than one set (`1٢`). + MixedDigits, + /// A decimal or grouping mark where the locale puts none (`1,5` in English, `1.5` in German), + /// or one that could be read either way (`1,234` when the locale's decimal mark is unknown). + Separator, + /// Too large for an `f64`. + TooLarge, +} + +impl fmt::Display for NumberError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + NumberError::Empty => write!(f, "no digits"), + NumberError::Unexpected(c) => write!(f, "unexpected character {c:?}"), + NumberError::MixedDigits => write!(f, "digits from more than one script"), + NumberError::Separator => write!(f, "misplaced or ambiguous decimal or grouping mark"), + NumberError::TooLarge => write!(f, "number too large"), + } + } +} + +impl std::error::Error for NumberError {} + +/// The number in `text`, as someone using `locale` (a language tag, `de-CH`, `ar_EG.UTF-8`) writes +/// it. +/// +/// - Digits: any Unicode decimal digits (ASCII, Arabic-Indic, Persian, Devanagari, Bengali, Thai, +/// full-width, …), all from one set. +/// - Decimal mark: the locale's `.` or `,`; `٫` in every locale. A locale this crate doesn't know +/// takes either, when only one reading fits (`1,5`, `1.234,5`). +/// - Grouping: the locale's other mark of `.` and `,`, spaces (NBSP, narrow NBSP, thin), `'`, `’` +/// and `٬`, one kind per number, in groups of three or the Indian 2-2-3 (`12,34,567`). Never in +/// the fraction. +/// - Sign: a leading `-`, `−` (U+2212), full-width `-` or `+`. +/// - Bidi marks and isolates (LRM, RLM, ALM, LRI…PDI) around or inside are ignored, so a number +/// formatted for an RTL interface reads back. +/// +/// Anything else is an error rather than a guess: `1,234` is 1234 in English, 1.234 in German and +/// [`NumberError::Separator`] for an unknown locale. +pub fn parse_number(text: &str, locale: &str) -> Result { + let text: String = text.chars().filter(|c| !is_bidi_mark(*c)).collect(); + let mut rest = text.trim(); + let negative = match rest.chars().next() { + Some(c @ ('-' | '−' | '-' | '+' | '+')) => { + rest = rest.get(c.len_utf8()..).unwrap_or(""); + matches!(c, '-' | '−' | '-') + } + _ => false, + }; + let mut tokens = Vec::with_capacity(rest.len()); + let mut zero = None; + for c in rest.chars() { + let token = match c { + '.' | '.' => Token::Point, + ',' | ',' => Token::Comma, + '٫' => Token::Decimal, + '٬' => Token::Group('٬'), + '\'' | '’' => Token::Group('\''), + ' ' | '\u{a0}' | '\u{202f}' | '\u{2009}' => Token::Group(' '), + _ => { + let (z, d) = digit(c).ok_or(NumberError::Unexpected(c))?; + if *zero.get_or_insert(z) != z { + return Err(NumberError::MixedDigits); + } + Token::Digit(d) + } + }; + tokens.push(token); + } + if zero.is_none() { + return Err(if tokens.is_empty() { NumberError::Empty } else { NumberError::Separator }); + } + let digits = match decimal_mark(locale) { + Some(point) => read(&tokens, point), + None => match (read(&tokens, true), read(&tokens, false)) { + (Some(a), Some(b)) if a != b => None, + (a, b) => a.or(b), + }, + } + .ok_or(NumberError::Separator)?; + let value: f64 = digits.parse().map_err(|_| NumberError::Separator)?; + if !value.is_finite() { + return Err(NumberError::TooLarge); + } + Ok(if negative { -value } else { value }) +} + +#[derive(Clone, Copy, PartialEq)] +enum Token { + Digit(u8), + Point, + Comma, + /// `٫`, a decimal mark in every locale. + Decimal, + /// A grouping mark in every locale, by kind (space, apostrophe, `٬`). + Group(char), +} + +/// The ASCII number `tokens` spell when the decimal mark is `.` (`point`) or `,`; `None` when the +/// marks don't fit that reading. +fn read(tokens: &[Token], point: bool) -> Option { + let tokens: Vec = tokens + .iter() + .map(|t| match t { + Token::Point if point => Token::Decimal, + Token::Point => Token::Group('.'), + Token::Comma if point => Token::Group(','), + Token::Comma => Token::Decimal, + t => *t, + }) + .collect(); + let (int, frac) = match tokens.iter().position(|t| *t == Token::Decimal) { + Some(i) => (tokens.get(..i)?, tokens.get(i + 1..)?), + None => (&tokens[..], &[][..]), + }; + let ascii = + |part: &[Token]| -> Option { part.iter().map(|t| if let Token::Digit(d) = t { Some(char::from(b'0' + d)) } else { None }).collect() }; + let frac = ascii(frac)?; + let mut group = None; + let mut groups = Vec::new(); + for chunk in int.split(|t| matches!(t, Token::Group(_))) { + groups.push(ascii(chunk)?); + } + for t in int { + if let Token::Group(g) = t + && *group.get_or_insert(*g) != *g + { + return None; + } + } + if let [first, middle @ .., last] = groups.as_slice() + && group.is_some() + { + let width = middle.first().map_or(3, String::len); + let first_max = if width == 2 { 2 } else { 3 }; + let fits = (1..=first_max).contains(&first.len()) + && !first.starts_with('0') + && last.len() == 3 + && middle.iter().all(|g| g.len() == width) + && (2..=3).contains(&width); + if !fits { + return None; + } + } + let int = groups.concat(); + if int.is_empty() && frac.is_empty() { + return None; + } + Some(format!("{}.{}", if int.is_empty() { "0" } else { &int }, if frac.is_empty() { "0" } else { &frac })) +} + +/// Whether `locale` writes decimals with `.` (`Some(true)`) or `,` (`Some(false)`); `None` for a +/// language not in the table (CLDR's decimal symbol for its Latin digits). +// ponytail: the languages a Craft app ships or could plausibly ship; add a row with the locale. +fn decimal_mark(locale: &str) -> Option { + let locale = locale.trim().to_ascii_lowercase(); + let base = locale.split(['.', '@']).next().unwrap_or("").replace('_', "-"); + let mut parts = base.split('-').filter(|p| !p.is_empty()); + let language = parts.next().unwrap_or(""); + let region = parts.find(|p| super::is_region(p)).unwrap_or(""); + Some(match (language, region) { + ("de" | "it", "ch" | "li") => true, + ("es", "419" | "mx" | "us" | "pr" | "do" | "gt" | "hn" | "ni" | "pa" | "pe" | "sv") => true, + ("en", "za" | "150" | "at" | "be" | "de" | "dk" | "fi" | "nl" | "se" | "si") => false, + ("ar", "dz" | "eh" | "ly" | "ma" | "tn") => false, + ( + "af" | "az" | "be" | "bg" | "bs" | "ca" | "cs" | "da" | "de" | "el" | "eo" | "es" | "et" | "eu" | "fi" | "fo" | "fr" | "fy" | "gl" | "hr" + | "hu" | "hy" | "id" | "is" | "it" | "ka" | "kk" | "km" | "ky" | "lb" | "lo" | "lt" | "lv" | "mk" | "nb" | "nl" | "nn" | "no" | "pl" + | "pt" | "ro" | "ru" | "sk" | "sl" | "sq" | "sr" | "sv" | "tg" | "tk" | "tr" | "tt" | "uk" | "uz" | "vi", + _, + ) => false, + ( + "am" | "ar" | "as" | "bn" | "bo" | "ckb" | "cy" | "dv" | "en" | "fa" | "fil" | "ga" | "gsw" | "gu" | "he" | "hi" | "ja" | "kn" | "ko" + | "ks" | "ml" | "mn" | "mr" | "ms" | "mt" | "my" | "ne" | "or" | "pa" | "ps" | "rm" | "sd" | "si" | "sw" | "ta" | "te" | "th" | "tl" + | "ug" | "ur" | "yue" | "zh" | "zu", + _, + ) => true, + _ => return None, + }) +} + +/// The first code point of each run of ten Unicode decimal digits (general category Nd), Unicode +/// 16.0. +const ZEROS: [u32; 76] = [ + 0x30, 0x660, 0x6F0, 0x7C0, 0x966, 0x9E6, 0xA66, 0xAE6, 0xB66, 0xBE6, 0xC66, 0xCE6, 0xD66, 0xDE6, 0xE50, 0xED0, 0xF20, 0x1040, 0x1090, 0x17E0, + 0x1810, 0x1946, 0x19D0, 0x1A80, 0x1A90, 0x1B50, 0x1BB0, 0x1C40, 0x1C50, 0xA620, 0xA8D0, 0xA900, 0xA9D0, 0xA9F0, 0xAA50, 0xABF0, 0xFF10, 0x104A0, + 0x10D30, 0x10D40, 0x11066, 0x110F0, 0x11136, 0x111D0, 0x112F0, 0x11450, 0x114D0, 0x11650, 0x116C0, 0x116D0, 0x116DA, 0x11730, 0x118E0, 0x11950, + 0x11BF0, 0x11C50, 0x11D50, 0x11DA0, 0x11F50, 0x16130, 0x16A60, 0x16AC0, 0x16B50, 0x16D70, 0x1CCF0, 0x1D7CE, 0x1D7D8, 0x1D7E2, 0x1D7EC, 0x1D7F6, + 0x1E140, 0x1E2F0, 0x1E4F0, 0x1E5F1, 0x1E950, 0x1FBF0, +]; + +/// `c`'s digit set (its zero) and value. +fn digit(c: char) -> Option<(u32, u8)> { + let c = u32::from(c); + let zero = match ZEROS.binary_search(&c) { + Ok(i) => ZEROS.get(i), + Err(i) => ZEROS.get(i.checked_sub(1)?), + }?; + let value = c - zero; + u8::try_from(value).ok().filter(|v| *v < 10).map(|v| (*zero, v)) +} + +/// Marks bidi formatting adds around numbers: LRM, RLM, ALM, embeddings, overrides and isolates. +fn is_bidi_mark(c: char) -> bool { + matches!(c, '\u{200e}' | '\u{200f}' | '\u{061c}' | '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}') +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_digit_set() { + for zero in ZEROS { + let d = |n: u32| char::from_u32(zero + n).unwrap(); + let all: String = (0..10).rev().map(d).collect(); + assert_eq!(parse_number(&all, "en"), Ok(9_876_543_210.0), "{zero:X}"); + let grouped = format!("-{}{}{},{}{}{}.{}", d(1), d(2), d(3), d(4), d(5), d(6), d(7)); + assert_eq!(parse_number(&grouped, "en"), Ok(-123_456.7), "{zero:X}"); + assert!((0..10).all(|n| d(n).is_numeric()), "{zero:X}"); + } + assert_eq!(digit('\u{1D800}'), None); + assert_eq!(digit('/'), None); + } + + #[test] + fn locale_forms() { + for (text, locale, want) in [ + ("1,234.5", "en", 1234.5), + ("1.234,5", "de", 1234.5), + ("1 234,5", "fr", 1234.5), + ("1\u{202f}234,5", "fr", 1234.5), + ("1\u{a0}234,5", "ru", 1234.5), + ("1’234.5", "de-CH", 1234.5), + ("1'234.5", "de_CH.UTF-8", 1234.5), + ("1,234", "en", 1234.0), + ("1,234", "de", 1.234), + ("1.234", "pt-br", 1234.0), + ("1,5", "es", 1.5), + ("1.5", "es-419", 1.5), + ("١٬٢٣٤٫٥", "ar", 1234.5), + ("١٢٫٥", "ar", 12.5), + ("12.5", "ar", 12.5), + ("12,5", "ar-MA", 12.5), + ("۱۲٫۵", "fa", 12.5), + ("−۴۲", "fa", -42.0), + ("123,456.7", "ja", 123_456.7), + ("-5", "ja", -5.0), + ("१२,३४,५६७.८९", "hi", 1_234_567.89), + ("১২,৩৪,৫৬৭", "bn", 1_234_567.0), + ("౧,౨౩,౪౫౬", "te", 123_456.0), + ("๑,๒๓๔.๕", "th", 1234.5), + ("1,234,567", "en", 1_234_567.0), + ("−3,5", "sv", -3.5), + ("+7", "en", 7.0), + (".5", "en", 0.5), + ("5.", "en", 5.0), + (" 42 ", "en", 42.0), + ("\u{2066}-1,5\u{2069}", "de", -1.5), + ("\u{061c}-١٢", "ar", -12.0), + ("1,5", "xx", 1.5), + ("1.234,5", "", 1234.5), + ("1,234.5", "xx", 1234.5), + ("1.234.567", "xx", 1_234_567.0), + ("12", "xx", 12.0), + ] { + assert_eq!(parse_number(text, locale), Ok(want), "{text:?} in {locale}"); + } + } + + #[test] + fn errors_instead_of_guesses() { + for (text, locale, want) in [ + ("", "en", NumberError::Empty), + (" ", "en", NumberError::Empty), + ("-", "en", NumberError::Empty), + (",", "en", NumberError::Separator), + ("1,234", "xx", NumberError::Separator), + ("1,5", "en", NumberError::Separator), + ("1.5", "de", NumberError::Separator), + ("1.2.3", "en", NumberError::Separator), + ("12,34", "en", NumberError::Separator), + ("1234,567", "en", NumberError::Separator), + ("1,,234", "en", NumberError::Separator), + ("1,234,", "en", NumberError::Separator), + ("1.234,5", "en", NumberError::Separator), + ("1 234'567", "en", NumberError::Separator), + ("1.234 567", "de", NumberError::Separator), + ("0.123,4", "de", NumberError::Separator), + ("1.5,2", "en", NumberError::Separator), + ("123,45,678", "hi", NumberError::Separator), + ("12٫3٫4", "ar", NumberError::Separator), + ("1٫5.2", "ar", NumberError::Separator), + ("1٢", "ar", NumberError::MixedDigits), + ("12abc", "en", NumberError::Unexpected('a')), + ("1e5", "en", NumberError::Unexpected('e')), + ("--5", "en", NumberError::Unexpected('-')), + ("5-", "en", NumberError::Unexpected('-')), + ("- 5", "en", NumberError::Separator), + ] { + assert_eq!(parse_number(text, locale), Err(want), "{text:?} in {locale}"); + } + assert_eq!(parse_number(&"9".repeat(400), "en"), Err(NumberError::TooLarge)); + } +} From 3fcaddbdaeaefffb89563228d70d9743ad89d0aa Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 04:44:53 +0000 Subject: [PATCH 05/43] i18n: file sizes and measures with translatable unit names I18n::file_size writes a byte count in steps of 1024 (KB or KiB) or 1000 (KB), with decimals per unit, no decimals from a given value up, a smallest and largest unit, and bytes as B or in words. I18n::measure writes any value with a unit template such as "{n} px". Unit names are catalog rows in context `unit`, keyed by the English template, so a translation can change the symbol and its spacing. --- crates/i18n/README.md | 8 ++ crates/i18n/src/lib.rs | 3 + crates/i18n/src/size.rs | 242 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 253 insertions(+) create mode 100644 crates/i18n/src/size.rs diff --git a/crates/i18n/README.md b/crates/i18n/README.md index df3379712..f3daff2c0 100644 --- a/crates/i18n/README.md +++ b/crates/i18n/README.md @@ -68,6 +68,14 @@ The app keeps its `tl!` macro, its language setting (`"auto"` → `I18N.system_l `zh-hant`, `pt-PT` → `pt-br` when that is the only Portuguese, `C`/`POSIX` → English. A script is never crossed: `sr_RS@latin` and `sr-Latn-RS` pick `sr-latn`, never `sr`, while `sr-Cyrl-RS` picks `sr`. +- **Sizes and units:** `file_size(lang, bytes, style)` writes `1.5 MB` or `512 bytes`; `FileSize` + picks steps of 1024 named KB (`BINARY`), 1024 named KiB (`IEC`) or 1000 named KB (`DECIMAL`), + the decimals per unit, no decimals from a given value up, the smallest and largest unit (below + the smallest, sizes round up), and `B` or words for bytes. `measure(lang, value, decimals, + "{n} px")` writes any other measure. Unit names are rows in context `unit`, keyed by the English + template (`{n} MB` → `{n} Mo`, `{n}%` → `{n} %`) and read with `get`, so a plain `B` row (bold) + is never used; bytes in words are the `@plural` row `{n} byte|{n} bytes`. Numbers are + ASCII digits with a decimal point. - **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 translation. `looked_up()` lists the source texts looked up so far, for an audit against a diff --git a/crates/i18n/src/lib.rs b/crates/i18n/src/lib.rs index 7fa7a4efe..30eb69727 100644 --- a/crates/i18n/src/lib.rs +++ b/crates/i18n/src/lib.rs @@ -6,6 +6,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod catalog; +mod size; use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::sync::{Mutex, OnceLock, PoisonError}; @@ -13,6 +14,8 @@ use std::sync::{Mutex, OnceLock, PoisonError}; use intl_pluralrules::{PluralCategory, PluralRuleType, PluralRules}; use unic_langid::LanguageIdentifier; +pub use size::{FileSize, SizeUnit}; + /// The catalogs an app embeds, one entry per locale: (locale, shared catalog, the app's catalog). /// A missing catalog is `""`. pub type Embedded = [(&'static str, &'static str, &'static str)]; diff --git a/crates/i18n/src/size.rs b/crates/i18n/src/size.rs new file mode 100644 index 000000000..1466672b1 --- /dev/null +++ b/crates/i18n/src/size.rs @@ -0,0 +1,242 @@ +//! File sizes and other measures with a unit, in the interface language. +//! +//! Unit names are catalog rows in context `unit`, keyed by the English template with the +//! number as `{n}` (`{n} MB`, `{n} px`, `{n}%`), so a translation can change the symbol and its +//! spacing (`{n} Mo`, `{n} %`). They are looked up with [`I18n::get`], never the plain row: a +//! plain `B` row means bold. A byte count in words is the `@plural` row `{n} byte|{n} bytes`. + +use crate::{I18n, Lang, fmt}; + +/// The context unit rows are in. +const CONTEXT: &str = "unit"; + +/// A file-size unit. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum SizeUnit { + Byte, + Kilo, + Mega, + Giga, + Tera, +} + +impl SizeUnit { + const ALL: [SizeUnit; 5] = [SizeUnit::Byte, SizeUnit::Kilo, SizeUnit::Mega, SizeUnit::Giga, SizeUnit::Tera]; +} + +/// How [`I18n::file_size`] writes a byte count: the step between units, their names, and the +/// digits after the decimal point. Start from [`FileSize::BINARY`], [`FileSize::IEC`] or +/// [`FileSize::DECIMAL`] and adjust: +/// +/// ``` +/// use craft_i18n::{FileSize, SizeUnit}; +/// // `12 MB`, `3.4 MB`, `640 KB`: decimal steps, whole kilobytes, no decimals from 10 MB. +/// let style = FileSize::DECIMAL.smallest(SizeUnit::Kilo).largest(SizeUnit::Mega).unit_decimals(SizeUnit::Kilo, 0).whole_from(10); +/// ``` +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct FileSize { + step: u64, + iec: bool, + /// Per unit, in [`SizeUnit::ALL`] order; bytes are always whole. + decimals: [u8; 5], + whole_from: u32, + smallest: SizeUnit, + largest: SizeUnit, + byte_symbol: bool, +} + +impl FileSize { + /// Steps of 1024 named KB, MB, GB and TB, as Windows Explorer shows sizes; one decimal, bytes + /// in words. + pub const BINARY: FileSize = FileSize { + step: 1024, + iec: false, + decimals: [0, 1, 1, 1, 1], + whole_from: 0, + smallest: SizeUnit::Byte, + largest: SizeUnit::Tera, + byte_symbol: false, + }; + /// Steps of 1024 named KiB, MiB, GiB and TiB. + pub const IEC: FileSize = FileSize { iec: true, ..FileSize::BINARY }; + /// Steps of 1000 named KB, MB, GB and TB, as macOS shows sizes and as download and disk sizes + /// are sold. + pub const DECIMAL: FileSize = FileSize { step: 1000, ..FileSize::BINARY }; + + /// `n` digits after the decimal point in every unit above bytes. + pub const fn decimals(mut self, n: u8) -> FileSize { + self.decimals = [0, n, n, n, n]; + self + } + + /// `n` digits after the decimal point in `unit` (`0` for whole kilobytes, `2` for gigabytes). + pub const fn unit_decimals(mut self, unit: SizeUnit, n: u8) -> FileSize { + if !matches!(unit, SizeUnit::Byte) { + self.decimals[unit as usize] = n; + } + self + } + + /// No decimals from `n` of a unit up: `whole_from(10)` writes `9.5 MB` and `12 MB`. `0`, the + /// default, keeps them. + pub const fn whole_from(mut self, n: u32) -> FileSize { + self.whole_from = n; + self + } + + /// The smallest unit shown. Sizes below it are written in it, rounded up, so a file that + /// isn't empty never reads `0 KB`. + pub const fn smallest(mut self, unit: SizeUnit) -> FileSize { + self.smallest = unit; + self + } + + /// The largest unit shown; larger sizes are counted in it (`2048 GB`). + pub const fn largest(mut self, unit: SizeUnit) -> FileSize { + self.largest = unit; + self + } + + /// Bytes as the symbol `B` (`512 B`) instead of words (`512 bytes`). + pub const fn byte_symbol(mut self) -> FileSize { + self.byte_symbol = true; + self + } + + /// The unit `bytes` is written in and the number in it, in English. + fn parts(&self, bytes: u64) -> (SizeUnit, String) { + let (smallest, largest) = (self.smallest.min(self.largest), self.largest); + let mut unit = smallest; + for &u in SizeUnit::ALL.iter().filter(|u| (smallest..=largest).contains(u)) { + if bytes as f64 >= self.scale(u) { + unit = u; + } + } + loop { + let value = bytes as f64 / self.scale(unit); + let mut decimals = self.decimals.get(unit as usize).copied().unwrap_or(0); + if unit == SizeUnit::Byte || (self.whole_from > 0 && value >= f64::from(self.whole_from)) { + decimals = 0; + } + let factor = 10f64.powi(i32::from(decimals)); + let rounded = if unit == smallest && bytes > 0 && value < 1.0 { (value * factor).ceil() } else { (value * factor).round() } / factor; + // 1023.96 KB rounds to 1024.0 KB: the next unit writes it as 1.0 MB. + if rounded >= self.step as f64 + && let Some(&next) = SizeUnit::ALL.get(unit as usize + 1) + && next <= largest + { + unit = next; + continue; + } + return (unit, number(rounded, usize::from(decimals))); + } + } + + fn scale(&self, unit: SizeUnit) -> f64 { + (self.step as f64).powi(unit as i32) + } + + /// The English template for `unit`, which is also its catalog key. + fn template(&self, unit: SizeUnit) -> &'static str { + match (unit, self.iec) { + (SizeUnit::Byte, _) => "{n} B", + (SizeUnit::Kilo, false) => "{n} KB", + (SizeUnit::Mega, false) => "{n} MB", + (SizeUnit::Giga, false) => "{n} GB", + (SizeUnit::Tera, false) => "{n} TB", + (SizeUnit::Kilo, true) => "{n} KiB", + (SizeUnit::Mega, true) => "{n} MiB", + (SizeUnit::Giga, true) => "{n} GiB", + (SizeUnit::Tera, true) => "{n} TiB", + } + } +} + +impl I18n { + /// `bytes` as a file size in `lang`, written the way `style` says (`1.5 MB`, `512 bytes`). + pub fn file_size(&self, lang: Lang, bytes: u64, style: FileSize) -> String { + let (unit, n) = style.parts(bytes); + if unit == SizeUnit::Byte && !style.byte_symbol { + return self.trn(lang, bytes, "{n} byte", "{n} bytes"); + } + self.unit_text(lang, style.template(unit), &n) + } + + /// `value` with `decimals` digits after the decimal point in the unit template `unit`, an + /// English catalog key with the number as `{n}` (`{n} px`, `{n}%`, `{n} dB`). + pub fn measure(&self, lang: Lang, value: f64, decimals: usize, unit: &str) -> String { + self.unit_text(lang, unit, &number(value, decimals)) + } + + fn unit_text(&self, lang: Lang, template: &str, n: &str) -> String { + fmt(self.get(lang, CONTEXT, template).unwrap_or(template), &[("n", n)]) + } +} + +/// `value` with `decimals` digits after the decimal point, in ASCII digits with a decimal point. +/// The one place sizes and measures write numbers, for craft-i18n's locale number formatting +/// (digits, separators) to take over. +fn number(value: f64, decimals: usize) -> String { + format!("{value:.decimals$}") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Embedded; + + static E: &Embedded = &[( + "fr", + "unit\t{n} KB\t{n} Ko\nunit\t{n} MB\t{n} Mo\nunit\t{n} B\t{n} o\nunit\t{n}%\t{n} %\n\tB\tG\n@plural\t{n} byte|{n} bytes\t{n} octet|{n} octets\n", + "", + )]; + + fn en(bytes: u64, style: FileSize) -> String { + let (i18n, _) = I18n::new(&[], &[]); + i18n.file_size(Lang::EN, bytes, style) + } + + #[test] + fn binary_decimal_and_iec_steps() { + assert_eq!(en(512, FileSize::BINARY), "512 bytes"); + assert_eq!(en(1, FileSize::BINARY), "1 byte"); + assert_eq!(en(1536, FileSize::BINARY), "1.5 KB"); + assert_eq!(en(1536, FileSize::IEC), "1.5 KiB"); + assert_eq!(en(1500, FileSize::DECIMAL), "1.5 KB"); + assert_eq!(en(3 << 30, FileSize::BINARY), "3.0 GB"); + assert_eq!(en(5 << 40, FileSize::IEC.decimals(0)), "5 TiB"); + assert_eq!(en(512, FileSize::BINARY.byte_symbol()), "512 B"); + } + + #[test] + fn precision_and_unit_range() { + // Rounding up to the next unit's step moves to that unit. + assert_eq!(en(1_048_575, FileSize::BINARY), "1.0 MB"); + assert_eq!(en(999_999, FileSize::DECIMAL.decimals(0)), "1 MB"); + assert_eq!(en(1_610_612_736, FileSize::BINARY.unit_decimals(SizeUnit::Giga, 2)), "1.50 GB"); + // Lightcraft's denoise sizes: whole kilobytes, at least 1, no decimals from 10 MB. + let style = FileSize::DECIMAL.smallest(SizeUnit::Kilo).largest(SizeUnit::Mega).unit_decimals(SizeUnit::Kilo, 0).whole_from(10); + assert_eq!(en(1, style), "1 KB"); + assert_eq!(en(0, style), "0 KB"); + assert_eq!(en(640_400, style), "640 KB"); + assert_eq!(en(3_400_000, style), "3.4 MB"); + assert_eq!(en(12_600_000, style), "13 MB"); + assert_eq!(en(5_000_000_000, style), "5000 MB"); + // Only sizes below the smallest unit round up. + assert_eq!(en(1025, FileSize::BINARY.smallest(SizeUnit::Kilo).unit_decimals(SizeUnit::Kilo, 0)), "1 KB"); + assert_eq!(en(100, FileSize::BINARY.smallest(SizeUnit::Kilo).unit_decimals(SizeUnit::Kilo, 0)), "1 KB"); + } + + #[test] + fn unit_names_are_catalog_rows() { + let (i18n, problems) = I18n::new(E, &["fr"]); + assert!(problems.is_empty(), "{problems:?}"); + let fr = i18n.from_code("fr").unwrap(); + assert_eq!(i18n.file_size(fr, 1536, FileSize::BINARY), "1.5 Ko"); + assert_eq!(i18n.file_size(fr, 512, FileSize::BINARY), "512 octets"); + assert_eq!(i18n.file_size(fr, 512, FileSize::BINARY.byte_symbol()), "512 o", "not the plain row B (bold)"); + assert_eq!(i18n.file_size(fr, 3 << 30, FileSize::BINARY), "3.0 GB", "a missing row is English"); + assert_eq!(i18n.measure(fr, 50.0, 0, "{n}%"), "50 %"); + assert_eq!(i18n.measure(Lang::EN, 12.34, 1, "{n} px"), "12.3 px"); + } +} From 69a4154fb803a5d7d654e9dc5f298a6556e3715a Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 04:56:28 +0000 Subject: [PATCH 06/43] craft-i18n: search folding and locale sorting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Folder` folds text for case- and accent-insensitive search (Turkish and Azerbaijani i by locale, ß, final sigma, ligatures, full-width forms) and maps matches back to the original text for highlighting. `Collator` (feature `collate`) sorts lists in the locale's order with digits compared by value, on ICU4X with collation data for the shipped locales only. The Turkish i and sigma handling come from PdfCraft's `fold_case`. Part of #198. Co-authored-by: snipeTR <31975916+snipeTR@users.noreply.github.com> Co-authored-by: nevicia2010-hub --- ATTRIBUTION.md | 1 + Cargo.lock | 133 ++++++++++ crates/i18n/Cargo.toml | 22 +- crates/i18n/README.md | 10 + crates/i18n/data/collation.postcard | Bin 0 -> 749171 bytes crates/i18n/data/generate-collation.sh | 15 ++ crates/i18n/src/collate.rs | 191 ++++++++++++++ crates/i18n/src/fold.rs | 330 +++++++++++++++++++++++++ crates/i18n/src/lib.rs | 7 + 9 files changed, 708 insertions(+), 1 deletion(-) create mode 100644 crates/i18n/data/collation.postcard create mode 100755 crates/i18n/data/generate-collation.sh create mode 100644 crates/i18n/src/collate.rs create mode 100644 crates/i18n/src/fold.rs diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 34cd2ee23..f3b1ed66a 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -9,6 +9,7 @@ author, source and license. Library code and original assets are under the repos | `crates/cms/profiles/photocraft-coated-cmyk.icc` | PhotoCraft contributors; generated by `craft_cms::synth::coated_cmyk` | [PhotoCraft `crates/cms`](https://github.com/storytold/photocraft/tree/b3222ee8e7e6bcb4799c37f904c595f20f9baf5e/crates/cms) | CC0-1.0 | | `crates/cms/profiles/craft-coated-fogra39.icc` | craft-libs contributors; generated by `crates/cms/tests/fogra39.rs` from the FOGRA39 data below | this repository | CC0-1.0 | | `crates/cms/tests/data/FOGRA39.txt` (unmodified) | Fogra | [Fogra characterisation data](https://fogra.org/en/downloads/work-tools/characterisation-data), `FOGRA39_to_FOGRA60_v2.zip` | Fogra terms: free use and redistribution, unmodified, with Fogra identified as the source; "FOGRA39" identifies the reference data only and implies no certification, approval or endorsement by Fogra (full text in [NOTICE](NOTICE)) | +| `crates/i18n/data/collation.postcard` | Unicode, Inc. (ICU 78.1 collation data, CLDR 48.2.1); generated by `crates/i18n/data/generate-collation.sh` | [ICU](https://github.com/unicode-org/icu/releases/tag/release-78.1rc) | Unicode-3.0 | Original assets are covered by the repository licence. diff --git a/Cargo.lock b/Cargo.lock index 4d9de4289..7f23f26b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -491,6 +491,15 @@ dependencies = [ "x509-cert", ] +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.21", +] + [[package]] name = "codespan-reporting" version = "0.13.1" @@ -656,6 +665,14 @@ version = "0.1.0" name = "craft-i18n" version = "0.1.0" dependencies = [ + "icu_collator", + "icu_locale_core", + "icu_locale_fallback", + "icu_normalizer", + "icu_properties", + "icu_provider", + "icu_provider_adapters", + "icu_provider_blob", "intl_pluralrules", "sys-locale", "unic-langid", @@ -1091,6 +1108,18 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + [[package]] name = "encoding_rs" version = "0.8.42" @@ -1845,6 +1874,24 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_collator" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08984ed58ac439ebf3e13d2cf26b0c46a60afcd21721c1d14087c0b240344dda" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_normalizer", + "icu_properties", + "icu_provider", + "serde", + "smallvec", + "utf16_iter", + "utf8_iter", + "zerovec", +] + [[package]] name = "icu_collections" version = "2.3.0" @@ -1853,6 +1900,7 @@ checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", + "serde", "utf8_iter", "yoke", "zerofrom", @@ -1867,11 +1915,32 @@ checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", + "serde", "tinystr", "writeable", "zerovec", ] +[[package]] +name = "icu_locale_fallback" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9" +dependencies = [ + "icu_locale_core", + "icu_locale_fallback_data", + "icu_provider", + "potential_utf", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8" + [[package]] name = "icu_normalizer" version = "2.3.0" @@ -1882,7 +1951,11 @@ dependencies = [ "icu_normalizer_data", "icu_properties", "icu_provider", + "serde", "smallvec", + "utf16_iter", + "utf8_iter", + "write16", "zerovec", ] @@ -1903,6 +1976,7 @@ dependencies = [ "icu_locale_core", "icu_properties_data", "icu_provider", + "serde", "zerotrie", "zerovec", ] @@ -1921,6 +1995,9 @@ checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", + "postcard", + "serde", + "stable_deref_trait", "writeable", "yoke", "zerofrom", @@ -1928,6 +2005,30 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_provider_adapters" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df887bc4f55dbc773c7c45a0c7dfa75be886f7b7812830a71ab7271cc1801820" +dependencies = [ + "icu_locale_fallback", + "icu_provider", +] + +[[package]] +name = "icu_provider_blob" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5916b44fac319d3f58e4d8d805a627df19e27937b582cd99fcde3bb6b7ce1b0e" +dependencies = [ + "icu_provider", + "postcard", + "serde", + "writeable", + "zerotrie", + "zerovec", +] + [[package]] name = "idna" version = "1.1.0" @@ -2248,6 +2349,9 @@ name = "litemap" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" +dependencies = [ + "serde_core", +] [[package]] name = "litrs" @@ -3079,12 +3183,26 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "serde", +] + [[package]] name = "potential_utf" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ + "serde_core", + "writeable", "zerovec", ] @@ -4842,6 +4960,12 @@ dependencies = [ "xmlwriter", ] +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -5787,6 +5911,12 @@ dependencies = [ "read-fonts 0.39.2", ] +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + [[package]] name = "writeable" version = "0.6.4" @@ -5985,8 +6115,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", + "litemap", + "serde_core", "yoke", "zerofrom", + "zerovec", ] [[package]] diff --git a/crates/i18n/Cargo.toml b/crates/i18n/Cargo.toml index 45e21775e..547e06f7d 100644 --- a/crates/i18n/Cargo.toml +++ b/crates/i18n/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "craft-i18n" -description = "Translations for the Craft apps: the embedded craft-i18n catalogs, lookup with fallback, plurals, language names and the system locale." +description = "Translations for the Craft apps: the embedded craft-i18n catalogs, lookup with fallback, plurals, language names, the system locale, search folding and locale sorting." version.workspace = true edition.workspace = true license.workspace = true @@ -11,9 +11,29 @@ repository.workspace = true intl_pluralrules = "=7.0.2" unic-langid = "0.9" sys-locale = "0.3" +icu_normalizer = "~2.3" +icu_properties = "~2.3" +# Generated data fits one ICU4X minor version; data/generate-collation.sh names its datagen. +icu_collator = { version = "~2.3.1", default-features = false, features = ["serde"], optional = true } +icu_provider = { version = "~2.3", features = ["serde"], optional = true } +icu_provider_blob = { version = "~2.3", features = ["alloc"], optional = true } +icu_provider_adapters = { version = "~2.3", optional = true } +icu_locale_core = { version = "2.3", optional = true } +icu_locale_fallback = { version = "~2.3", features = ["compiled_data"], optional = true } [target.'cfg(target_arch = "wasm32")'.dependencies] sys-locale = { version = "0.3", features = ["js"] } +[features] +# Locale sorting (`collate`), with ICU4X collation data for the suite's locales embedded. +collate = [ + "dep:icu_collator", + "dep:icu_locale_core", + "dep:icu_locale_fallback", + "dep:icu_provider", + "dep:icu_provider_adapters", + "dep:icu_provider_blob", +] + [lints] workspace = true diff --git a/crates/i18n/README.md b/crates/i18n/README.md index df3379712..77e3d9967 100644 --- a/crates/i18n/README.md +++ b/crates/i18n/README.md @@ -72,3 +72,13 @@ The app keeps its `tl!` macro, its language setting (`"auto"` → `I18N.system_l included, comes back as `⟦…⟧`, line by line, so text drawn without the marks never went through translation. `looked_up()` lists the source texts looked up so far, for an audit against a complete catalog. +- **Search** (`Folder`): `Folder::new(locale).fold(text)` gives a key for case- and + accent-insensitive matching (`keep_accents()` makes accents count) and a map back to the + original text, so `Folded::find` returns the byte range to highlight. Compatibility forms fold + to their plain letters (`fi`, full-width `A`), `ß` to `ss`, `ς` to `σ`; invisible format + characters (soft hyphens, joiners, bidi isolates) are dropped. Turkish and Azerbaijani fold `I` + to `ı` when accents count; ignoring accents, `I`, `i`, `İ` and `ı` all match. +- **Sorting** (`Collator`, feature `collate`): ICU4X collation for the locales craft-i18n ships, + with digits compared by value (`Layer 2` before `Layer 10`) and `-u-co-pinyin` / + `-u-co-stroke` for Chinese. The data (750 KB) is regenerated by `data/generate-collation.sh`; + the feature adds about 1 MB to a release binary or wasm module (320 KB gzipped). diff --git a/crates/i18n/data/collation.postcard b/crates/i18n/data/collation.postcard new file mode 100644 index 0000000000000000000000000000000000000000..0ea89d72b5b70cff1b93486909e2f2515ae38509 GIT binary patch literal 749171 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ⅇlr@YkgCzMe39Gllj6%>s98}rKmhR@%~4yF?fB7 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 l(wvpL*&!>&=d4srw~p)mv4(EF=a|-< z7%gSX^O5pLFIZ{LiuyO_#pjg!E44r3yg750HplBW_w%)X;_Nwd9&C>Ln|t)16Z&g3 z<;QFP>2v4Ixu-d{ALRR=)AI9z(7#81?r)A${$}}c+~?}}wDc})>KO;)^`*BsKWEN@=6J65^E9?!^wC?W{bwS+alJ$GZ9-0duMS*kUxXe!pBA{x z3vnL|er>|g{ej&FXcw0H- z$>-9*=X~&gXUMZIuMN5J5cda?oB!u%OkMEP(;?qMIL>b#C*FlRZhqlyD~;WkjE~Y7 z9`MKboQ^Saj-M>K`7{5>f2uw*F68&9PVj-9^8){q(QnR+`u#zEJ>OFPXvoQ9RO8HJ zB*tm?66NoCnd`&);cwF>KKyOhf$Naq&v`O0?^j;-YkTA?hrQ+VB(H_s_|Si;{5#*| z=ij04`r>C^;C5eu`_qye4>-@&*mZPYqPMZ+t`B_tR^zNcbz;0o@vY}RMgDT(U8X&H z<^%azq3?B(dcG)l0`ID_H!gU1TgMo`*u6kCe3SHDx5V@IE{ zJghA{=jXVLH;nbrd3rt?Cwblx{;>aH%uoCi2fRO&p7Tb2d*F3kaP2QW*D3q)_g(P7 z+n=Ma@w>77IzP`B^4UK0h__APHctBFRM}@ge!e50O~SwNf#a?6=lNned4FB(df+}n@y%P-bwBC54(>1V9*O=!ev$NCU&a^5n7B{I*mVZ?_R-HN zUqAZM{fqxi-Ca%)&swv3Az1Z|9-`DUEymy@>p48>dHL(mF!&);v>iJ8$0?AIqU8}gJ0)I zUtA|Y8Smd3I}hSxPo38h4(DO{BFQ;FTkbrl=LS(XAO#ik9@WZzx4U2@OXX3{uvsRAO7u+aj)d&9lPgfd|Fcv zJO|4!{;?w-=V|J~nDZ6aC+5NP>vG9|6@BJDNAHu;cRYA{k;dl9`Q!iQF8O26dTV>| z?Ad|mRe>k@c{JAh0oj3{xT{Iu^2!#DTxHf+wF}!N+}o`wu&=f1Mw8 z6S6aZ9REV}k@0f;UlE`F-n}EQ6T=SP){5iQVRglE-Q0il=QS~J+y~^lQ}F6_ANg8= z!}TH0mnoj}1m9|r7xDkDxTT@Q8(uE z+oebT;B_CNw}t#JZOYw8@VrIf@p?f1-|T`P{&rV9^KJk1&(89n`K~Q{_ep$U_@tVT9KznfF?|dDno{V!{d|lvnT>P&p zebg}p{8|pa69PB>4^=$(wflj-ew+MyK7nh;(8KO{ja|RQ^Wm7E$a((c ze2jy9whJ6yr@^;RhyA`$H^)Kmd6Bo*b8z1&xp_8!=)YfhQ_eboocZUx>1)pa&YL>z zD!Z)zWnt&Kli%Zk+v^^9eja#?pZa_x^s^s+rhFaW_`wAZ<2PT(Hw!=3$3N#O_l5Z( zU)EjAoi}w{Npam@=wBOoljmz>=Q=uX?EW0_eZGdT7f0U6c}`?|^8@~0ho1WboLpZz zU;2;duwLIdA8ZhPVg5LO@qE$znQ#2n11I`#h5>ohSM5 zoX`1F=eGpU&KrDp#ropDTO>L6dA7Hl`Q9(Nd2*jn{}0N)@eOFaMZ_bIJ5*X$A*!I zAN41GqykTwLtPcqYjp1Pu*A3V2m z-nmYm5Pj-Av7Zck=R=+ssb0&p_qxCy`Nbgz2hZ!_k>ftE!N)Hm5Bnv*ZDj8}sM8g) zH(u8h`_BeH*uPqKtlz|8{I9UHe|X}&Zobps=8`)u_1s!x;@BU1#z!lT``Lcs={U*V zr}j(U^ojK||2NCO<@Q6r*TTQ+NZb`APki8k?~S5A9T)k_A};m(nBut(jDM~1h1%oC z{Y#wF!ykCRv>YDJi+tTL*x%Zb*9p>d+{D)}J?CRyz|Hq$o$C(vm&Uqdyyy=EUU2Z- z+V zh>Doc+5Av{6zYTwI@HH$KZi^Vf@BJ-h6LrefuY` zLHT#x!O3-{d2+uJZ+qF>FF58Yj(NxK)iE|s`tRYuiNBTPe}VSk&iQp_7ktD$NcQFn z-uIWD<2n!cTNrxwOMW~z>dYHlCx$+L7DV2z7d-IZz*6nOoAMjw*SP3IzK8Mr$KMxY z%=xdc3vT>xCq3gskNbA#$M~5sc0Y2Q=Z&^Q&igX-BRH*xf6mv|ga1Q9FL^pJ`qjKH zl0N4H*WGe>y+rb5+B@D@_(ks{I!=6!yAPR{`^UQH>w<-nzaiEc_{5HUUKIS`_muGK z{zUH;fyeWcc<%|n#(_TX4H&=ukQeupopSnc2i3tm;O9$S>Vp02C3j!AUy$>jpXJo& zqTtJQpsqYucfXkj?D-zm^VIc4J}&*m+QXOS_~HJnQ@&90-2#W>!V}l|_HQ}Z<>MjPhyY1#Cw|JENjZK zdt=xouSbdp^W*gZ|C>qQbwvK|<=7w9VgH@ba~{-XG_D81xv+zm`(@|)ah&&`oTvL7 zzWKh$eZ=|yt`58Jb&Pork@Ft>kjEp@Ctio)@0qe&+SGHOQ>Ps|&JVojVZN)24i$_B@BPKjS5DzBhBv)E7VImw11Oy3u#Plb!Xj<2knd zxZdFBJtFgDf9O3&_KUSA4*J`4;CP+vd>w&*^sqZoa@*M-an9}N=RM^QIda)v^1DoS?hmd{ zoDcJnK64!R3vu{9&2fw$J)Rq9AG|>Jeh)JF;Cm|LN%>PDcV6@5wA$bXeE0bPzr06d{N@Fm@=@iV_<29m_~{eA z2Q&WE<2gargZ!vJ*KdvozUNEN>w@v(Z*}Q8kMw(W^sVzo{~-A>F84X{PF5Z5$LEcC zlAkI6uDkQb&%Ee|l{5x7_2B$tKg2yOaA)0LBERk%`^A2f$j5T(#reyAku&eTo;VNe z?~T0SX^C)J&pa%YoORLsBIkWI?8yTj*O7nM%X~1VUdG`%;GgF*<`sNbggtsc4jdeR zkB+-9!9NgV@bLE}uJ?e(;3EF%kso%~hhOaG$}VwJZ^toSj&nU`yx8%cr{gg`J?z|9 z)ZuW&bN;Rm{&-%Qee!7F0_R&}jNPG%Z@yVic&=fb@XdL^cyhhry(-5ipPz(Z_W^!Z zjq_9X9oIdsyLqL4rv$I$dqDU#Z}iQg4&JB>^JgIZdfmY9!_qe|@VdG5oVWeL-x`q@ zdE5~3;cHvj&DWlI_~CnS^MfDmx4n-0ya^9$$&cq5cGwe-_0RQB9u@`lkVSdaLACgUN8=hrB1_62hI;`?UT z+i~HEzjL)8_YJ)8y`}wFj-S^Ce)EESU&URfJ@KrMAHH|8oPPeba3Dv|{4wTw(l|WN z$cOJmjDvVrOV9Iz@jJtx@xa?T!JG3%eqP|We4*q!hrauR__xcy>t*@nlJh*)a<7Z{ z*C*BQGtq7S*QTSa`NKh`<;$B*%GU3yyJBQDPq zt!I82f1pbr!7uM&n|Jes-_Hfkl;0h4)*0$zoVo67+JOt)`2B9!4`{qqzzcrfPwm8y9q%C;zx$6k zn+K1``TH8Lzu-ATW3L;G*V7m~@?^~4H~2Vx!ShGgmv!a68v8hSFPFaYdA@@0*&TV| zAAawWy>Snqr?KNZ5BltVF%Q5+y&Tti%$IvY&+9FC2jzFE_QbXRB8_`veY2c+d|&Ol zBj^0;yoiq|vP9NFcxZrV@z?*%?^BCvn zbpd=lkFh-cd@uTxzWTlF!3TcEpU~KKrY<~}asI^R`H9EL2iNoNYv+R>uE))j`9RKl z=*zTsT;AJGzguc-9JT}JYEd8JZxwpP<^Ia+tnp#@66u+bTtAjb-+Wsi`^A#G4=kq+ zOS{P7`+WJgAM<^=z2U> zc3j^RhvVkY_vQ9Wp87m1e{jD_{#|G5&zF3!&?ld*BChL){XsE5$nRaD2OgfU8jpDa z&)0&N?0?>;H^10(pXv9ijSqWau6T8SGT(O%zu13F_U=RU)((7(|15p?7h~@4+^6Jo zW$3$pZl?JeLNp%zJ_2xL&pg?{{N8a(r;E z)kU9pn?yWt(Ld;49Q;{Me{C*5t_yKD(AYfrz72co+HIb3-zD_~sVnyz=EM2J z)8|9(enFpg)3_WL{pUm-tPhV|N4w6JgY*6JV;()9@biM;ojfO_e;g0L2M53A4LSEG z#%I6Gv&$o&)W25vjKlRK&##1kl67gcKk&e zb6?>2=$#XG8JF{&c_c1l)^YpeeE_d7KHn1m| z-d>91zP6mWTZ9~b|D>_=cK?ItAAu+HyCV9W_|%U&k{|xw5xBj6f#@SHC_*A+dUFWPQpjcJ_{|8Cm{JSpLU!eS~ z559is!4LkdhdtMq<^}mF8oPdsKdQ0q;N$4PYd^$eU2>hkx1Gk$-~2M(L2}2-b!vxL zr<@-=uM>QkNBroV)fSJukn{IA#z9e|*>vXv}?)`xm^%$L~Tof9!akVm;=`Gvvqh z^t>UjTRZTd5%KWD_haUVd9X+Hh4Wb?`JaM+=R>?*Wbb)t-r#4Ij`Q0k(xX1)W50~w z8GflF-zPf{_t$*McNA}~kLN46=}*s-)PHH{gO~Sr@k4xgrQXIvT%NN!F2~;*{2DLu z-W~DKW1icO^?1(n`N+$B5&w4SFV)^Sct6bjgx+Bt{(c($Wt%)9**C)cx&2QKq8UwZ0|wtUDFd|!|J=_l^n%#-6{f0p#jGx_Z) zz00-7k8z`SK*UM=y(3T05904Ax$`6+{K3;qjPxeqfB#|GqK* zQodiv$$PMaN4|&3{J0J<&&lhjBVTwqJoKDDd7dsk=V=_QtD6T7^m$L;c`@d?$$jL$ zr9S_PxbXHN#q~Pqdf@+05f?q4PdJa%Ya^>>Z<<^z4cAGROsb6@p% z__sd&a+myj-Ev%T|4V*ce|X?MP4fWH;DaZggLlS*pUWa1_IrmtW6sm|$JqYXi9Utz zKT6N@7Cq*l?dVJF;QjfM8^?gg-2b>9;N^QBU+**ik@Q_>>hkRvn;(wf)`e&A@SM=| z(fGl&paVbmafyE)jg8;+p$=zBzEpeiaeVaXAJ@V9^v@@PUtfokFZ0B>&>xBT_~rSA z@z@`6xNmSA_*q|mjMI4F{J{^;*^G0bsZYGO#d>63 z(f>ur?Vq^skRRhlpZ8vzpY7q7>wM#I-q=6d1vfk%6+AkB{Cp;OvYdW@yW)1%4}Jb_ z%yEqyd|arw&#njS zInU3HCwV+u@vUdSoJYUd!6Vl>-~gxf7+(;2p1;Vs|22Q)wMh2h!k)f)M8`eGj`wDoG0i&h?+`?!Ka*uaMmHf%W{Q^5^r7>j;nk4xXG3eRWaHGkD)x{@ib= zcSYEP=RJWZ=lQMC|L9#EddAItKF`(MSH?+xuZw=Qd_MZoN0x(==WxzH`MW`J%^&)E zY3zPt{k$T0XUzSY^TrQo!6SYT54_g9T=I_wf0@V2<=6G0Zd{L>5Bizw0O!xV;r!|` z$M=msblu^f?=9_@JnjrT^mrc&UdV$n>!i=)t~;QU0` z86SRm-fo_Ky@3DKCGVUs^wD(moBhD^_d^aZe6R01XWp-qU-Oatog@8avZJ1^2mQHX zhu`hH_yyOi<=6Ace&LhnhnByE#Bf){wbR%6FWJeNis$0tv|e=|<%_)h8hJecvHiadQD13&kX zu6OFQ&RU-F4v2W>kv`zLm-!}->mm>L7rZPEd-oCi@OPTd0~{wv54rndQHf{$b zc&=!B@Pd>0a2jaZp{y@(6DfS2MLzIW@%?CX4-o5+9^BenjDxUMk z?-BCn>rLm)JmdZH#o8wy^a+B9Mpxs^T2<`gTEf>83*}t z9C^Rw=7Bu+l0EZ;hWV$bibnIG4Y`1{8B-+Z9Y{i5S}Jwkui@b9?D-xqTH z^W4>alYMxR_%ROTe~Ws$?$nq2d-I6=^%}cwjCrna{>YQ-D8~cWdWugT@Pq!2I(}ty zOrAV1@)#Vv|7d;Z%lM_s>C0j1XCA}>_wA9N^8qLGJ@xs!SI6hQ(Psw_UU#tjs`Ok3 z%gJ-osONyj#0T&Ez?=2|i~KBY>bd^Je`(0!ZAFdEqvNs8eL!;eC44cq9^=)-?*i?? z!I<|I%qL^y@w=?UFZdTq{`JsLe!mg*NcpnR_k1C*Bc$IM7u>vG&?#^57XH1yfpb#& z&ddJMf41cA8{@(Mtd>j&%lmCd;B2l5+}oq2QrkjLTCKj6Al zdbZDgTo5?HGcV$xx0c4lfd}~LmwvZ?WqmRZ=LHY^y_D+>jz^{EIwcOix3V7n!usYo z`1^5;ohP_-tg^%Zr=;gR%@cg^yvX>7%X@Xs%Xrb_y3#moPaSTIILP7MdAN@`f3K@N zJRdT@g^{285PrzZxGV<;^U65z^WBICUjF{U@r|GOBhqvKTE0l~ouqGE&dd54A3gUS zJagaYILZ6w>SMK3w@62DmKeQk7Ltf+2|LD;l_Mdq9zR~$3=lyAaA88!;J2UEH z{>Y2xCCc)F~j^{k62j^eMBX6Fsc|K-d z7e*gAFYG@n`=#206Mep)avbLOr!}@e`ha!U$Gu*{{|jUNX5IZ-haKyt?Tl-l^nMxn z`}{+^n)J=L>)GJ3OFfZaB!0~U{M@Or`_=h^llQ!w?|{ax&zXVW{NnfG@Js&mjq_k^ zd!B0RE)<_|o4pXxk)-fdiW1TN24@|*~{<5P!aQAhhn|NDyDnMXrE@LHcd z?pEHer~NNN9{$}w@Uc>d{HDkgKCcWOn0N3&oQorG$Dw~$54^7X<&ys`aN*~L@=JXD zxxdJZ@Bf`I@wvV=@2)TFP z$=*CTZ|0jgsp7e=^v7VtgSWQ^Z=5IK54}|+cOJ-jPRKmsICkvmlj|fm9>)Ra+7XAm z4h@{{7wq|6boYhvBOj35eu>ZhnDewfeD4(N2lHUnh+{s{d!}$M(;nRJOXBdJll#x> z1^FKo^`#HruQB@AgOmGd>dM%Bur8b)adKWB9CdSFgKPiD)A{o~_Lrh>$)CSp$apo4 zeO>4H_~SXN^Rz$g4i;Y5)A6a(rO_wIdGFP6Hr1H?$a^U8J05+;dxEYLea-vAu9Ni` z^Zu>%((g{;m-yVjkq2>&pLP9-s55e&6D(-zISzRE{>Ak~&hsMkMBh+1*D-OtJk|rp zrJmPB9UKSyW98Rwa1!d0+IS>jDqlmssDpS=Y{4j(^@yGH&yRAFj)t zXX4@a?cA^C5BzIJU%Jo0zmN3o-+u7V??BkU^Fq$`fb|D7?$sDMe8D%@DPC7>2cN%{ zKlA7K*qz#?&(U8!^v%;k$&Ziv+7Ef~J2uAaJXoh*9Q@^Z=Z(P!`iDu+{e<7^m6!V< zAMdshYy}Vn_uK_4jhd6-rd)~j*lJhBiJAE z4Wka=XWevu9DkdRgO9qIFZ_Hk>W<#WBc6G{P8h1Z(Br)Z<8nUq^_QcLmUA6@Q_MTh zkINl;*|u@UVK|_j-W*vB;k~^4^en zOP*dJx&5a6h0Bq1U2OdU`2`<-ha(>Neja`3J|Zuk|GJNy-y+HRey>xG{u`CI`v`sB zmvDVD-(N<2uXot*vK+rX^5b~!2kNn>D?~FPdH+313zUQmu#Jwcs^u-|> zdmfr+=G!a8hx5yL{N0V^)`#ayB2V=Bdm8&q`%eX4&U3sc;&}qjg~H?eF!M zGkJ2qXufX`vmh!-EWS1Sr2>OC%2yS!TyTqE9Cr6 zfa?u!yr*K`YzMy&1`jFcxrK4MPWXF5`mP83a3AV>QwIp*7OspA@wn_tI8pWhjBe%1%ieqHb*-#_rUPVh%x7*G1) z`$_j%#yu|brLI>ApZk=2KBaMIoj6W^JC4^q^7uj2iTeFrW6!U=?mH~{)VwW}{)vib z9M(PQHwzrRL4=<&M(uCwa~jvXa;J;ArLaM_=6QMaMMMO}6X zf4N=~$M~Hu_I%&y`WfeZ$sd-!>jy5L6C*b-t7~k&>HpyvdtHUMotHB|dGFJ4>F?D7 zclP@sVLu=}o|n5%(0fkUIbY)PJ(&GFFXX$)zO%mM@ww2aE_-X7`H?sFyGm{z!DIO< z!528T3_dtNp$FfqO73~)^#{Mq-^9gw=lHHSdPggc0pC1vCNCwAO7nK$CFZnz%!`DgesAK^@%So zD_(P+e?H~|`m9^V@4PRRocDcP7vzf-A2~c)4?FJHt(WyXCi>s&6Lncx`sQWRra$ES zN8Z$zel#xlA})HYtG3U4H;8JMH!k?%dj{9j^AbJ&4#9m({A(58eUx?Wjrisj zUiiBz^8i28n|R;`=b*-}zxiWcoYBEwpT_vZF7xI&oAb7uI`jRw*CTMF=eUgdeIoPV zJT8}<|A)wUlb->_cO2|*3Ow{Xyx1T7t*Lm%>%N4y*GFHH?|kW9sXck&=k5+ahwHfc zaa|enzO3tI-iXJ2r*S$idG8!?o$ox!dG2jLmNx=V^I^r{ItJgWLUNJ@AbQpZQNY>#Oqw-p5c8Z9fuF|%kO?IXpV`8 zoOpM1@y~quN=N?I=kEi|i~WF;_1k%Z=hGTzeC*NVy+z};J$1Yz>f`kS{SQW-Dd+yx zeS)8VMnAw4=LOe;d2pTlxL?q}S!3gK-LI7V{#YN)H@rR>^8-?xU&&|!h_3?jc_@SSW&}YV{$%>xqkIHaq)hP`^SAjJl<>ayl0&`DeMO{CNA|k zDe$L!UdZj2ymjR-_%}}aWexdrz4H99hx}Qd{qR-ET_5;4HR2?% ze2-u}*&p|Xocy^CGfw#2N@H+=)BYA|yjIv*PTrr5{?0fblD_Mj__;4IF7OivKSxUL zx*%UkW8!k$dho|}yY)PusQ;2!|H$jDitoNKUhJ<4yp|LH&C!RBw@~tZ70-E4H{P4J z-_+Yfdalc+(gQd6dm^6oS;u)#zKaKdTPdv9zygaYN z&iNdrv3WorIr`@X4*Z@cJ?BY1=^y)XU9jW#YTYO3@jFxI!+O;FLvda(FUa?j-_AZj zJ`>j`p8xpeeM0w{{m`#`Z*0D@|Nhg#*F(Wq@~Rk>eaiX1$oWy9$7F9_=!aW7#_$PG zcX!|)2|nF_;O73{`MVzYe_zzYeF5K3gkR)W$R0Uytp|U{hCTcp(Gj2Li0%XP2*0e4 z&X@eSj>8}R(c3`BJvL9o-zxGj|LC17`O>h*59|9OlDm%X6L@=72j6_3W4;{^zaI!a z+asqx%qRFipt12XCO&xI5@ga9V;&+S4Bl-W4^s^s1Uy~p4!Euc2z`+>4 zc%FHAbDVx22tV+@Kzg<_Zu}k)*Gcrz8|B~gF#WM!8khON{&QXUK>lpWt?zjF}-2H<7(ZcP1NuD{Mx?jM-{eb;5=I^AOuk#|_Ws#TT zApc_E_xX@|zB0}m#OM13ujkmGpt#P%{uW6Nf9A>a7<}g|j&Y~_f{;5;@N->ief;zH zh|bIL;Q6koxBH)YwO-@}&-54e9}Bu}fa)A0BzxKRiAa{_ThQuN`vq`8$oQ6a3DX z{NTXBbK6?v?>OikCi|t@V+Y^g3_a%s5Ay=2=NE-0@631Am{-K*e)m%C@t1n5g&uOAuV2~J zb6=7t??rgMz~3|F4>|tK6LyyePv~)<;q#UKkw5Q6X8ybvK|c8N{K5Zg6xY1l9y!-z z#$&l^x8hUb*SLsBUXBMY{=UHTl`-!lxK8GmIJX7A=$#@v^J5;-<9^L}vYsE6+yBkjy_EBPq2;-b^L$}(Q{VB)i|_f3m-)P5%v1dG{L1S(dGh_D`^NRA zjy%_Ne%P%jJ@aE87D>+aV(OWnmx*`tVBGknAFc2Df&2eLj{a{nMvg!80nUpeKld4a zxjrx-)(77L>AQc78-1R0Sl{x6l5?MDe3pabV9D(V-uS%`^G3gMe~e%7GUmCi$L1A3 zT+f*o_bJyC-|d(u_`(1F5!ZR~9G>^0jK}A>3nhQQ{2RaJ#63OYS`I$mqcIQed-RVF zxp7`8`PP!Vzg$=J`5wk`Gv5tEjvsKC7x>*x`Qev*8S^~bWA`=s{9Q?>f8;#hx7>J; zzj8VHUy|JUf|KVWj+eN3KGDf9es>KX-GAiIeWmp+rykD@Ir(p{F>>-{ezM2@S)u3p zApfxB#yg-f-|ryDj(%W1*kAT5>x26&>-u@gvwr{9*!g>XWPR8w`VrimU%YNOKlpf8 ztfTG|@EsNFE_Hmp>=Wn9qRz&LzgGy4^UZv&m%i&v{KKPgTJ^F_4L%lw-uCZ=k-aMZ9@m$n?vi@%g9>Mp{ zsEhMsJyUL#ef9}|A7#Im!}n&@K63ck96HUzvvgf2eLi+^Ze0# z(g*aj`wG5(uCe)Y-U}t?d4O?)gTD*29e!4cF@9E79QQxtXLXFv54`x}`GWloXuO)n zwl`1UU|lv2c&f>d?cCSk|4ZPtJ$YX*zwTT3;C|2ff|EWWF4uGB&GU#jt4rT~=X!&S zb=dl@6Mpv!yx=3A^CC~jTTyz( z4}G2+;Rjwl55WHq#dF=v5A$?K>37P(-7C5MqJOl;UN^Fz$Rq2* z{L8_Y@z5{4zvTW#{-o^PpPBc8k+qS#%=zHzp3QLX?^;3KgF{g|6FHMPx!MQ zeDWN`JZ;)M4o|zrJVPIQzxU0$#Pd?)w?BAiUCTU)hn)8lEjM4pIU?$x@;8Ls{E+WX zifg=ie&Bmj`!_%M=ep2xtP(&UnbnHtL5nP{ek7~ckU18N1lmq*TBd6$Mav;+kK7SN4xL=ZtlN~55MqZ zd*h<7SU=4J>&(A|2fyTr-5K)lzHmLM|DU6;Szowcu%7EhT{zEsy~X}pp$8BD(Q)*M zhuw`4ADrNJpBguDJ}y1;iQVJj*ZffDdc=pvpGG|Vy+ZyS-*)iA^EC5<9r%#5&O0vl zyk}s0#xIXCyzu)NoxFqp6M>(&ivyQ&pvUhh7>D}<`LCicEhpdIqHmC&8N8T3)}I04 za(_4;{d;Z1%lXK4vvF8XpTMu>#)15l=nvwu-gnlK`>u0?2loy3Uy$5=gPijl`B4Yr zbKZgn#?Fhrd{_9(y!e0BT(^{O5%TQs$2#y62mjn(nK$PPE}ma6YvRfIeU|W;ALmEE ztShQkQ0Z#*(vJg^LK-nsC)J$*N2Xi_1R5w=WV?3_Sf*^ycbGdi*?#@>5I3= zI!Yc(Ucx3)BZR!tb-xqOQXY?M7yx?IQ z#lf%n+d24iJ;;xB!hBfHeArI@j61L2IM2BciSv;3TqoyEAKxGSVxGadddGYOA99|< zyI-sijx|H>eBt>h$<2%7;)mbIu^;mY4{wh;g8MA#b*@9i+gS3&+Jnn};pvBg8yw6( z*PDKQp2p_eJaYWf;L-lL9=o~2-`!#F{NeQn(Py5g_jd%08{Q^0c7DbU4?har#I1!Ld2?Um{w5xOKjiCA$7jBAf1bGhEkC(_JsjiA z`;F1B=7~HHihgt5!O!=Ut|##?R9x4|^BKL{WBwuMIg0bgj`M@x8_GO*56<(%Jdy8R z!BfT=kYCRq`(YjBJ%q&hWsS3*pNg^bENe$)>hdCr*rxUNWkchzy%2_6^4`e0nl zhu6u@eUf}%C4KvM-tfixWV!W;`?uiN^`vh5L_G&IHvSVs-}7mnj-M?5=9_tpK7IQO z$=y%r^ZkwM3}2jIGJl@KWd8U8|Itx5{O=cb&J!HG7iS#iYoX-dmcIF-?z;r9&YL(N zk)C-1-?+xcYu*_5L|@w;-iYIT9Us4(U)*2d<@*88KX~Cj&3R*YOdL1=_~G}Pjn8?5 z=b^ykK18lwsq!`+;;3?H~2!2Ja~T(zCC8YNaHI5kMqSp*OTTm*9pE?Sk~bOoZE*!ex9wd zaSk-~8J`urpf@T#)=Tg>4)x^vva=4@^Zd@Z%s2Yf)BQ|+o~^O-b-mDAv!h-c1fTQ` ze~0P$!})I8zz1I5N3mb<@?OCL?ZNMQ!UxZ57dPeDttP!on`7*5inu;+6ZhAWI}hr} z_v}7jkq3WwXMCO)@UltF3+D^Y9|qs%7rw6uevwZouIm6^@?o7I5BHz#@w=Yn&d>3{ zv9QZJh2Nir+(S6~5gx_s~-xq0oP~h~uBhL$!zw2x{{PFiY z_Upca_bo%8JXmkdmw9D;WyH6f{MU>;$dBisuBYQP>|=h|p8j}1_MN!qOJ2y2c}pJT zv(mSmKIXahGVR^37e_t(JO90FJ=-I{t)p-71CFl+4(iJH$?iwj6FvID{b=6t z%lB8#&-%!J8NB&=0r_C87x2V&rTdKWw_|L7@OxhPLq4Ur_%k2u8DA>=#zX$AN)P+g zBOdpW=E?P1B>Crqf6sIHKU4bVKlxq|`Ir~_;~M4RKD8hEf%CiLBIo(O@wuO{=RIKC zCl8#D+&9?o5%$i5{JtZ5*UNJJ{;o@(bKSHgR{>h8$GW+p*gZ%8k55HVzns0c$S!4GR{jypd zcU+D?68@2&EqtCQ0~&)9y;XyE^#(zTk@Pj?|R9|~UV zmwdS2b^myM!0$&SHxBUaqjB;~9L7J2Jivi`zV^&}Wdv?V8g2v8+`f;7<`q_^0 zJ7XR)kBPHLd+s|DCvw)?rNO(`8S3;r`L{kiY$$ur7vf_N{sX0F{FZ~GH~hNq;o(1l z&vN+vN%(P{!OiNOEH|vq#BhiOh=L^Fxa_SCl z{DJqc8k=|f<9>_ZozDLFqV&4O<$W0Q<$S=yb4TYxJ$b*^_O35{qv!lG{wFUuM`gBP5q5S-}R(E^tIP3c;Puu^1|~m^Wway-}SO{{fNVRH$GoEF5|xkf0kpv zQsBvXcenhSFV~NC;p~tr_eR~o&+mabU*`p%&kNp+n|ZM)aGN*u>391<|JA{R?csqw zbe)Y4zV4H~d9+{jna}pidd>GvzK?*)^&0`?D+dg z+o1=a)-%uW_L$_x>$)tI{HMVua=zDuALfbkCC=|<@4Uv`U~ zI_hG-O3n$91Bg%rAa+4<6HxdZDUM=6i_puz%yB zUw;z$ru-G*2YxpXei{EvcDC9UMg8E1=bqNX&(Fh;`x;zace}6gzm4?h zOXB4`L~cF&aUAdtV9(Y_2`uf(;w;cVkz&W5X`GfQ1=nw0&eouxz>#6m5AHw+IXM5pxpJ$%m zmE8HK->LBHe6as_@a6pB{TZQ8-8WKv)-&+Xx2!9!xBXF9o}YBahlgV%U)r<>|KBvW zz55FPymx0k;`A$y-}04c@Mm+<448ariwA=WCqsGaWdo`?=xQ>mhMIDS79* zK|Z{XXgU0He?UC)WX$=%xScm~*XW3gK5_m}a^rM-^5TBgalpxY74BE`?us}Z=Q@%& z#=#z5UlM%^KW7PlCy(T{d&I;4=OUi@L+|^6*Y!g_6#eM=M10yiR-mx+LKTE83|tTv#<1siyl0& zhbPX9i<@Kn1;=Y5|CH|=a_T!@WA~B$&X@e4@auVs9?xl9@8siSvF_oQ=b^q%vpu-D ze{@~Ib++>Cj1Mn7hp?V;VgG2yJb(w}r$&6o!|!eK<9fp{ec^cSZ{pn_d}lnqS8{#K zBl_Ry(vQf$9&-3uH+XPA&zF21;c!2=Psrm~>1W<&NDsf{N&Ia!rtXZnzh}=n_4)Qj;H(v0KM?D>nyk6C@uB@&+eLg16OErcU zaGNLW?hZTikKVSC7d-Qv-E~bpe(&1(xG(U_b%SxIobR1G>%#oye$?}9Kx1&?XRhK} zZeGymd8_@e+|&mj^6W?aP^YcC^dEKQ{9*s_#JcVJyFS!`?=zNZk3Yu)2j_Fg^L%aK zi*?QGH2D4_9M)&Pd`|Z6KX~H)#XLJ-c=(m%&e!~dli!Ol|6bp)=RVMRC7*r4r}c^d zRP>+y6PM@9j+^;C8u_`O=$qT4pRCV$fxkm@-tJp?T0Q28`vm-~+s1D{izNS2#HBvG zmj_Pj;(Cz}JQ_DV4#;2U`cD0i3!a=e_TLO#$rJC3n{WJ{EPL0>d4Xq-E5`EqvD(eoJnuS6eYT+Szs>wW_N?!hnk`To}HjP>w)L-2^aCO!HTei-w9 zsK@v@U1QhDypSi)Wz4T}6OZ$U^Gw_)1a9iLtMuGY<_~Hsc+ty2hWXo$nOnY_Icd?$h$}K&iI_C_`43{ zcO2rYhE?5NC*)lBT0gIMjtoEMhkoF9=d5r4)MfARZ$99S>mc_VevVW;_pkk;x4!&Z z&;3Tc=S6+<{IH(*v_5^r`+b*cPd(^&-v4vm=;z%!ctwx=Pm27F8-8z>zWH^Y=<~Z9 zp5H0oEb@1q(EoGfW&iNCUGS2Aejf8XIk8IX`u)Gw-2v#zUX)1B}l&kZ%$G zJuk`UO8K`R%fZLZ#w&vx+EBR|g9e9<5KgRX{;K%cfI=r=Gp3z_C z)p6mAzvD7a&OZlA&;8B#pcvDa&r*D!e>e_4^uHoKd}9kzwF=j1TVi+ zY+m4Tf7!Y2?px|GDY@}t&-FWeg9Ci{cON)UaBLHK+8=h);m_+LapB+jB>$_1+C=58ANM)(7epLz z@pn9qV|)6Z=hsWMC!X_Sef(aRyyi=ONYtHpTLk}Hr`{`no=@qo9{!MXe+SNtiyikv z?n7|%cOmeJo^dlCj=b@ApX?kL9?>Tcz8`gdt^+(gDE+K2@7=hMa(?mr(s-N?{qc*4 zZ@j<>0sna#n?L8tnCs2O+LMpvM&#SujRsaz&p@q=t)LV)yd z{-piWo!@&8cicPf`2M)#et3xI-RoIpuDRCUPkrBG{~~`;;gT2fotu2E1M{^Y^@4oCi`iGC%2^5%Cb#7n$G<=1tV z_`FZ5UiZhUl0&?rdJeN22^Zup#o3|$w zZuO&2BToE%weZL*`di~)JgxteS@hwR`@iy{ANr5`xXR@b|E$ODpZ6~86YI$O z{OYQg`Jfl~&k1xHh-?rji zzNVu8(3(%wiSupqG#~W%-86BveYiLwZ@jmwI<%j67cb=Tw2IgE_bmIa%M-(!IFMf> z_VP%aH&mV2f0yFPy3r5(KEY(ak>~wNE^heXzMcIrFJpM8Pp%bSl*KkT0uJ@cQJvHV@T_%mPXz;o4^p=V!!=LXdm;NKg+_AC9% z`X}z57gv;>btTSwsvlc^#w>huy#HwNeNx7p?;5Z9{6^IuzpRJEBX8rt6Zbcc4mrHu zHG0l#{c+y#>hg#DTG8`-LOpO?r}D8M;QecrzkE{nn^d2`-{*sez6NKXHz2qFtUr2B zDmnGPdvJNZWPI{oQ@mLh?B84Qk^g4J*PguSbK`fPt&M)qpT&t!zTgKZ$A8DzU6rx@ zVjaNu-r~*t;O8gl1O0m(!=Cw}T%6>ywd$lF;?I;mIPOtA+4tD9ejUjc{N@RcQ=)JF z#>4MlR9(?uGLbKSu;+P~_Qpg0^orMYxvKmUkLxS@y!(RruV46lW5$#GGUoVcUg+_B z-u0yG@V=@;=gIN%XzV6=!H@Cf8=Q|ST+R>j{btoiepwfv6MgI1JiM!TaNeMQ^^*7f zk$olC5B>Fql8XnN$>{|Cc^Q)*^7Bd`9ynj4KiJ>(e<)SgZEtYC;#AITk}^r>lfE?>Nl?E#*g*xd_P$EijTaO1&_xI`+@%E{-g5dlly$i z)yEHi??61t$!qg0a`eAb{nq+hCiJhKc=o^fu>QWL@*)2R{t~^{mY)2<>-~$@=7akX zGucnQ#RVT{7haA(>?aSd|Ky|nKcM`#{MLo1>wlZzmp}1vJmNU({7_C`os+ogxBqn& z7k%zq`P{MXxt`Z=+dr`Ed%Yo^yx4E#zdiNR59`3)q6cpHQICGVZ|NBiUjMS@n|+4< z?W1oW!xQhTJ8!h7j;9x1*P*?U|3~uEzU4f>RW3gKoH`3W;{9s*r~i0P4sP;mem)wx z^?+yY^Q$NC@I`;M9`kQDJ+7D4BVXRfGk$K*j?8S@;)`Y}&AFHz5Vhu<%j9`WCkvGEu`zsBUl{c`&S9(WIBDqHfgkJSX@J{g;X>{^`hh&LJN3dC$%|xUPcl{nan>g5Q6vJe9M4yr%LY z-g~P~>cP{8%6?wP&q^NFTmJDoQ@knXc<}qhPs?9c za(To5rHMO}t^B__b+VsWSGL5?apULj8p=2R-WET^#}8xfCu+x-`y2K%<8PK<*LUi8 zjp7S_d5)l8<1y3YKpm#WO^*ryR-q(#>UWv0WW9J{dvCbP;9@)?N zk^Zbd{`ei6_D6jB_)W!=`s8`-S^5zC_l&-N$m8u3IGES++I_vI?C76YmY%%f|NP=j zKFISH#fSRf|EsEpyyA!JYu6F^LB6y2QJ;M2OL=2{o?H0lWei`~9}K?EpZlHWD_+JQ zh`s#>uTRO?`l&}=H;-I@o!73ai#!sa?|Z1P9KU~1_3C`_V_vQQtkPG`ykA@X#YzA0 zzMOuL-#2+XUl{*Q@}M5@#CU7Q@*>aR=X(SCabDs7-c?8Ozz_Gy%@@C{2gr%jeEoIV zqsM;y@|@dv=r2!vaA?0`VvL;qtamd*uW{bK;)oOeUs-&bFZU-oZ#6!+R%a|P;9;F| z?EK?+bd$ z+GHKj3#Jn|u>Vr*z(@S%<+9=hy*HH}<1g3P;|)CVywrT?^Y=!t`;Iv19gSQ)@bX@U z$1nK-FY=je8y|Akf9pp4S7mIS$b;*X!)5oa+HYOJ^*0lI{(1D|qxbA8cx$p`&EE5GWK$A49Sxn3NM{6o<{nl1L7$ET~m>oeG*DrPAysG8gU+y|xk?|zo?7v#YHGk@LQt@bf z^zT>m$oS;7x#G7zzxUsDIxX=U7yNR5^W(zdJg2XCU*9^w_qL3|XI*YsW9z~GaDSo? z!1?sbU*5re-`LwP=#htYv+m&g>MV7Kcb@B*-@J^$h2I-g-tt7=4~u{EQI7u0BDenL zf&bIXKk`>+j6QL!7j@-*O7nofOD6PQkg>ecpWmQIlHvz^zgzyr2QN=e9C_3)^-PUU z$g#g?<&Qnjhs0xDz_YUA>JRz73uo8iMZsl!{IEXCyZtm3`E3i2_~GksgDY51@WS^Q z$sZoHC(kz~j(Ws@VeE`Uf4{Ws#62B3_XF&c?lX=%v-Kt4TUD+f^8ZQUH-F^MoWRfe z?Q^$|$MIkM^I}JSJP z&A54jzp2RISm#~X(LdTTW_@!^9d1-OdVN2m<|XmYj-Kne`7(Z2mpk0bvr|KSAQsme<|{C&E30SE6-8n^jp9*NgHu;+ZpdXe{=D-Pq= zXKWwok1_dMf9J`;$d6V&eO#L#KkC!p509RFV9)!j;%3Y`?U*|9{-^r(8}V3QIWYGIQHaE9Ool`IUjF5_H#aPR>jjV`q!;>P&w-t*VFn@PCieH zAL~Y*&q#jaFmJ}pQ|*-#kM~HGciyS>2|4j@7roie4_-V`Kk_5qO%u;~(CZ-cM!fXx z^lCs%FDhZ-^~;C;CBGU1bwL8>$7^~$MweHS^RN7*gB%eyl-BvJ7M?!jKzii zHzx9aXz6#}@1G@aaKF6rc0B?oyo;;-yu12_{2x~F=oi*$@lhAPPj6kUEAw+z@nc-- zy(RWD*}^;X{GJ)hN8`9v>|I~``1--b6Ss9^-mi`y<>H_(x7B>1ejklri9@@tkkSe=GXNQ=dGzE;nAwr%K+q4+O`N;a~p1 zamSL=N0__2=SrayR&rd(cG zf43H|td3HZ-Eu8rISmB~?IPO~)`614R@@Idc$9+)iVjSwi@!q;{9QjPu7V8k<)+Xjow%4_#%JIU+xo4_7A*1yYQ%wob_4!&EJEozl}@2&#AbL z`xEhFT>XQG^-3P(6F+wVqN;J*n6JP^MU@h-}FnJc&=*Q!FTiM5fA_3 zVm;&ev3Vos{s+9a9{67|p~rseOFgx3{Cf(&yy16UaLEI5>Tf*8w~IgfqwDbST924V z#=lS10r?l=UwiWT)A)xE^xNMI54(jG7k+vEE|1oo{$;(BZ~U>YUYRX% z%^$t*6<+O$%W+nF;@mM~=LP=ogWkR4SN@b!2adna7woTBIMt($&yC!AnJ4`JcJa}8 z`CS9!sShsh*BB4|+h;tie}FLM4Ki}<|XLVl;$@zp-34*c$qyq%n} zJn7zd>G@O{OwQd|78|?x|QK^p%?* zynVgo;NrMG$pi7fQ+nj}fYR$cx!)jP^1|`u#nHDP(O*{f+T2!54UL zSaHaQ_sS>l!@}>QD{k{~QsnZ{{r%(eOI^6H3a`Yq58>_6(GwT(SwHP({9P|&jxX5J zr(8Goe&mdwQ~5Q%KdSyBF8paP4(h~xPH_-tTlDNJ>x|vD;F-ymxZ1 z`V)NvkMtYso_v6d>tgHVJOe-HvEoGUX!OKMT)v;Foj9<|+&N)KAN^ze*l*Swy$?ig zAILxcxo!|2elM>$@`(M@ibvvbA5uJ=AOCB{=#h8V>tN*Oh2FoFo_WCMa}w7+K>mk? zgLv}!iP)Q_#iKMJ2$vy#u)ki3OBeOlrcP^-#DLJIH@ajHb3;-YxVmpR|2IGRA@9Gdb&>e2*Y=6_;9N!ev^tTZsgaTz|H#%jhp*F-tTc;058w)t+W1!f2Z<^WylK$rgUi zkM-c$wJyTv8w!_t@bILGe&xP)YJAL}bwlq_#pArOpFDR|J>i|}Df?BNjIW4a>!x4g z@E+A9-{kv|$emyKd1maXCwbEMym!z%Bgbz4FNwq7g)}}m-x~k=6DRg-%U=G8^VZ^D z{K&cf(69AH{>}J5k}bHjg*e?`TiE*z()hMsi-|I?x;f6CDdcPDV_7yR_4_~n6ktoJk7f}_u~;6Z); zpwIIL<@OW)?^|)fb5Z5n*ZJ$K9@lF{n!%ssTEf|%p0CF%a{2g zUtV#P6X(T|i=TWsPFqjb$;_dg>Q_=9Tl_ zIM}n!xK1_x_ZNQG32^ZqVB?}6$@jbE2i~~9*Y*B*#^|AM{TM&A`d4143-3W39s1S* z`6p5b^J+hjDL?W+zT7ubzx{Lls9Zk5%kO$k@=4yGDL&i(t;;`k;=JEF%Nu!e-%Osx zLH(Z>UX0i48t)5##IHQ#m*?m9zy9dor_a*A;QE8;8wY>4EPv)npFBBu z#BV*2e=>6GL|-v~kyB^lpO>+H*LI&NJ9y(hJUFoVy)vL(-!|8dEiuQl;we0iXcxNe^r zev}{Mz3PqpjQl;-J4?T6o*vhKV@SrPuz>EI#`@c01F= zj^X7I8B-7HjQ+a#vmY2=nX!EjzTb_$`%lW4dF8s(>oCuw%nMu`e_gMMyJ{jX@yU<) z_7TTP-j5J3_~|pp@&&%XtvZ35`z7W@pFB9@sceaZ9pB?IFY?%v{f@!Q{aDBNJ3V9b z#{O{GGv+-K_oHt<*qKlB^YiGt&i6dx{+Ig5`5u$?)h~U?eb=KS4*hYp;>meIzWjXv z{n>Zmd{)Wf6};rj_lvr2=JC^#d;UTl4u^OB!vp7k)~9iBU#Ru)OJ1+5^$2_Bk$uwk zJU4S4aXym=_ZM9s#6{lED_rsmuE!L=!~@6SY?&AGiJbn$Kl{Z2KU}994>`}f>@#q2 zpTPXv|2qn&cvv6*qWn?c^~uw?JjnaP;)!_N zUy*O@IPUAme6Twlx&6*@itnjwhaUIw>2uto+4CokSh0XO~_-!xev0mys~|nk9^1<_4!5dtv>PgSKh>B9T7ic=AUEu zU77rd0}l3ouyD&i`SScg{>4EZ|E+M75BGQVuO9YX*LfTjFL60<&@X*^>%{T6B0u!^ z4~y^id!zVmewZJV$6@09W%T47d+r|{&X&BLN8scq%GW&bb6)%$%@#cPVcy6i<2z-H9{a5e{lxlW{vC(!fhgxVws3-%@0UO9{=MQk z-@*H(@?(CiE6<8tyz&Iko2wtd$$YjR>RlN*=cn3xeotTXJy&_AA2>fmpS;bFIK1a6 zzv4pvk>Z^^9vS=Qkv?#&KgJIzf7XRO^BvF=c)Cw`U&?-Vy`?_9S0#S+@&D_Qi`?`9ce&)9Vty*Jf$ z7yLgr`O{b0vBmCNrAMDWtNO&a^zGXUw|Nq0L*iQ}?4MosjGt5Ew&QuAc_8P0qxq8m zT`Mp4pBz8L12^;JF4Yg6_e&}taxE?`{KjJ+k@f?F2Ie569w2x0N`NL~| z+K=e}ZE&gIa=u4u-;0-czZtpw&CB>&#Y5ZwapdZWgZ`UW`GTA0f%0hn@b=Nlzy1DX z0`Gqm5B4qgcdq)oP7!}=#U~!mZJmGS37)qUU-;#Ije4#RjNci1`E&h6e)W~U*KhLWQyJqlu-%lLy@Ug<7oIc(czw+q(LC*aL^P_)x zA6R_g<++mQJLJWArg-g3^gj|mN3tak)|H!;AM7}8*%#W8*E6a<;N|+)`eL^#c2n8n z*L;}w--(^^(BpV7Zs!+v_%m>$tKXUWNzxq7yvafsH`{P>okl!qN@&y0is=grKM-?9ZPY*le+ArYbd_uWAklzK7 z+Yik1cVsMI#91>-9Q?gH`1EIgP^Tx?Jfc7TzU%oLyZR13A5;9PN1bk3dg3Dw>TQ2?o!(M)LZ9nI^r>ftCwfi z5$dZQW1desHa~byF8?n+xSm6fU+TU(cKByZz4`v7ePdscC(m0Zc_rSt#jE+F|HzVy zhd7TYJnGYT|6X-K&U`wYEx24a!Tq$VFMar58Os-TtheUZ{rly}<*(&;D}OD&SINQ8 z@k75|&*xYDJJ07n+_*VT8pk>jCu_+GJm7qCJCHmoMZzA2Ba@;J9jD@^vus`$lgjTl^`=@anM)G3b#DM&+AIx`r?P@ z_(!wFKYnhW{KNs@$Qg4TDo@xyFm|3VHSc?hcl3C_fjIaPKj$0YO&s&LpNaGI!jJyd zVt@It#~l-&+2~17B|}UfTapD_{8q&kYiHk{@{BK81PdAKo~=Di~7_t1kF|Xz-J#`P?>R@fx3b^WfkTZ`Y0c zzek6?a`O0K;qAOHjJ|cFFX%V<6enYjJMthu;5w(`fp1yi#ozMm7e74xM%BkY#DCV7 z6ZtfsPb=Qg=lvIWh9B+eKhE342ku|XSUvH=+osafo<93_%>&m@aQ#c^%Ny}l6rbWn z{?_t?oaaJ~gX;y?UvTk0m}BMv&!w!txZ&@c;lX}G&UwM%Y~cYr-j^UgWA@*n{D>Pr z4=mi)7y0j1A7W3P+CP4|uI9e|(QFyd8(VlfP<5j3xW9p4^yCvB-W@*eTjF20>SR5r z6Ys0c414;D^J~{#dBNX}3%7ZK=PmJTU!%|WzFZf;_s#65?%-oT?{#$@9#mtmFVdrP z?@@WeJNCrmKC^k52l9Q@*W!jpjsw=yI#56E$H=d|;fFr4zwI~je@N?8W)Ig!h^yiy;&CnF#B19_~R=zFe9^@~04)7u~B1211JJ8)l-u{_8(dQU07 z?ThKinFr$O`SR<96Me4p@FSnx&ovG_Ua!XH%l_*ZujY%N>lSYHry{>3e(W#xQ~P6o z4^*7$!za(p<=J}T=VKLDzQD6M{?#WhzNdsfc^QxK+QO$j{loJD=SA~%e$6}j|b~}GE4u0 zm+Juik{9n+%NKFDFD+lJS37D9e;=+fI5^*zKjV}Crugmtp|t>$In9( zPe1h2yo}}7x)J~FwN9eP{g?yU!UKBTe=u)(#n1hVSNjF|8>?UJWALQ6PViyh!ryOI ze-Q7_Ci>v^!6R?*e)Ej+2R`cnzAJ)L9=Z}m@yri>-jjD8 zx8Il4`k-Izzg+WH-oVB4I{73X?>SFr3x4C1KkvcHlRUuR9gBy)UfEvt?!I|a>4T5+ zH0LRGVV=pCaabRp9l7z_-;>K<*WniNYaP0O&^KTGkRRWVoK1dF~(QKW-a?7vaC z7;}7e-f0K_yf-f&)&cp8V{aYE>ucqYG1uLb^#jK2D_PO-~|FwhPz9sIDgU`B%1HUtotEWA6SyK6s|IWn0 zFL>-{^gdL)pEUL}KX{IJWQ_HT{nv|E{eXk#eDdMEpbzd-`pwh*iVyPv57#;7qaXa= zrRITk1JCm+E`GQ!#t(TCkNXpjyFV_Sz{UK(eAso|o0lH`pBFuO6bJs_UwoSn_2Pb} z`Jm7Bzt^$yjs3yMXR-x%$!RVqEy(z4z&C$%pZ}jHkvJd*tMEQS6S4vBwkYu%r5<`QN8WiqG4|lXpFF@1=Xu6O z{>bRrcjk#6*YElR-|md{$M};O!w3GYC-@%~xx95;)+hbh7uesp;*jU-GIm}xzfUTD z@%yBS{63no{cBwK-w=PDFL;T|eQxn|U4BsbJFn->GViA2hvzf1`RBS_|BYw+EcyrI zXJPd1EA;ukfjofY^w>4uSId6;MgPnb-aE9PUB9T?)go{I@PPj%h10$z&f{vnS_kxb zpWVEyBRH1U`Bd{ZuZ{=wE4U}?1a6LV;wGPyqvv^meNLRuM9=;bFZo?i@jZV6@73%4 zC0OR(yVe)+lP}kG@`L=8jPVOk>d{ZHt>d=wvHxksCk}SVPb$91i}z;Q?^NQThra&6 z&HF{o2m9$0))R4h{sx~MKa|TCepyfTqknjOb>)lv`WX|KIN;Oh({SMa| z<6+0&ADOMbpC}y8U--RF;yHido!e@LFjHy&f2vl@^7yKC902d>w|{%E%3hn)Lo#$(=cUUlWzFORG{FDf44 z`&tUz>uX&6zqtCL&nNz^^oe(kS9;x99K9st@^GT>LQR{k}=Q=$o5Feq{I)FZ|!7@Y;{)|3~!2h5r1E zk&`EJF8L*KUKsf#-{7H+%FTy5EG!Z72@3P44E9*<& zJU4RdV7|orcI4)ZobL&nFLAhTAP@5QypZwVR2=&YJdciCoPGS^{qV{72cyq?mml-M zKkJcx#0#$HL|;GZbN}GaYhHloZNXt4=<%Ms=h2Lxmi^8*{e$Pc@=v}TujIM==kmfw zUvu8>`7{2WpRxXlNB;UT9(CCm|MD-t;O6}z<;F#y`|swDpN)xQeXPS&SKSW$mJV-o`Z?kKEnRr%bxoFcE;*~^Q~oPp707zaT^z$e>&lh^AYp0zS#eH z{EDyb{++K z{e4DuUGwmU+(8Nt_9ieJa8U!T>O0WRej zGhg!K{R78c&!-k|%8B#6;@>#PuQ?HCb>f&0xZz#g)Zy)gqvz2R*PZ5z|GDL# zJYG;^^5=aU^CS-Ix&2|C4@b`Lc62?##c`YS6ZtSta2~BZ^@DtKt=sZHh5y90kJN|P zf3AGh2Om64;=zA>oxnr>952mBd-!@xHeMaj|{5ut1{4K~BJ?t2> zWj^dkT+g3jEik$N~>rDP{$k=+&M_hN< zZ}t`A4^&*wFUaTW(YJrq2M_mItY6FTRdVwJ7w^wb>LX_zQ0{pZcs?DudFY2c_?;>1 zt{gmk|5N?ObGPy@PpmU{34i8={F01aH}K2v^fhnr&iJ>>pZ$gW&c!?S94Enropq;g ze^B=J^TEh>Rz1ZJKdVX~zbD5pb-}Osqqnv4*DrefK9Rii{O0&NGyKTQbjExS#d@@# zFU{fy|H=J?fBlg6&x6xC&C8hQhW1_S{i5`czcBIT4IbcCJ9%XM*vPwn@PXZvBR5~q zgW%^OHP7_}@AohK=8K&7p~NrGzpQ&Uvx?GG-olo@GARaeg+FE%FCX>~`S3dr#zUXuz5G(Y_tn_*1?t58)Fat~yXD;PRBn9g!S70n%lP=Y zN%gP!B3~N3`ca>C?-S+6ywU&BFNw?V0~xpLa=YkT7vkcS~c&~=X&DbgG2s_`^k*)53j~MnDI|5zn1^&g#3va!w2>~|1O%~ znfDRY(;xVGk4;`$&iS))kFV&TQ+df7cpp~$fa4vB=X%k4Jg=0;mh*jb<@OQ0{Gj65 z7u1d4owr|3%9#AgkMjxnZ9X26d}fAT_r=@FzjXpP@3V-9xICwzKZwKlyNNHK9VfLv zVdwD{yPL(n{ox1Rz8`(@8vlyOH%;hYn(-u`jHik>aDA_Ek>^=8X8-c|vES@haNIug zrg3oJ(|Gf;pFVNjwLVjk^F0geU_G(peJJR_2vGH_QodSM&1x@`pX| z%js|4@C(kJg&%zPj2^ra*L8$Ay#HyO=9TAW@=yF{SN`H5?n$+tTUX-$QS|lO@qQk; z^9(=qmH72bzx`d!d-I}if1~n;uSX{@>p)#O&$v8W^0dzQJ2P>P4tdXe&LhP`{_ih5 z*mHg{i4PthRrAC+=zXjBa9xJyjg^o3Z!q_WE|2bvP{F;~j;3S^BkpGPiYlreh5#Xs+pTMyR53o~}T#_tD$)B1@Md;Z>i^T_krDb&mwT%Ra6K7OxX@q2x}W%5#= z_5EjsOM7r~99Pe}faA)Ey1cXO?L+LjpXB-9yo`BoP2SOaQO5F4+)u@id{7tO>#=Xq zUlo1lk-T8f@50Ex@#Rsub;bW<;`eB_@QwcN__aS6^IXKZ)CoQEVZD$K;}9RYbru)( z|5??G{5VhOd_NsK^VQ#U^muno^@INh#gB4v z;^!gZXC_8d7ESA&!X5{ zAM1@@u3xPy@*8HX9`oR?@#ENe4}Ru{e3}>jxIVGo*dHw%ohRRC(jI;}{&t+lRetz^ zSN#7VcFiZ}V?Gb+dN41{OMCcuN!i28;^Kw=yn2nbqrSXXAztHv_on3!9$5dK&*ls6 z{gp3qmsA|;X?|Y{?xSOT(%2H`8<88II2>1e?r%MrSL-8}e{mw`Ik@)Z#dQmPPF(e< z>qW&IIBy((_F416{U76ihv%pIGjI6*tD5KN5f419AL2pIeG%==hcVAJx{j=4*4sE! z(Yt%`3!ki4_CI>hExyfzy3n8cC+;W8j{L!k|5wy;2fpA*|Kg=iJdZFh?0F6>f9hQk z`Oete$Kbtl#dW>G|4m9yd+>07RotF$lFwA(>^#>*ebkx1LeBA1f9f&jzCq((m+@r(5SQyU z>uUbUA5iOza?Z>57GKTdmrG8bFOFa1iHq?IN>84^dykq=@rRlH}tNkIL-&;oM+fa`nfW4`cB@=k32qIymfw$oFy*$9PjKu z{CqHDdF$gJ&tJs>54@K!oh|)CoVU%=FZh2);l%DG6LrC_JQ4px72kZBk4FlB=k=Ra zx0Z8X!1-&Pi1VhZlXa$k_pkm3$Gw6R{N~4&G4IvM1M}oImA7&5$8$LK$eZu2=+C+z zTaT;m=1ZS{rSM}O1=vzyA?G_Njj0;hyD(xV*=AWwzjGy+;)f=7BxWjqO|Pxj&@6c`)C2 z&To9>^zExEKl+^SQ8=$V{;rB|zQkV}d-*kAczbW;)`xgM$rw3#i636Jq;A?N^nMY0>q8u__sEMl;NkrntVz{=WLuc}4u6#((n*4)|ccT4(d6o^LEZ^-mw4 zQu6lCdL=)dH_zdXiyw|};32+wg5y?&n>?=yF5;nQeCqkb;zPX1&x&9BT7CGqAn_-0 z!QWpd59`+b@gLDwj(?udStr+Z@N?W!ujOAY9>hUB-XjtRe*dogT37Pr_x77F@+V%_ zwiEL1zduhr^&7`$3!gku*FP#hT_@fzGavC#7uFa3!_%o5+uzPh_<3g4PdT`G&T4O7c^{G{sdc^O;(>s0@U4}QN=_=wB> zKkMWCB<~v(uhtiP__aPQUtMzZ!O#4{tvG`?3y-+mDXb>RM=_`vaZ(ZfIZJKhJYF6fyb->1}X>$9Gl zpZwwXK+QkrEA@Iw)u-{jrSh~-@Ux=usE?fcdEz1ecO^gPfqZaX%yCbimMBdS-hAh{qvN{v-Pj2I?%VDi9P!8+{;aS_qg&e-{8DS z@vVRSe7*Y3ypVG}eMY+~q^B?YU27p3Mu-Gv$%>i02c|U+j;@ZnEz< zKfi1FLyvXFIvIC5^4}}}@(YiDQ2ASb@_bhG&9m1>?n{XueclUre75k>eqULBtgGxKk%F}fs^xR^<1Cv|Kkb&pN*dNv;Xn;z{t&y zJXoi#pL+B)am<%EJb#8a@(~}$Ii7>*5BYTL#Mk~FG7*>WF_=I6f4JdSe6IK@T$Z&w|Vvrc$lmGOf!HopGghk33)`K7d40f#=J~-}Qt%K2muw z{%DPf`@`tVclRCZ)lBv`zu@8b4)t$b?3Wau_+62){Gj(6@h5+bSx+3(Z@eF1ox0C? zUND(Idi>6pIGUGNmOePZ4__PVc&;2j&o3U49|#WgvC}Vk@SIRSsLww|Pu|qS->*&J z;`zG%jEmhzD=zlDZ=yfy@Ui0K78#odzsF&J+b67-uZjL_eDM2X;_mpLtN8e#|Eve~ z;XKs(omBA`R~-EDKBRSH%zKcI?Wd{8dER(*_;FoD@1@B{+^*}$?^1GcBmYkEYyTaL ze0}`O3v%k^{L(LVcx&|RCvm|0HOk(4g8MNQk9+aQqm}pecdz1I zIXwS;t(V|>eC%Bh(Bpoqal7uk4`Dv?O`g2hWPI}fTBfr?q zl%DlC82R5-J{|u_)u-y?m%kr8S#SK`u4|p7v8^9`<)-~!+mS{?YMgik99)-0r5MNE&ZmPxKAy8`F-(2gcDei<>pP?-!EQ~eG$zsWAw#qT>Nt%<7l>> zAGn`T{NR6H@{u2KY_4(p<2v7d>iVE>eDMXn=PLGWr`4=DOG5q~6>#3Z0*NOh59G*X4{nmZBx9Zh?HWhEmx&Hlh>Dx!d<$Zl| zw}1M?`oaU(efAgY74IK_Pab|6{Epktg7RY@GG9)v^{MOe>s1fpKBfGMdn$6SFJ~$~ zIH(Kf*Wz~Frf(Jp$832%I{NmD{^4b9`Jb0Db*FyB0T)~HxJKmmFa7YDjO|nTXFcKX ztr?#Mv3TjTXU0w(_~W^R{n!5aKBoM%{k-Vgf6A%n zo2sA4k9nluuJgXCbKAeU?BxS{&acfAIp<$f*}|*GL-cMMyyDWH;{ng-v^PKMc3_tL z$m5SEa6G2?F&^=Gz9f(2aaG3n1sDBv+xWN7%@dq=iJmy&X;0P3zNZiFR()tb$a$V( ze;}Vf3qEjssOHT{8Q&&xT%WKzcLML-3$OK}@A$rneW0BD7nWRJ>2tm}DL(bV^Q7>q zocO%|c6qk&1rKbQmoEw~%kc+W1p9-r|0)#!_>R^hbh6+|>JTV-J7eH$VE33Bg#+~RAXLI!JOYwu}Na4pX^TOi?e0?_K<`aM9cYfsdEqbR{KHz5E(vEpVU930y zFP-pvi;U$596YZO2Y7$2^vRd!Uh2yqd0sPe^TZG5E9x=kdd)F@-c@6HAwSMrohQxj zYl>gx#N|B&_4Pv>t{beI$7AGN=P0+I@pHB4+h_JA`YUUGDhKBSN^ZZv`*qVtlX&pY z_tmVM_@^T0d-KYfx2F{U;--E-u6)4F`?uEFyurnKq(AIA&$ph`pZk5&+0svpdCwkv zj(LCIF@C?0IF6Z5kIH`I${+ZytodVKfai?j34ec9{)kUpc;I|T-sF>Yll#Kf?v!V)luC$^UmEw|?-)`f_=;_(AW|jK!xNaXG&>Zud9O^W+0L z_gjst9z0w%!6*LYz3cNkl^^wBeN#_7%opwl8u!?|%Gf?Y@1G~eV)J;;}M?VtCwU*Q~d5D_?LuHMp#|c8q^feQ$ht;l5q-30~x% zt31?aU7;WBYver71}}E5|MbIkDqi!$_kpdq^O(Bw+<9jBLC*a^`(D5Bb?u4u;&ah6 zzI_Y+50{?(gWl9E^(Fqg_&Jg-d6+MLo>BS2FWAxx39_brWu>J>oNZC6S?(~U)G~bC*-$|fBOXcFJ(XW*L|Ns+;{wyayK!^luk`9fvx| z2Xfhrc`rHQ=hk2rR#>9N%e#L>v$rFDZzvK(MTV?;@v7bKJm$Ch7|A6~*v}`Puix`@ISN{2D{ zM_lm0byo8Ne&XIP@|kQI+jrRiXX5J@oHt7x>(u=JTI5HDy?p~N`c^r4^L}8@i_d1i zJb?Rf_REw0@snrYCvYfdUi?Gw>A!LRSK&tgi^Ze!jW}19Uh~Fvm3f2v9vNGA?ZN%g zS?rNNKY7Tze84B~lN`+!p7F~T9Df+O^OQOvhiAUG-8hg_ufHlj%o{)a?z8&(L4T_J zpwII#`-t&VD?f2z_tzB%e_SuhgLUAz!t+@3B44gU?Gt!?S&iibKW~mdd6HLn;yH$L z;~;-v;puvDooFAJH+-<3$}4dnS$XQ0dTlAc$PY%(zHGd2E4=a!uHA)qUdF8NllJKG z9*gxcKIiqHsrrx?_d6ze#vjiqCwW26_Y!Bar9b3lI%Cdrtc&r`r{2o@xOa_OfADiz z>|Gc2OCFp@nuq-0_v_`?I?xAvA3=R^oLBrX=6M13ji3A%B>s^hr$5NkzA`R)Pl>&H z%F*LJVEOVmMPK~t1kQIvPrk(so>xV#U-_e6U#omr7jGVa;*ux)alA6`Uguw0JUid8 ze_iz<`nM>*#s?SuB`)J3=Q-TbY$ti-yjUF0Gw^Z0Oh4ko&qoTMaap(do|$~2f6H0$ zu`d5(@y0y4Z*cYT7JS6#JW;&x&iy3fv){PXi+LwbJEP1i!+|{ z6JA!;d8zSOf1eq9>jK}L_i5jF`QEAV?enSV@2`ID{@^)x`#oHKjZeNj7f{cBoQ|C5 zF!mktFBH$NpX7Dz(nHSsW8zm2p159|jDtP*bzJAgg+A8{%JqZa??f&S=szQ4`-OVn zs>bla^{@Sa{xfU8`O@FKXKcRcalYMswj*QxG@kFqPCWF>yEAqkSSQ9$o~SeZgFk*( z%edgbb>Xv~)QR&gJ_kieY-*V=+^7g~=T|dabTzN76i;3}H z)>!}a_ub;(I>Ha{&(OEzFF%aAKdc`7-X?nTtv~d5Urs&bPb)p~lP`5JFYV!n`~2E- zee%ML;TJsc&lY>`&mA3O`hn{<`h@+B`yqw9<6Ibh``zmx`rs4MpH0sFCvg#vIOxB) z{EH7c>zw-JO&`iv$K(BY_4Er5%v0s+Gru^$lsDHO_;_&jL(4x9Kk|h9Q5ie`tp~WC zRrccGc>EW&ewiQf?j3z`!1p^d)-U4?iGvq7tYz88KwK6&sx0q2{?9rSoUCob}2 zUf73>e_VOXD|V|Uj!VQl82N)@Z{1seb?Jlq4-0o6$5z*Ij5@Fm$p>TRyJPD~{!cAC zI?~^>@%Fmt+n4A+HhP|Shzon(^OIlwQa}1yzu@J4 zYU{?B?+tbT!7u*kFa4P(^6Qq|yuibGg?T9_{?d}$r}+7P@J{OE=b@$Fd9d!QuN*(T zcV!)X%a4wI*u?*7Q`yGX=F5+d*D#KJL$Dn4h3$xY5w_7?K%B_7;y-@Z zUz%-l+4$sa?{H}D3NS}+#lW@w8OQbJKmPdsV>x^wpKowz?&^VgZnme)9M@Y-?D*gC z*yG~`*u~!t{KWst;eQ7lCBM1BIDO=}&Z0|>Zx;-1wvbnBF99RmfO+I|fOxS#1OM4~ zD*m!vMcm={d~_0X5B9O!JuuA&OZ;vf{x3kD@y;P%Iru$r;JExE@;HVIldP1Ir5p)< z5kHA{hVjDPf+NTEHw->59@yq4$1~UmmegVGRoTYJ9S8k2b_sFAK9q$#Yc|I=)+U74gS+cuDeISZ5w>U*}CClVesK=VQ_$P zXYAo;IlAF**Wm9EJ`(G^E^-L2 zL*UAm_?H~Xc3@dWyx`n;;J9qb-~heV1Iq&YGVym^bzE=Zh;#Af$H%L+i_Y2h+7nY( z^n>R#@FeC^_2PdwIV8VTqt?XSN6h52cl7h}QNz>X7u~bz&umwYxCf|B{BCr9q$d{3 z4fV>tMeMs%<`g`WcYG`w94r`dPD^b3o;r9~jXb!gNBqUZ{{rIUBXPGLJ+5;$*rK}~ zOmN9KIc&t|;QY{tw`kPqGQMd&By1>nkd(ZG2= zxrEn+@EZB_;F3AITpZzIw=p7LLyTZJb6{GGkCP6~J&=9iSVGMBTF5vYo;^6+hJNH1 zkG#)foZOa?Q?_t*@l3Yi=yYV!-EsNx?Ii=(lF_p}hkP-(qkk@(#>Xn;iMffGu|Fr+ zk8wY?90$&!x3h1Vy%d~_N6+pf4j4zzoIChCIJOIipQYePoOP~G@v-gbaoIVeKNgC4 zjH%DoBgg%(Ih<{9A0SU^m%5xfaP1m7u0@A^iMi*h-{p?E~hX#ff;*8Ek_{zRra25U4Y>AV3w~QR}c@6v}zjN5a@4&ef9E*zn=ketxXcs+QcyTRDOcs{ri>rC^2Yw!o> z)Tqk>^n-o*(3wAS+ytl5oli~!&th`P=S3s#De%I+_z0eJMq_4t_!dacV`V=@Oj4Yxn$UG8gq6#7*fLx@gZO$7Q7l9`|cY6FB^GjW?8D0jiQ%9Ur#&+58yLQxR zsXQmnG@Jz69(3d5g2;|x3@58cyye5s8TzHpiNBus$>&tY!FneCgLU)ckB=8nyVPp~ zJcsXH$P??bv36WS9*KE6yfP<@9ltAQj@vNz5Ak{Q-%{|#W(_v+bqF7c$-29XK24n6 zBhH!BAbvO7FY$ji_R(26xLOEz@w0^b4L=tWZ+t#DVlTi~?9PF^Amj2abc_NUXhs$F=60?P7Fezj@d%u{X1C3HxGq7JlMy(O3s|B*rlt zIEd{=w%Na!{i)A}gU97l=taJqI%Uf|pAQfDyo7kj5@#blH$HC~pH~d5?4KGo%SQjK z&|l6USB!m}tDH4HFM^Zc-orM27Z04Q9aDpwGl%~rU?z6#H)EfC(Az)sR-%_VxN6jW zH@;Gj1#p_}zJci!wu!kJu2P>h&Ix)VKF=ghdSWB|X1s;`Gd?(al=bcaeH0&;5+mEG z>1^ZcLbe%i8FirN_RJiYZABJ8=Z(5;75tG-GgHJaBF!c5rPU7&Z+IrzAGId$ApLJ%hSNHU+-XQ%u#F3b(W7FI#n*>|1>a%%eCmIM>mo; zvFS7a@!*6wOUN<478yG^oI||G zme60x|03ch-*ZO3D-UEG9BUY7yAb@*KYQR=$y`YOXOVySI-hvSbL;3o;;sNoY!(|g zHW$EAWP9K_ai>SksPDPt5ubd%WaM-{ae`|ndWm!9$ZKi1#m|nz*~VsFY`{s+ok@(? z9P4d4i=PXJd@HtzeK9^`vypnm*G_!lBf1x%%jaO91@XZ^@;e?A}6P0kBv z@;R}$5hM1iz@P1@#2UFBv|jPOYUuA8Se6a`7Y_dE(KSPV?c4*oOEyPK$t)thH z+XW+U=HSYKVJ$wBFYD1oN3so;73MnT_|_4B9lD9Td*psP|U_R~`SnaKyv^wA|&`vU==i&TsH@ z?3VdCxHb+v>x`%W*soM?I`a{~$?cH)BRhBOUq0%&cJvYPHjaI(s6q0XvL?fR^_c&s zjT)`L@^~(b#=PG=_Ty{Q@WmWj3*N!yxdS^oTZYb-5pO&B1n)lLMgPDUFWY-O&Z*#u zd^dFnubeMkI+JZ`auGG5=Hcr65#u8265iK{D|iVf~n+(S&Tu`k=p@EQB_MjuV%FY@WpX9uQ_@82@~uS>q{-$UKA-D~}$w-HQ%c~EAdA1PavZ&dBWH9^MK}J=g}-c%t*;~R72t?$KYrlw%H*JLa>%|N z*d@2~$t_sVzC7E++%#e@V?X1O^S;VuKRuVZv6{IN{|C$=d2X}M^Z6jy<9kUso}2l6 zQDpc$V_?`adT%>&Fc2f#i}5#dI!I25advXRcG1AGZunZlcJ#?*qmIY&#V)?iu`kjy zD~GRD^m6;Ei6} zMZFSd+o<_DV9&a<2R>r6N&oS=eeiZB+$7I+W6oYY#+MAv4wnDj!~ddz_Z;dTTVkIL z7vuBR@%fBVr`6zL9z}0AeGv{Wun$IG9`gZT_z1pJi8Vei=W}wOg4=ADjy_n0kK_ga z=i`?e9!zfI^UlJ)2OsI7<=76tODo1QKF8O&U?xWVz}*>ahyIG8f7ak;E1V^lZQvkw z_}EJ9=r5&i*>@rPGG06SV%ezY6x^jprx|Dex-s`J8ou_9-Z%rUhwm-;4#qQ)1>-q# z7ayk#Y{&A4$HB!8Y@&NEakAYpwmaEI=R7$|PFt``oh~7Eux}q&j`7RB;5$cIe*zCk6`_CWoH{&;a?~M=Qa16UxznKrGjQH4| zcUAU-PYkiy#r|+JHMp6g_cK0=G5!)~?ci@QdBNZ1*~ZT_ z;NL#*uO9oU_cpNLKeh`8#`WZc->Ktv>*=|S&&Mu))-q0pviQ9K|M7Lo-fUA}xY#qeSgU?`JuvuQ zPQJ0*G}e(RbYgdizDSI%@Iubvc$qcI=OunlJx&|A*6Ba8HA8kDJdAwLK9bMzy&YNV zzb|^^bIRcQH00r92Q|w6v)P|qb_Fkbr{h1{t#Aon!NGRb&|Ah9tl?@t_~2peJ0Pb! zGymWtdW%QgZKL+Pvk#l8G1pERardY8;Jy@2z(>8Zy@c(^?Sjz{Yseu!P8Cah>>6C1 zg8$fU!7n&7-Z-!@m)Fijmi+dU-!;=0=*9nq*bV(nL;qCv#n(N9@uMXO3F#$6w^T z#Xah^Q@p{y?MQUu^K|+_Uu2`I*?=J2Zr7*_)Pqj=tu8-{3X}R7^ly7kZbmziyvZV zyNKBFvCmp%yZOj*y;aynZ>9TEyECaBwwa5k4%~a?F0xa`{zcfv?=oTq!=B-5eSCoJ zAUR|%oIdIUmihRL@8!easuA~0a7TB;CC9gyupb|XkL#}+`wtEI`J)fd9kI?Edd!v6 zz!wgdz*Shl6k%j<=7s{%mrI&y~bWy}`717z%MTbJms8+l>78%&A4p8UZ;j)`>{vEzRaep0JT!I15a zG4IX;D|y3nIK3E7Grn+)_Y97gl3Vs)!Z_P~;Li3Cj`0?@!E!d3f@LXK;55GW&t#kU z)8v!wMQnrfGI)rO74VWRobMUy$c12zpJn)o&BfTn-;#mtwA~pGoAa;A=d2r3=nT6vj*idSzk~hr z@-KYt1Rpw)pT+0&($3NI7m#0KEFIgkD#lKHB=>zIckI_=AK7Kff_E=@M0Pf^#N0i4 zo%;k!&`<1TgRdRLiSAVP!PO;jmG}p&_lUnTv5xVDJo@M0JNoO4o4ijy5?Oq&3I=pf z#dpT1;3KuZ=&yO0IPY4ArL z1J605zB@;)cCbyHm9fRw`cd~yBw|3U7u8Kb_X~V)wuHzemkkGG}l8`QMG^{O|pD#hhPU>jQ!C|Nr~H zmxE7S^Z$R`ryyYcx?>*;QSO2g7TDIl# zxr>vwqul=ZmBYXGvR}!1`_wtl%$m!J`}8?4o%4b@e>&$mbAE5mv*x@u|Gjh0yXL$n z+b7L=O8)=#UoQQCIPojbE8duDD=GCwGZ}_fu#s50KxmWyu HgYW+Vx5n1r literal 0 HcmV?d00001 diff --git a/crates/i18n/data/generate-collation.sh b/crates/i18n/data/generate-collation.sh new file mode 100755 index 000000000..cc7bbd3d3 --- /dev/null +++ b/crates/i18n/data/generate-collation.sh @@ -0,0 +1,15 @@ +#!/bin/sh +# Regenerates the collation data `collate` embeds, a postcard blob for the locales craft-i18n +# ships (its `locales/` directory) plus English: each locale's default order, and Chinese +# pinyin and stroke orders. +# Needs icu4x-datagen at the version of the icu_collator dependency: +# cargo install icu4x-datagen --version 2.3.0 --locked +set -eu +cd "$(dirname "$0")" +icu4x-datagen --format blob --overwrite --out collation.postcard --deduplication maximal \ + --cldr-tag 48.2.1 --icuexport-tag release-78.1rc \ + --attribute-filter 'collator=/^(stroke|pinyin)?$/' \ + --markers CollationRootV1 CollationTailoringV1 CollationDiacriticsV1 CollationJamoV1 \ + CollationMetadataV1 CollationReorderingV1 CollationSpecialPrimariesV1 \ + --locales en ar bg cs da de el es et fi fr hr hu id it ja ka kk ko lt nl pl pt-BR ru sl sr \ + sr-Latn sv te tr uk vi zh-Hans zh-Hant diff --git a/crates/i18n/src/collate.rs b/crates/i18n/src/collate.rs new file mode 100644 index 000000000..d73b1efce --- /dev/null +++ b/crates/i18n/src/collate.rs @@ -0,0 +1,191 @@ +//! Locale sorting for lists people see: fonts, layers, presets, files. +//! +//! ICU4X collation with data embedded for the locales craft-i18n ships (regenerated by +//! `data/generate-collation.sh`). A locale without data of its own sorts in the Unicode root +//! order, which suits most languages that have no tailoring. + +use std::cmp::Ordering; + +use icu_collator::CollatorPreferences; +use icu_collator::options::CollatorOptions; +use icu_collator::preferences::CollationNumericOrdering; +use icu_collator::provider::{ + CollationDiacriticsV1, CollationJamoV1, CollationMetadataV1, CollationReorderingV1, CollationRootV1, CollationSpecialPrimariesV1, + CollationTailoringV1, +}; +use icu_locale_core::Locale; +use icu_locale_fallback::LocaleFallbacker; +use icu_normalizer::provider::{NormalizerNfdDataV1, NormalizerNfdTablesV1}; +use icu_provider::buf::AsDeserializingBufferProvider; +use icu_provider::prelude::*; +use icu_provider_adapters::fallback::LocaleFallbackProvider; +use icu_provider_blob::BlobDataProvider; + +/// Compares text in one locale's order, with runs of digits by their value: `Layer 2` before +/// `Layer 10`. +#[derive(Debug)] +pub struct Collator(Option); + +impl Collator { + /// Sorting for `locale`, a language code such as `sv`, `pt-br` or `zh-hant` (any case, `-` + /// or `_`). A BCP 47 collation type picks another order where there is one: + /// `zh-hant-u-co-pinyin`, `zh-hans-u-co-stroke`. + pub fn new(locale: &str) -> Collator { + let mut prefs: CollatorPreferences = Locale::try_from_str(&locale.replace('_', "-")).unwrap_or(Locale::UNKNOWN).into(); + prefs.numeric_ordering = Some(CollationNumericOrdering::True); + let options = CollatorOptions::default(); + let collator = Data::new().ok().and_then(|data| { + icu_collator::Collator::try_new_unstable(&data, prefs, options) + .or_else(|_| icu_collator::Collator::try_new_unstable(&data, CollatorPreferences::default(), options)) + .ok() + }); + Collator(collator) + } + + /// `a` against `b`. Text that differs only in case or accents still has an order, so a + /// sort is stable across runs. Without collation data (never with the embedded data) this is + /// code point order. + pub fn compare(&self, a: &str, b: &str) -> Ordering { + match &self.0 { + Some(c) => c.as_borrowed().compare(a, b), + None => a.cmp(b), + } + } + + /// Sort `items` by the text `key` gives each one. + pub fn sort_by_key(&self, items: &mut [T], key: impl Fn(&T) -> &str) { + items.sort_by(|a, b| self.compare(key(a), key(b))); + } +} + +/// The embedded collation data, with locale fallback, and the decomposition tables the collator +/// also needs taken from `icu_normalizer`'s compiled data rather than embedded a second time. +struct Data(LocaleFallbackProvider); + +impl Data { + fn new() -> Result { + let blob = BlobDataProvider::try_new_from_static_blob(include_bytes!("../data/collation.postcard"))?; + Ok(Data(LocaleFallbackProvider::new(blob, LocaleFallbacker::new().static_to_owned()))) + } +} + +macro_rules! from_blob { + ($($marker:ty),*) => {$( + impl DataProvider<$marker> for Data { + fn load(&self, req: DataRequest) -> Result, DataError> { + self.0.as_deserializing().load(req) + } + } + )*}; +} + +from_blob!( + CollationRootV1, + CollationTailoringV1, + CollationDiacriticsV1, + CollationJamoV1, + CollationMetadataV1, + CollationReorderingV1, + CollationSpecialPrimariesV1 +); + +impl DataProvider for Data { + fn load(&self, req: DataRequest) -> Result, DataError> { + icu_normalizer::provider::Baked.load(req) + } +} + +impl DataProvider for Data { + fn load(&self, req: DataRequest) -> Result, DataError> { + icu_normalizer::provider::Baked.load(req) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sorted<'a>(locale: &str, items: &[&'a str]) -> Vec<&'a str> { + let mut v = items.to_vec(); + Collator::new(locale).sort_by_key(&mut v, |s| s); + v + } + + #[test] + fn every_shipped_locale_has_its_data() { + for locale in [ + "en", + "ar", + "bg", + "cs", + "da", + "de", + "el", + "es", + "et", + "fi", + "fr", + "hr", + "hu", + "id", + "it", + "ja", + "ka", + "kk", + "ko", + "lt", + "nl", + "pl", + "pt-br", + "ru", + "sl", + "sr", + "sr-latn", + "sv", + "te", + "tr", + "uk", + "vi", + "zh-hans", + "zh-hant", + "not a locale", + ] { + assert!(Collator::new(locale).0.is_some(), "{locale}"); + } + } + + #[test] + fn digits_sort_by_value() { + let layers = ["Layer 10", "Layer 2", "layer 1", "Layer 1", "Layer 2b", "Layer 02"]; + assert_eq!(sorted("en", &layers), ["layer 1", "Layer 1", "Layer 2", "Layer 02", "Layer 2b", "Layer 10"]); + assert_eq!(sorted("de", &["Ebene ٣", "Ebene 10", "Ebene 2"]), ["Ebene 2", "Ebene ٣", "Ebene 10"]); + } + + #[test] + fn locales_sort_their_own_letters() { + let words = ["öl", "zebra", "år", "äpple", "apa", "oxe"]; + assert_eq!(sorted("sv", &words), ["apa", "oxe", "zebra", "år", "äpple", "öl"]); + assert_eq!(sorted("de", &words), ["apa", "äpple", "år", "öl", "oxe", "zebra"]); + assert_eq!(sorted("de", &["Muller", "Müller", "Mueller", "Mahler"]), ["Mahler", "Mueller", "Muller", "Müller"]); + assert_eq!( + sorted("tr", &["şeker", "sabun", "ılık", "iğne", "çay", "cam", "uzun", "üzüm"]), + ["cam", "çay", "ılık", "iğne", "sabun", "şeker", "uzun", "üzüm"] + ); + assert_eq!(sorted("en", &["ılık", "iğne", "cam", "çay"]), ["cam", "çay", "iğne", "ılık"]); + assert_eq!(sorted("es", &["ñu", "nube", "oso"]), ["nube", "ñu", "oso"]); + // Japanese: Latin, then kana in gojūon order (hiragana and katakana together), then kanji. + assert_eq!(sorted("ja", &["漢字", "カメラ", "あめ", "いぬ", "Zoom"]), ["Zoom", "あめ", "いぬ", "カメラ", "漢字"]); + } + + #[test] + fn chinese_sorts_by_pinyin_or_stroke() { + // 北 běi (5 strokes), 上 shàng (3), 天 tiān (4). + let cities = ["天津", "北京", "上海"]; + let pinyin = ["北京", "上海", "天津"]; + let stroke = ["上海", "天津", "北京"]; + assert_eq!(sorted("zh-hans", &cities), pinyin); + assert_eq!(sorted("zh-hant", &cities), stroke); + assert_eq!(sorted("zh-hans-u-co-stroke", &cities), stroke); + assert_eq!(sorted("zh-hant-u-co-pinyin", &cities), pinyin); + } +} diff --git a/crates/i18n/src/fold.rs b/crates/i18n/src/fold.rs new file mode 100644 index 000000000..19289fe32 --- /dev/null +++ b/crates/i18n/src/fold.rs @@ -0,0 +1,330 @@ +//! Case- and accent-insensitive matching for search fields, with a map back to the original text +//! for highlighting. +//! +//! [`Folder::fold`] turns text into a search key: compatibility forms decomposed (`fi` → `fi`, +//! full-width `A` → `a`, `㎏` → `kg`), lower case, `ß` → `ss`, final `ς` → `σ`, and with accents +//! ignored (the default) combining accents, Hebrew points and Arabic vowel marks dropped and the +//! few letters with built-in strokes mapped to their base (`ø`, `ł`, `đ`, `ı`, `æ` → `ae`). +//! Invisible format characters (soft hyphens, zero-width joiners, bidi isolates) are dropped too. +//! +//! Dotted and dotless i follow the locale when accents count. In Turkish and Azerbaijani, `I` +//! folds to `ı` and `İ` to `i`; elsewhere both fold to `i` and `ı` stays a letter of its own. +//! Ignoring accents, all four fold to `i`, so `istanbul` finds `İSTANBUL` and `irmak` finds +//! `IRMAK` and `ırmak`. Plain `str::to_lowercase` follows no locale and turns `İ` into `i` plus a +//! combining dot, which nothing typed would match. +//! +//! The Turkish i handling and sigma folding come from PdfCraft's `fold_case` (snipeTR, +//! nevicia2010-hub). + +use std::ops::Range; + +use icu_normalizer::DecomposingNormalizerBorrowed; +use icu_properties::CodePointSetData; +use icu_properties::props::GraphemeExtend; + +/// Folds text for one locale. Cheap to build and copy. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Folder { + turkic: bool, + accents: bool, +} + +impl Folder { + /// Case- and accent-insensitive folding for `locale`, a language code such as `tr`, `pt-br` + /// or `zh-hant` (any case, `-` or `_`). + pub fn new(locale: &str) -> Folder { + let language = locale.split(['-', '_']).next().unwrap_or_default(); + Folder { turkic: language.eq_ignore_ascii_case("tr") || language.eq_ignore_ascii_case("az"), accents: false } + } + + /// Accents count: `é` no longer matches `e`, and a match can't end on a letter whose accent it + /// leaves out. + pub fn keep_accents(self) -> Folder { + Folder { accents: true, ..self } + } + + /// `s` folded, with the map back to `s`. + pub fn fold(&self, s: &str) -> Folded { + let nfkd = DecomposingNormalizerBorrowed::new_nfkd(); + let extend = CodePointSetData::new::(); + let mut text = String::with_capacity(s.len()); + let mut starts = Vec::new(); + // A cluster is a character and the combining marks after it, folded and mapped as one. + let mut cluster_start = 0; + for (i, c) in s.char_indices().skip(1) { + if !extend.contains(c) { + starts.push((text.len(), cluster_start)); + self.fold_cluster(&nfkd.normalize(&s[cluster_start..i]), &mut text); + cluster_start = i; + } + } + if !s.is_empty() { + starts.push((text.len(), cluster_start)); + self.fold_cluster(&nfkd.normalize(&s[cluster_start..]), &mut text); + } + starts.push((text.len(), s.len())); + Folded { text, starts } + } + + /// `s` folded, without the map: a key to compare or store. + pub fn key(&self, s: &str) -> String { + self.fold(s).text + } + + /// Whether `haystack` contains `needle`, both folded. + pub fn contains(&self, haystack: &str, needle: &str) -> bool { + self.fold(haystack).find(&self.fold(needle)).is_some() + } + + /// One cluster, already in NFKD. + fn fold_cluster(&self, cluster: &str, out: &mut String) { + let mut chars = cluster.chars().peekable(); + while let Some(c) = chars.next() { + match c { + // `İ` decomposes to `I` and a combining dot above: the dot of a lower-case `i`. + 'I' if chars.peek() == Some(&'\u{307}') => { + chars.next(); + out.push('i'); + } + 'I' if self.turkic && self.accents => out.push('ı'), + 'ß' | 'ẞ' => out.push_str("ss"), + 'Σ' | 'σ' | 'ς' => out.push('σ'), + c if ignorable(c) || (!self.accents && accent(c)) => {} + c => { + for l in c.to_lowercase() { + match base_letter(l) { + Some(base) if !self.accents => out.push_str(base), + _ => out.push(l), + } + } + } + } + } + } +} + +/// The base of a lower-case letter whose diacritic is built in rather than combining. +fn base_letter(c: char) -> Option<&'static str> { + Some(match c { + 'ı' => "i", + 'ø' => "o", + 'ł' => "l", + 'đ' => "d", + 'ħ' => "h", + 'ŧ' => "t", + 'æ' => "ae", + 'œ' => "oe", + _ => return None, + }) +} + +/// Invisible characters that only steer layout: soft hyphen, Arabic letter mark, the kashida +/// that stretches Arabic words, Mongolian variation selectors, zero-width space and joiners, direction marks, embeddings and isolates, +/// word joiner, variation selectors, byte order mark and tags. +fn ignorable(c: char) -> bool { + matches!(c, + '\u{AD}' | '\u{61C}' | '\u{640}' | '\u{180B}'..='\u{180F}' | '\u{200B}'..='\u{200F}' | '\u{202A}'..='\u{202E}' + | '\u{2060}'..='\u{206F}' | '\u{FE00}'..='\u{FE0F}' | '\u{FEFF}' | '\u{E0000}'..='\u{E0FFF}') +} + +/// Marks that searching without accents ignores: Latin, Greek and Cyrillic combining diacritics, +/// Hebrew points and cantillation, Arabic vowel marks. +/// Kana voicing marks, Indic and Thai vowel signs are letters' parts, not accents, and stay. +fn accent(c: char) -> bool { + matches!(c, + '\u{300}'..='\u{36F}' | '\u{1AB0}'..='\u{1AFF}' | '\u{1DC0}'..='\u{1DFF}' | '\u{20D0}'..='\u{20FF}' + | '\u{FE20}'..='\u{FE2F}' + | '\u{591}'..='\u{5BD}' | '\u{5BF}' | '\u{5C1}' | '\u{5C2}' | '\u{5C4}' | '\u{5C5}' | '\u{5C7}' + | '\u{610}'..='\u{61A}' | '\u{64B}'..='\u{65F}' | '\u{670}' | '\u{6D6}'..='\u{6DC}' + | '\u{6DF}'..='\u{6E4}' | '\u{6E7}' | '\u{6E8}' | '\u{6EA}'..='\u{6ED}') +} + +/// Folded text and where each part came from. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Folded { + text: String, + /// One (folded offset, source offset) per source cluster, in order, then both lengths. A + /// cluster folds to the text up to the next entry's folded offset, possibly none. + starts: Vec<(usize, usize)>, +} + +impl Folded { + /// The folded text. + pub fn as_str(&self) -> &str { + &self.text + } + + /// The source byte range that folded bytes `range` came from, widened to whole source + /// characters with their accents: `ss` in the folding of `Straße` maps back to `ß`, `e` in + /// that of `é` to the whole `é`. An empty range maps to an empty one. + pub fn source_range(&self, range: Range) -> Range { + // The last cluster starting at or before `at` is the one holding it: clusters that fold to + // nothing share their offset with the next one. + let cluster = |at: usize| self.starts.partition_point(|&(f, _)| f <= at).saturating_sub(1); + if range.is_empty() { + let s = self.starts[cluster(range.start)].1; + return s..s; + } + let first = cluster(range.start); + let last = cluster(range.end - 1); + self.starts[first].1..self.starts[(last + 1).min(self.starts.len() - 1)].1 + } + + /// The first match of `needle` (folded the same way), as a byte range of the source text. + /// An empty needle matches at the start. + pub fn find(&self, needle: &Folded) -> Option> { + self.matches(needle).next() + } + + /// Every non-overlapping match of `needle` (folded the same way), as byte ranges of the + /// source text. A match may start or end inside an expansion (`ss` of `ß`, `fi` of `fi`) but + /// not leave out a mark that follows a letter, so `re` doesn't match `ré` when accents count. + pub fn matches<'a>(&'a self, needle: &'a Folded) -> impl Iterator> + 'a { + let extend = CodePointSetData::new::(); + let starts_mark = move |at: usize| self.text[at..].chars().next().is_some_and(|c| extend.contains(c)); + let n = needle.text.as_str(); + let mut from = 0; + let mut done = false; + std::iter::from_fn(move || { + if done { + return None; + } + if n.is_empty() { + done = true; + return Some(0..0); + } + while let Some(i) = self.text.get(from..).and_then(|t| t.find(n)) { + let start = from + i; + let end = start + n.len(); + if !starts_mark(start) && !starts_mark(end) { + from = end; + return Some(self.source_range(start..end)); + } + from = start + self.text[start..].chars().next().map_or(1, char::len_utf8); + } + done = true; + None + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn key(locale: &str, s: &str) -> String { + Folder::new(locale).key(s) + } + + fn key_accents(locale: &str, s: &str) -> String { + Folder::new(locale).keep_accents().key(s) + } + + #[test] + fn folding_table() { + let nfd = |s: &str| DecomposingNormalizerBorrowed::new_nfd().normalize(s).into_owned(); + // (locale, input, ignoring accents, keeping accents) + let table = [ + ("en", "Hello WORLD", "hello world".to_owned(), "hello world".to_owned()), + ("en", "İSTANBUL", "istanbul".into(), "istanbul".into()), + ("tr", "İSTANBUL", "istanbul".into(), "istanbul".into()), + ("en", "IRMAK ırmak", "irmak irmak".into(), "irmak ırmak".into()), + ("tr", "IRMAK ırmak", "irmak irmak".into(), "ırmak ırmak".into()), + ("az", "İIıi", "iiii".into(), "iııi".into()), + ("tr-TR", "Şişli", "sisli".into(), nfd("şişli")), + ("de", "Straße STRAẞE", "strasse strasse".into(), "strasse strasse".into()), + ("el", "ΟΔΥΣΣΕΥΣ οδυσσευς", "οδυσσευσ οδυσσευσ".into(), "οδυσσευσ οδυσσευσ".into()), + ("el", "Ἀθῆναι", "αθηναι".into(), nfd("ἀθῆναι")), + ("fr", "Œuvre ÉTÉ", "oeuvre ete".into(), nfd("œuvre été")), + ("da", "Ærø", "aero".into(), nfd("ærø")), + ("pl", "Łódź", "lodz".into(), nfd("łódź")), + ("vi", "Đà Nẵng", "da nang".into(), nfd("đà nẵng")), + ("en", "final ff", "final ff".into(), "final ff".into()), + ("ja", "ABC123 ガ", nfd("abc123 ガ"), nfd("abc123 ガ")), + ("ar", "مُحَمَّد أحمـــد", nfd("محمد احمد"), nfd("مُحَمَّد أحمد")), + ("he", "שָׁלוֹם", "שלום".into(), nfd("שָׁלוֹם")), + ("en", "soft\u{AD}ware \u{2068}name\u{2069}", "software name".into(), "software name".into()), + ("ru", "Ёлка", "елка".into(), nfd("ёлка")), + ]; + for (locale, input, ignoring, keeping) in table { + assert_eq!(key(locale, input), ignoring, "{locale} {input}"); + assert_eq!(key_accents(locale, input), keeping, "{locale} {input}, accents kept"); + } + } + + #[test] + fn turkish_i_depends_on_locale_when_accents_count() { + let tr = Folder::new("tr").keep_accents(); + let en = Folder::new("en").keep_accents(); + assert!(tr.contains("IRMAK", "ırmak") && !tr.contains("IRMAK", "irmak")); + assert!(en.contains("IRMAK", "irmak") && !en.contains("IRMAK", "ırmak")); + assert!(tr.contains("İSTANBUL", "istanbul") && en.contains("İSTANBUL", "istanbul")); + for locale in ["tr", "en"] { + let f = Folder::new(locale); + assert!(f.contains("IRMAK", "ırmak") && f.contains("ırmak", "IRMAK") && f.contains("İzmir", "izmir")); + } + } + + #[test] + fn matches_map_back_to_the_source() { + let f = Folder::new("en"); + fn find<'a>(f: Folder, haystack: &'a str, needle: &str) -> Option<&'a str> { + f.fold(haystack).find(&f.fold(needle)).map(|r| &haystack[r]) + } + let find = |haystack, needle| find(f, haystack, needle); + assert_eq!(find("Große Straße", "strasse"), Some("Straße")); + assert_eq!(find("Große Straße", "SS"), Some("ß")); + assert_eq!(find("Große Straße", "sse"), Some("ße")); + assert_eq!(find("Café crème", "creme"), Some("crème")); + assert_eq!(find("Cafe\u{301} noir", "cafe"), Some("Cafe\u{301}")); + assert_eq!(find("File 2", "file 2"), Some("File 2")); + assert_eq!(find("the final cut", "fin"), Some("fin")); + assert_eq!(find("Layer \u{2068}İSTANBUL\u{2069}", "istanbul"), Some("İSTANBUL")); + assert_eq!(find("abc", "x"), None); + assert_eq!(find("abc", ""), Some("")); + + let all: Vec<_> = f.fold("Ab ab AB").matches(&f.fold("ab")).collect(); + assert_eq!(all, [0..2, 3..5, 6..8]); + } + + #[test] + fn accents_count_only_whole_letters() { + let f = Folder::new("fr").keep_accents(); + assert!(!f.contains("ré", "re")); + assert!(f.contains("ré", "RÉ") && f.contains("re\u{301}", "ré")); + assert!(f.contains("ré", "é")); + // Kana voicing marks stay even with accents ignored: か doesn't find が. + assert!(!Folder::new("ja").contains("が", "か")); + } + + #[test] + fn source_ranges_round_trip() { + let f = Folder::new("de"); + for s in ["Straße", "ffi A", "e\u{301}\u{AD}x", "", "İı", "\u{301}a"] { + let folded = f.fold(s); + let text = folded.as_str(); + // A leading mark folds to nothing and has no part in any match. + let whole = if s.starts_with('\u{301}') { 2..s.len() } else { 0..s.len() }; + assert_eq!(folded.source_range(0..text.len()), whole, "{s:?}"); + // Every folded character maps to a non-empty source range that folds back to text + // containing it. + for (i, c) in text.char_indices() { + let r = folded.source_range(i..i + c.len_utf8()); + assert!(!r.is_empty() && f.key(&s[r.clone()]).contains(c), "{s:?} {i} {r:?}"); + } + } + } + + #[test] + fn folding_is_idempotent() { + // A folded query folds to itself, so a stored key can be searched with a fresh fold. + for locale in ["en", "tr"] { + for f in [Folder::new(locale), Folder::new(locale).keep_accents()] { + for c in ('\0'..='\u{FFFF}').chain('\u{1D400}'..='\u{1D7FF}') { + let once = f.key(c.encode_utf8(&mut [0; 4])); + assert_eq!(f.key(&once), once, "{locale} {:?} U+{:04X}", f, c as u32); + } + } + } + } +} diff --git a/crates/i18n/src/lib.rs b/crates/i18n/src/lib.rs index 7fa7a4efe..806644f8a 100644 --- a/crates/i18n/src/lib.rs +++ b/crates/i18n/src/lib.rs @@ -6,6 +6,13 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod catalog; +#[cfg(feature = "collate")] +mod collate; +mod fold; + +#[cfg(feature = "collate")] +pub use collate::Collator; +pub use fold::{Folded, Folder}; use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::sync::{Mutex, OnceLock, PoisonError}; From cf36f3cb215323845809c9688db681af2d835af9 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 05:14:23 +0000 Subject: [PATCH 07/43] i18n: numbers for a region on ICU4X, locale digits in counts and sizes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Format writes integers, decimals and percentages for a Region on ICU4X's decimal formatter: the locale's digits (Arabic-Indic in Egypt, Persian in Iran) or Latin ones on request, its separators and grouping sizes (Indian 12,34,567), CLDR's percent patterns, and the region's own separators where the system sets them. I18n::format holds the one trn, file_size and measure use, the system region's until set_format. Plural rules move from intl_pluralrules to ICU4X, and trn_decimal picks the form from the digits shown (1.0 files, 1,5 файла). The CLDR data (decimal symbols, digits, plural rules, percent patterns) covers the shipped languages and their regional variants; data/generate.sh rebuilds it. --- Cargo.lock | 170 +++++++++++++++--- crates/i18n/Cargo.toml | 14 +- crates/i18n/README.md | 29 ++- crates/i18n/data/generate.sh | 23 +++ crates/i18n/data/locales.rs | 79 +++++++++ crates/i18n/data/numbers.postcard | Bin 0 -> 5664 bytes crates/i18n/data/percent.py | 64 +++++++ crates/i18n/src/format.rs | 286 ++++++++++++++++++++++++++++++ crates/i18n/src/lib.rs | 148 +++++++++++++--- crates/i18n/src/size.rs | 49 ++--- 10 files changed, 777 insertions(+), 85 deletions(-) create mode 100755 crates/i18n/data/generate.sh create mode 100644 crates/i18n/data/locales.rs create mode 100644 crates/i18n/data/numbers.postcard create mode 100644 crates/i18n/data/percent.py create mode 100644 crates/i18n/src/format.rs diff --git a/Cargo.lock b/Cargo.lock index 11eebe644..7fc992dfe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -491,6 +491,15 @@ dependencies = [ "x509-cert", ] +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.21", +] + [[package]] name = "codespan-reporting" version = "0.13.1" @@ -656,11 +665,18 @@ version = "0.1.0" name = "craft-i18n" version = "0.1.0" dependencies = [ - "intl_pluralrules", + "fixed_decimal", + "icu_decimal", + "icu_locale_core", + "icu_locale_fallback", + "icu_plurals", + "icu_provider", + "icu_provider_adapters", + "icu_provider_blob", "objc2-foundation 0.3.2", "sys-locale", - "unic-langid", "windows-registry", + "writeable", ] [[package]] @@ -1232,6 +1248,17 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +[[package]] +name = "fixed_decimal" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79c3c892f121fff406e5dd6b28c1b30096b95111c30701a899d4f2b18da6d1bd" +dependencies = [ + "displaydoc", + "smallvec", + "writeable", +] + [[package]] name = "flagset" version = "0.4.7" @@ -1861,6 +1888,23 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_decimal" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eb8f655bba2d0c0459e43a5b0e6cd3cd388109898fb3d85d048165e8b0abd08" +dependencies = [ + "displaydoc", + "fixed_decimal", + "icu_locale_core", + "icu_pattern", + "icu_plurals", + "icu_provider", + "serde", + "writeable", + "zerovec", +] + [[package]] name = "icu_locale_core" version = "2.3.0" @@ -1869,11 +1913,32 @@ checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", + "serde", "tinystr", "writeable", "zerovec", ] +[[package]] +name = "icu_locale_fallback" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9" +dependencies = [ + "icu_locale_core", + "icu_locale_fallback_data", + "icu_provider", + "potential_utf", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8" + [[package]] name = "icu_normalizer" version = "2.3.0" @@ -1894,6 +1959,32 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" +[[package]] +name = "icu_pattern" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b3db611446bd92c246b03e4aa0bafa71ddb194af431ddbb42b9d70e4bfce1a0" +dependencies = [ + "displaydoc", + "either", + "serde", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_plurals" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e475e6766ef87b1d3c1f97be6363995b0bfaeddbc789671615df598a97ff0593" +dependencies = [ + "displaydoc", + "fixed_decimal", + "icu_provider", + "serde", + "zerovec", +] + [[package]] name = "icu_properties" version = "2.3.0" @@ -1923,6 +2014,9 @@ checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", + "postcard", + "serde", + "stable_deref_trait", "writeable", "yoke", "zerofrom", @@ -1930,6 +2024,30 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_provider_adapters" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df887bc4f55dbc773c7c45a0c7dfa75be886f7b7812830a71ab7271cc1801820" +dependencies = [ + "icu_locale_fallback", + "icu_provider", +] + +[[package]] +name = "icu_provider_blob" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5916b44fac319d3f58e4d8d805a627df19e27937b582cd99fcde3bb6b7ce1b0e" +dependencies = [ + "icu_provider", + "postcard", + "serde", + "writeable", + "zerotrie", + "zerovec", +] + [[package]] name = "idna" version = "1.1.0" @@ -1997,15 +2115,6 @@ dependencies = [ "hashbrown 0.17.1", ] -[[package]] -name = "intl_pluralrules" -version = "7.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972" -dependencies = [ - "unic-langid", -] - [[package]] name = "ipnet" version = "2.12.2" @@ -2250,6 +2359,9 @@ name = "litemap" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" +dependencies = [ + "serde_core", +] [[package]] name = "litrs" @@ -3081,12 +3193,24 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "serde", +] + [[package]] name = "potential_utf" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ + "serde_core", + "writeable", "zerovec", ] @@ -4717,24 +4841,6 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" -[[package]] -name = "unic-langid" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05" -dependencies = [ - "unic-langid-impl", -] - -[[package]] -name = "unic-langid-impl" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658" -dependencies = [ - "tinystr", -] - [[package]] name = "unicode-bidi" version = "0.3.18" @@ -5794,6 +5900,9 @@ name = "writeable" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +dependencies = [ + "either", +] [[package]] name = "x11rb" @@ -5987,8 +6096,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", + "litemap", + "serde_core", "yoke", "zerofrom", + "zerovec", ] [[package]] diff --git a/crates/i18n/Cargo.toml b/crates/i18n/Cargo.toml index bfff93ce2..3daaca103 100644 --- a/crates/i18n/Cargo.toml +++ b/crates/i18n/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "craft-i18n" -description = "Translations for the Craft apps: the embedded craft-i18n catalogs, lookup with fallback, plurals, language names and the system locale." +description = "Translations for the Craft apps: the embedded craft-i18n catalogs, lookup with fallback, plurals, numbers for a region, language names and the system locale." version.workspace = true edition.workspace = true license.workspace = true @@ -8,8 +8,16 @@ rust-version.workspace = true repository.workspace = true [dependencies] -intl_pluralrules = "=7.0.2" -unic-langid = "0.9" +# Numbers and plural rules, on CLDR data for the shipped locales (data/numbers.postcard). +fixed_decimal = { version = "0.7.2", default-features = false } +icu_decimal = { version = "2.3", default-features = false, features = ["serde"] } +icu_locale_core = { version = "2.3", default-features = false } +icu_locale_fallback = { version = "2.3", default-features = false, features = ["compiled_data"] } +icu_plurals = { version = "2.3", default-features = false, features = ["serde"] } +icu_provider = { version = "2.3", default-features = false, features = ["sync"] } +icu_provider_adapters = { version = "2.3", default-features = false } +icu_provider_blob = { version = "2.3", default-features = false } +writeable = "0.6" sys-locale = "0.3" [target.'cfg(target_arch = "wasm32")'.dependencies] diff --git a/crates/i18n/README.md b/crates/i18n/README.md index 5d37b72ee..bb48bbad4 100644 --- a/crates/i18n/README.md +++ b/crates/i18n/README.md @@ -45,7 +45,8 @@ The app keeps its `tl!` macro, its language setting (`"auto"` → `I18N.system_l → `pt`, `zh-hant-hk` → `zh-hant`); `sr-latn` never falls back to Cyrillic `sr`. A missing translation never errors. - **Lookups:** `t` (plain), `tc` (with a context such as `menu` or `@msg`, else plain), `tid` (an - `@id` command id, else the English label), `trn` (a count, `@plural` rows, `{n}` filled), `get` + `@id` command id, else the English label), `trn` and `trn_decimal` (a count, `@plural` rows, + `{n}` filled), `get` (one context along the locale chain, so `pt-br` can return a `pt` row, but never the plain row or English: a key cap's name under `key`, or "is there a translation?"), and `fmt` for `{name}` placeholders, filled in one pass. @@ -54,10 +55,12 @@ The app keeps its `tl!` macro, its language setting (`"auto"` → `I18N.system_l are skipped and returned by `I18n::new` for the app to log; craft-i18n's CI rejects them before they ship. - **Plurals:** forms are listed in CLDR order (zero, one, two, few, many, other) for the categories - whole numbers 0 to 1000 reach, from `intl_pluralrules`; a count in any other category takes the - last form. CLDR's `pt` is Brazilian, so `pt` uses `pt-PT`'s rules (0 is plural) and `pt-br` - `pt`'s. `intl_pluralrules` is pinned to the version craft-i18n's checker uses, and - `plural_form_counts` holds the same counts. + whole numbers 0 to 1000 reach, from ICU4X's CLDR rules; `plural_form_counts` holds the counts + craft-i18n's checker expects. `trn_decimal` picks the form from the digits shown (ICU4X plural + operands), so English `1.0` is plural and French `1,5` singular. A count in a category integers + never reach takes the `few` form if there is one (Russian and Ukrainian decimals, `1,5 файла`), + else the last. CLDR's `pt` is Brazilian, so `pt` uses `pt-PT`'s rules (0 is plural) and `pt-br` + `pt`'s. A language outside the baked data (below) has no plural rules and shows English forms. - **Names:** a language's name comes from its shared catalog's `@id language.name` row, else its code; English is "English". - **Right to left:** `rtl` is true for `ar`, `ckb`, `dv`, `fa`, `he`, `ps`, `sd`, `ug`, `ur` and @@ -74,8 +77,20 @@ The app keeps its `tl!` macro, its language setting (`"auto"` → `I18N.system_l the smallest, sizes round up), and `B` or words for bytes. `measure(lang, value, decimals, "{n} px")` writes any other measure. Unit names are rows in context `unit`, keyed by the English template (`{n} MB` → `{n} Mo`, `{n}%` → `{n} %`) and read with `get`, so a plain `B` row (bold) - is never used; bytes in words are the `@plural` row `{n} byte|{n} bytes`. Numbers are - ASCII digits with a decimal point. + is never used; bytes in words are the `@plural` row `{n} byte|{n} bytes`. Numbers are written + by `I18n::format`. +- **Numbers:** `Format` writes numbers for a `Region` on ICU4X's decimal formatter: `integer` + (`1.234.567`, Indian `12,34,567`), `number(value, decimals)` and `percent(ratio, decimals)` + (`50 %`, Turkish `%50`). It follows the region, not the interface language, with the locale's + digits (`١٬٢٣٤٫٥` in Egypt, `۱٬۲۳۴` in Iran) or, with `Digits::Latin`, `0`–`9`. With Latin + digits, a decimal or grouping separator the region sets replaces the locale's (English in Poland: + `1.234,5`). `I18n::format` is the format `trn`, `file_size` and `measure` use: the system + region's until `I18n::set_format` (the user's region setting or digit choice). +- **CLDR data:** decimal symbols, digits and plural rules (`data/numbers.postcard`, about 6 KB) and + percent patterns (`data/locales.rs`) cover the languages the catalogs and apps ship, with their + regional variants; `data/generate.sh` lists them and rebuilds both from CLDR. Add a language + there when a catalog adds one. The ICU4X code and data add about 90 KB to a stripped release + build and 80 KB to wasm. - **Formatting region:** `Region` holds how numbers, times and measurements are written, apart from the interface language: the number and time locales (BCP 47), metric or US measurement, A4 or Letter, the decimal separator, and the grouping separator and clock when the system sets diff --git a/crates/i18n/data/generate.sh b/crates/i18n/data/generate.sh new file mode 100755 index 000000000..695a698ae --- /dev/null +++ b/crates/i18n/data/generate.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Regenerates the number and plural data in this directory from Unicode CLDR, for the languages +# the craft-i18n catalogs and the apps ship (each with its regional variants). +# +# Needs `cargo install icu4x-datagen --version 2.3.0` (the ICU4X version in Cargo.toml) and +# python3. Downloads CLDR's JSON release once into $CACHE. +set -euo pipefail + +CLDR=48.2.1 +LANGUAGES=(en ar bg cs da de el es et fa fi fr hi hr hu id it ja ka kk ko lt nl pl pt ru sl sr sv te tr uk vi zh) +HERE=$(cd "$(dirname "$0")" && pwd) +CACHE=${CACHE:-${XDG_CACHE_HOME:-$HOME/.cache}/craft-i18n-data} +mkdir -p "$CACHE" + +# `sr-Latn` and `zh-Hant` are named as well: their CLDR parent is the root, not `sr` or `zh`. +TMPDIR=$CACHE icu4x-datagen --format blob --overwrite \ + --cldr-tag "$CLDR" \ + --markers DecimalSymbolsV1 DecimalDigitsV1 PluralsCardinalV1 \ + --locales "${LANGUAGES[@]}" sr-Latn zh-Hant \ + --out "$HERE/numbers.postcard" + +ZIP=$CACHE/icu4x-source-cache/github.com/unicode-org/cldr-json/releases/download/$CLDR/cldr-$CLDR-json-full.zip +python3 -I "$HERE/percent.py" "$ZIP" "$CLDR" "${LANGUAGES[@]}" > "$HERE/locales.rs" diff --git a/crates/i18n/data/locales.rs b/crates/i18n/data/locales.rs new file mode 100644 index 000000000..a2ad6e72b --- /dev/null +++ b/crates/i18n/data/locales.rs @@ -0,0 +1,79 @@ +// @generated by generate.sh from Unicode CLDR 48.2.1 (Unicode License). + +/// The languages the baked number and plural data cover, each with its regional variants. +pub(crate) const LANGUAGES: &[&str] = &["en", "ar", "bg", "cs", "da", "de", "el", "es", "et", "fa", "fi", "fr", "hi", "hr", "hu", "id", "it", "ja", "ka", "kk", "ko", "lt", "nl", "pl", "pt", "ru", "sl", "sr", "sv", "te", "tr", "uk", "vi", "zh"]; + +/// Percent signs around the number (CLDR's standard percent pattern and sign): locale, Latin +/// digits or the locale's own, text before the number, text after it. A locale missing here +/// takes its parent's by dropping trailing subtags, then `{n}%`. +pub(crate) const PERCENT: &[(&str, bool, &str, &str)] = &[ + ("ar", true, "", "\u{200e}%\u{200e}"), + ("ar-BH", false, "", "\u{66a}\u{61c}"), + ("ar-DJ", false, "", "\u{66a}\u{61c}"), + ("ar-EG", false, "", "\u{66a}\u{61c}"), + ("ar-ER", false, "", "\u{66a}\u{61c}"), + ("ar-IL", false, "", "\u{66a}\u{61c}"), + ("ar-IQ", false, "", "\u{66a}\u{61c}"), + ("ar-JO", false, "", "\u{66a}\u{61c}"), + ("ar-KM", false, "", "\u{66a}\u{61c}"), + ("ar-KW", false, "", "\u{66a}\u{61c}"), + ("ar-LB", false, "", "\u{66a}\u{61c}"), + ("ar-MR", false, "", "\u{66a}\u{61c}"), + ("ar-OM", false, "", "\u{66a}\u{61c}"), + ("ar-PS", false, "", "\u{66a}\u{61c}"), + ("ar-QA", false, "", "\u{66a}\u{61c}"), + ("ar-SA", false, "", "\u{66a}\u{61c}"), + ("ar-SA", true, "", "\u{66a}"), + ("ar-SD", false, "", "\u{66a}\u{61c}"), + ("ar-SO", false, "", "\u{66a}\u{61c}"), + ("ar-SO", true, "", "\u{66a}"), + ("ar-SS", false, "", "\u{66a}\u{61c}"), + ("ar-SY", false, "", "\u{66a}\u{61c}"), + ("ar-TD", false, "", "\u{66a}\u{61c}"), + ("ar-YE", false, "", "\u{66a}\u{61c}"), + ("cs", true, "", "\u{a0}%"), + ("da", true, "", "\u{a0}%"), + ("de", true, "", "\u{a0}%"), + ("de-CH", true, "", "%"), + ("de-LI", true, "", "%"), + ("en-AT", true, "", "\u{a0}%"), + ("en-DE", true, "", "\u{a0}%"), + ("en-DK", true, "", "\u{a0}%"), + ("en-FI", true, "", "\u{a0}%"), + ("en-NO", true, "", "\u{a0}%"), + ("en-SE", true, "", "\u{a0}%"), + ("es", true, "", "\u{a0}%"), + ("es-419", true, "", "%"), + ("es-AR", true, "", "%"), + ("es-BO", true, "", "%"), + ("es-BR", true, "", "%"), + ("es-BZ", true, "", "%"), + ("es-CL", true, "", "%"), + ("es-CO", true, "", "%"), + ("es-CR", true, "", "%"), + ("es-CU", true, "", "%"), + ("es-DO", true, "", "%"), + ("es-EC", true, "", "%"), + ("es-GT", true, "", "%"), + ("es-HN", true, "", "%"), + ("es-MX", true, "", "%"), + ("es-NI", true, "", "%"), + ("es-PA", true, "", "%"), + ("es-PE", true, "", "%"), + ("es-PR", true, "", "%"), + ("es-PY", true, "", "%"), + ("es-SV", true, "", "%"), + ("es-US", true, "", "%"), + ("es-UY", true, "", "%"), + ("es-VE", true, "", "%"), + ("fa", false, "", "\u{66a}"), + ("fi", true, "", "\u{a0}%"), + ("fr", true, "", "\u{a0}%"), + ("fr-CH", true, "", "%"), + ("hr", true, "", "\u{a0}%"), + ("lt", true, "", "\u{a0}%"), + ("ru", true, "", "\u{a0}%"), + ("sl", true, "", "\u{a0}%"), + ("sv", true, "", "\u{a0}%"), + ("tr", true, "%", ""), +]; diff --git a/crates/i18n/data/numbers.postcard b/crates/i18n/data/numbers.postcard new file mode 100644 index 0000000000000000000000000000000000000000..53764d4cc9c73c84627d11699de9e5f564923c92 GIT binary patch literal 5664 zcmcJTTTola8OP7rBgEC%*sdMNvSJg!iH!)()wy&WA%sMP5FoB@XHwZvqacJw2-j|> zjbwwtsgtblCP^FLu&gSSD1_J!w6zaVr&(ZF0zuoLdJZzYsb#kwjZ{;pX=~= zcl)~iJ$ru4aHVP0D;<|MGiU1x>At5;&+h1*nlIRDEcfw}`5k(Lpmq6NJ9zPYm$9te zT=|Tx%I>JCb=J2C@k>;$EZq|wN6!D@Z)vOVfAW+&+TA zetS)cGo{9uQZsEVcQ{+}@*gTsY5F!QUn{g>#ST~&vkNT1LJzGn{V}ayZr)V%dHl+%IwyQ@(wG=RKpzYO^=D#2a&! zdcE4p3#QB#n;jjMKIk`Tu5&UCFMy$&#-#0a6jk|5F^k^E?*>+5cQ&-dhRa>HT!~hc zh^dS%+mNJuUT~w_TxG9z)W_$h&TIqPbdzR#4aQRZDRX5tMO33qn~c^Uaekw{P_tUMlMi zS`WR<9xzt$Qhn@^Z{kIUkC7WnBj!jO)zq@GbSurH=cj!y4h~(4Oq(kzpQ%#zuZYKg zZWznyH-U~@Mph1{8mnJRcf2ea-KUKe)it$?`_=OzJ1=VBIvi%~Y^}0dM99Td}!bE!FCC123xJMF>CT_W663uUX4C(285z_PF@^Dq8mj zzR^%-y{O zuElC_Z@{Ai*}`MJ@?RA%?7_%WWj1>QMrc773)=A;Q_c&Fyl~Rv+rvuveF#5YW~#C` zOWAAnMy}iE^BC;dZ(1ncUuDe_Ds9RUK*={5WMhTY%r{l?O>&zrlW)-b+dMt|UdSfA z>EsSC25IH_Zmw&`0n!^X+g)CFYuiqK|D@IKtnHL-F1(KPiCVAR?Z3w$TkT71$<;Wp z3(jqU>@CTSvZG~Dz~nYd@`pd)>2CpzHs`Jln@S&i^1ItAZQFma%k6u4SFrmK-udE- z{ZsTZ7nv07-``KiUt|aSdnet#9z47h#NLa60at)FT4cdsz~ATT$EJxd95B?>5A+GJQ>WaRAYb?+mRm;$N&Za5wk~_!O8$ zK{yR2z)euY1mQe*RuY7d!C{Rc+yJfUcolpI{sA(zg0K-dz>mP|;0E{-tWFn%ZD1F8 z8C(W)AU#769so_?c@P30fq#R0G6lf`_JIia3-}U=6)GMey(Ea+; zWlIyE<-`*nu;|!@jA0-Qa6MiTAN5-vV0F>}%7`?oGUB&9ke(qg*O2E#1_Q15KnY$! zro4Leh!ff_UHXCKLZ?V}?Hv2Z49b>cau-VIO_6nQ5%H{%WF39Hgv$+Fou9waA zvAKh6uAj{fu(?Ch&7q4R1TKLv_!WqNH^4CXH5id@hTw(Zh2VwYh2VwYh2VwYh2VwY zh2e$ah2e$ah2e$ah2e$ah2e$aMc_r?Mc_r?Mc_r?Mc_r?Mc_r?4Z|CTHwt`fH$Hx^*QW-Fo*h?uVq$UwswN$EDBvhVl6T z?h)zM)n7jj5=Br=!1*iy8v7d?q zRP<8ON5w%Z`l%S8;t+}Hnh#bB-|GA3yZn~OKnw_QmR z7ivnru7)L?l9XO*O_EbgUoa)M5V@pSiJV%KG3?v1N*- zh4oorB(5!!B#kCb``N(J&Bf|k?oh|_)Etx)$G^H^*gU0ky3#o@@SQ5zN=ee<3;rjs z89sS!^Gsys4f-FXh_F(l$;ime`YiC00W3`6w)%K7Byp8io0XNF0~do*k3`E_YRSo6 MXi-POcP0$ ... +""" + +import json +import re +import sys +import zipfile + + +def rust(s): + return '"' + "".join(c if " " <= c <= "~" and c not in '"\\' else f"\\u{{{ord(c):x}}}" for c in s) + '"' + + +def affixes(numbers, numsys): + pattern = numbers[f"percentFormats-numberSystem-{numsys}"]["standard"].split(";")[0].replace("'", "") + sign = numbers[f"symbols-numberSystem-{numsys}"]["percentSign"] + number = re.search(r"[#0,.]+", pattern) + return pattern[: number.start()].replace("%", sign), pattern[number.end() :].replace("%", sign) + + +def main(): + path, version, languages = sys.argv[1], sys.argv[2], sys.argv[3:] + found = {} + with zipfile.ZipFile(path) as z: + for name in z.namelist(): + m = re.fullmatch(r"cldr-numbers-full/main/([^/]+)/numbers\.json", name) + if not m or m.group(1).split("-")[0] not in languages: + continue + locale = m.group(1) + numbers = json.loads(z.read(name))["main"][locale]["numbers"] + found[(locale, True)] = affixes(numbers, "latn") + default = numbers["defaultNumberingSystem"] + if default != "latn": + found[(locale, False)] = affixes(numbers, default) + + # Kept only where the lookup by dropping trailing subtags would find something else. + kept = {} + for (locale, latin), value in sorted(found.items(), key=lambda e: (e[0][0].count("-"), e[0])): + parts = locale.split("-") + inherited = ("", "%") + for n in range(len(parts) - 1, 0, -1): + if ("-".join(parts[:n]), latin) in kept: + inherited = kept[("-".join(parts[:n]), latin)] + break + if value != inherited: + kept[(locale, latin)] = value + + print(f"// @generated by generate.sh from Unicode CLDR {version} (Unicode License).") + print() + print("/// The languages the baked number and plural data cover, each with its regional variants.") + print("pub(crate) const LANGUAGES: &[&str] = &[" + ", ".join(rust(l) for l in languages) + "];") + print() + print("/// Percent signs around the number (CLDR's standard percent pattern and sign): locale, Latin") + print("/// digits or the locale's own, text before the number, text after it. A locale missing here") + print("/// takes its parent's by dropping trailing subtags, then `{n}%`.") + print("pub(crate) const PERCENT: &[(&str, bool, &str, &str)] = &[") + for (locale, latin), (before, after) in sorted(kept.items()): + print(f" ({rust(locale)}, {str(latin).lower()}, {rust(before)}, {rust(after)}),") + print("];") + + +main() diff --git a/crates/i18n/src/format.rs b/crates/i18n/src/format.rs new file mode 100644 index 000000000..07dfd70c8 --- /dev/null +++ b/crates/i18n/src/format.rs @@ -0,0 +1,286 @@ +//! Numbers, percentages and counts written for a formatting [`Region`], on ICU4X's decimal +//! formatter with CLDR data for the shipped languages (`data/generate.sh`). + +use std::fmt; + +use fixed_decimal::{Decimal, Sign}; +use icu_decimal::{DecimalFormatter, parts}; +use icu_locale_core::Locale; +use icu_locale_core::extensions::unicode::{key, value}; +use writeable::{Part, PartsWrite, Writeable}; + +use crate::Region; +use crate::data::{self, PERCENT}; + +/// Which digits numbers are written in. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub enum Digits { + /// The locale's own: Arabic-Indic in Egypt (`١٢٣`), Persian in Iran (`۱۲۳`), else `0`–`9`. + #[default] + Locale, + /// `0`–`9` everywhere, for users who read Arabic or Persian with Latin digits. + Latin, +} + +/// How numbers are written in a formatting region: digits, decimal and grouping separators, +/// grouping sizes (`1,234,567`, Indian `12,34,567`) and the percent sign. Build it once per region +/// and keep it; [`crate::I18n`] holds one for counts, sizes and measures. +#[derive(Debug)] +pub struct Format { + /// `None` only if the data doesn't load: numbers are then written as Rust writes them. + decimal: Option, + /// The region's separators where the system set them apart from the locale's. + group: Option, + point: Option, + percent: (&'static str, &'static str), +} + +impl Format { + /// The format for `region`'s number locale. With Latin digits, a decimal or grouping separator + /// the region sets (Windows' and macOS's own settings, English in continental Europe) replaces + /// the locale's. + pub fn new(region: &Region, digits: Digits) -> Format { + let mut locale = Locale::try_from_str(®ion.numbers).unwrap_or(Locale::UNKNOWN); + if digits == Digits::Latin { + locale.extensions.unicode.keywords.set(key!("nu"), value!("latn")); + } + let decimal = data::provider().and_then(|p| DecimalFormatter::try_new_with_buffer_provider(p, (&locale).into(), Default::default()).ok()); + let mut format = Format { decimal, group: None, point: None, percent: ("", "%") }; + // The locale's own separators and digits, read from a number that shows them all. + let probe = format.parts(&Decimal::try_from_str("-1234567.5").unwrap_or_default()); + let latin = probe.integer.starts_with(|c: char| c.is_ascii_digit()); + if latin { + let point = region.decimal.to_string(); + if probe.point != point { + // `1,234.5` with a decimal comma becomes `1.234,5`, never `1,234,5`. + if probe.group == point && region.grouping.is_none() { + format.group = Some(probe.point.clone()); + } + format.point = Some(point); + } + if let Some(g) = region.grouping.map(String::from).filter(|g| *g != probe.group) { + format.group = Some(g); + } + } + format.percent = percent_affixes(®ion.numbers, latin); + format + } + + /// The format for a locale tag (`de-CH`, `ar-EG`), with the locale's own digits. + pub fn from_tag(tag: &str) -> Format { + Format::new(&Region::from_tag(tag), Digits::Locale) + } + + /// A whole number: `1,234,567`, `1.234.567`, `١٬٢٣٤٬٥٦٧`. + pub fn integer(&self, n: impl Into) -> String { + self.write(&Decimal::from(n.into())) + } + + /// `value` rounded to `decimals` digits after the decimal separator. + /// Infinities and NaN are written as Rust writes them. + pub fn number(&self, value: f64, decimals: usize) -> String { + match to_decimal(value, decimals) { + Some(d) => self.write(&d), + None => value.to_string(), + } + } + + /// `ratio` as a percentage with `decimals` digits after the decimal separator: `0.5` is + /// `50%`, `50 %` (French, German), `%50` (Turkish), `٥٠٪؜` (Egyptian Arabic). + pub fn percent(&self, ratio: f64, decimals: usize) -> String { + let Some(d) = to_decimal(ratio * 100.0, decimals) else { + return format!("{}%", ratio * 100.0); + }; + let p = self.parts(&d); + format!("{}{}{}{}", p.sign, self.percent.0, p.text, self.percent.1) + } + + /// `d` as text, the sign in place. + pub(crate) fn write(&self, d: &Decimal) -> String { + let p = self.parts(d); + p.sign + &p.text + } + + fn parts(&self, d: &Decimal) -> Written { + let mut sink = Sink { group: self.group.as_deref(), point: self.point.as_deref(), into: Target::Text, w: Written::default() }; + let written = match &self.decimal { + Some(f) => f.format(d).write_to_parts(&mut sink), + None => fmt::Write::write_str(&mut sink, &d.to_string()), + }; + if written.is_err() { + return Written { text: d.to_string(), ..Written::default() }; + } + sink.w + } +} + +/// A formatted number taken apart: its sign, the rest, and the separators and integer digits as +/// the locale wrote them. +#[derive(Default)] +struct Written { + sign: String, + text: String, + integer: String, + group: String, + point: String, +} + +#[derive(Clone, Copy, PartialEq)] +enum Target { + Text, + Sign, + /// A separator the region replaces; the locale's goes to `group` or `point` only. + Replaced, +} + +/// Writes the formatter's parts into [`Written`], swapping in the region's separators. +struct Sink<'a> { + group: Option<&'a str>, + point: Option<&'a str>, + into: Target, + w: Written, +} + +impl fmt::Write for Sink<'_> { + fn write_str(&mut self, s: &str) -> fmt::Result { + match self.into { + Target::Text => self.w.text.push_str(s), + Target::Sign => self.w.sign.push_str(s), + Target::Replaced => {} + } + Ok(()) + } +} + +impl PartsWrite for Sink<'_> { + type SubPartsWrite = Self; + + fn with_part(&mut self, part: Part, mut f: impl FnMut(&mut Self) -> fmt::Result) -> fmt::Result { + let outer = self.into; + let start = self.w.text.len(); + let replacement = match part { + p if p == parts::GROUP => self.group, + p if p == parts::DECIMAL => self.point, + _ => None, + }; + if let Some(r) = replacement { + self.w.text.push_str(r); + self.into = Target::Replaced; + } else if part == parts::MINUS_SIGN || part == parts::PLUS_SIGN { + self.into = Target::Sign; + } + let result = f(self); + self.into = outer; + if replacement.is_none() { + let written = self.w.text.get(start..).unwrap_or_default(); + let slot = match part { + p if p == parts::GROUP => &mut self.w.group, + p if p == parts::DECIMAL => &mut self.w.point, + p if p == parts::INTEGER => &mut self.w.integer, + _ => return result, + }; + if slot.is_empty() { + *slot = written.to_owned(); + } + } + result + } +} + +/// `value` rounded to `decimals` digits after the point; `None` for infinities and NaN. A value +/// that rounds to zero has no sign. +pub(crate) fn to_decimal(value: f64, decimals: usize) -> Option { + if !value.is_finite() { + return None; + } + let mut d = Decimal::try_from_str(&format!("{value:.decimals$}")).ok()?; + if d.absolute.is_zero() { + d.sign = Sign::None; + } + Some(d) +} + +/// The percent affixes for a number locale (`ar-EG`), found by dropping trailing subtags. +fn percent_affixes(tag: &str, latin: bool) -> (&'static str, &'static str) { + let mut tag = tag; + loop { + if let Some((_, _, before, after)) = PERCENT.iter().find(|(l, lat, _, _)| *lat == latin && l.eq_ignore_ascii_case(tag)) { + return (before, after); + } + match tag.rsplit_once('-') { + Some((parent, _)) => tag = parent, + None => return ("", "%"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn latin(tag: &str) -> Format { + Format::new(&Region::from_tag(tag), Digits::Latin) + } + + #[test] + fn locale_digits_and_separators() { + for (tag, integer, number) in [ + ("en", "1,234,567", "1,234.5"), + ("ja", "1,234,567", "1,234.5"), + ("de", "1.234.567", "1.234,5"), + ("fr", "1\u{202f}234\u{202f}567", "1\u{202f}234,5"), + ("hi", "12,34,567", "1,234.5"), + ("ar-EG", "١٬٢٣٤٬٥٦٧", "١٬٢٣٤٫٥"), + ("fa", "۱٬۲۳۴٬۵۶۷", "۱٬۲۳۴٫۵"), + ] { + let f = Format::from_tag(tag); + assert_eq!((f.integer(1_234_567), f.number(1234.5, 1)), (integer.into(), number.into()), "{tag}"); + } + // CLDR writes Arabic without a region in Latin digits. + assert_eq!(Format::from_tag("ar").number(1234.5, 1), "1,234.5"); + } + + #[test] + fn forced_latin_digits() { + assert_eq!(latin("ar-EG").number(1234.5, 1), "1,234.5"); + assert_eq!(latin("fa").integer(1_234_567), "1,234,567"); + assert_eq!(latin("de").number(1234.5, 1), "1.234,5", "a Latin locale is unchanged"); + } + + #[test] + fn region_separators() { + // English in Poland writes a decimal comma; the grouping moves out of its way. + assert_eq!(Format::from_tag("en-PL").number(1234.5, 1), "1.234,5"); + let mut region = Region::from_tag("de-DE"); + region.grouping = Some('\u{a0}'); + assert_eq!(Format::new(®ion, Digits::Locale).number(1234.5, 1), "1\u{a0}234,5"); + region.decimal = '.'; + assert_eq!(Format::new(®ion, Digits::Locale).number(1234.5, 1), "1\u{a0}234.5"); + // A system's separators don't apply to Arabic-Indic digits. + let mut region = Region::from_tag("ar-EG"); + region.grouping = Some(','); + assert_eq!(Format::new(®ion, Digits::Locale).number(1234.5, 1), "١٬٢٣٤٫٥"); + } + + #[test] + fn percentages() { + for (tag, want) in + [("en", "50%"), ("de", "50\u{a0}%"), ("fr", "12,5\u{a0}%"), ("tr", "%50"), ("ar-EG", "٥٠٪\u{61c}"), ("fa", "۵۰٪"), ("hi", "50%")] + { + let decimals = if tag == "fr" { 1 } else { 0 }; + let ratio = if tag == "fr" { 0.125 } else { 0.5 }; + assert_eq!(Format::from_tag(tag).percent(ratio, decimals), want, "{tag}"); + } + assert_eq!(Format::from_tag("tr").percent(-0.2, 0), "-%20", "the sign leads the pattern"); + assert_eq!(latin("fa").percent(0.5, 0), "50%"); + } + + #[test] + fn signs_and_odd_values() { + let en = Format::from_tag("en"); + assert_eq!(en.number(-1234.5, 0), "-1,234"); + assert_eq!(en.number(-0.04, 1), "0.0", "no sign on zero"); + assert_eq!(en.number(f64::INFINITY, 1), "inf"); + assert_eq!(en.integer(-5_i64), "-5"); + } +} diff --git a/crates/i18n/src/lib.rs b/crates/i18n/src/lib.rs index 73e794f9d..910041edf 100644 --- a/crates/i18n/src/lib.rs +++ b/crates/i18n/src/lib.rs @@ -6,17 +6,41 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod catalog; -mod size; +mod format; mod region; +mod size; + +/// The CLDR data for the shipped languages, from `data/generate.sh`. +mod data { + use std::sync::OnceLock; + + use icu_locale_fallback::LocaleFallbacker; + use icu_provider_adapters::fallback::LocaleFallbackProvider; + use icu_provider_blob::BlobDataProvider; + + include!("../data/locales.rs"); + + /// The number and plural data, read once; `None` if the blob doesn't load. + pub(crate) fn provider() -> Option<&'static LocaleFallbackProvider> { + static PROVIDER: OnceLock>> = OnceLock::new(); + PROVIDER + .get_or_init(|| { + let blob = BlobDataProvider::try_new_from_static_blob(include_bytes!("../data/numbers.postcard")).ok()?; + Some(LocaleFallbackProvider::new(blob, LocaleFallbacker::new().static_to_owned())) + }) + .as_ref() + } +} use std::collections::{BTreeMap, BTreeSet, HashMap}; -use std::sync::{Mutex, OnceLock, PoisonError}; +use std::sync::{Arc, Mutex, OnceLock, PoisonError, RwLock}; -use intl_pluralrules::{PluralCategory, PluralRuleType, PluralRules}; -use unic_langid::LanguageIdentifier; +use icu_locale_core::Locale as IcuLocale; +use icu_plurals::{PluralCategory, PluralOperands, PluralRules}; -pub use size::{FileSize, SizeUnit}; +pub use format::{Digits, Format}; pub use region::{HourCycle, Measurement, Paper, Region}; +pub use size::{FileSize, SizeUnit}; /// The catalogs an app embeds, one entry per locale: (locale, shared catalog, the app's catalog). /// A missing catalog is `""`. @@ -57,6 +81,8 @@ pub struct I18n { pseudo: Mutex>, /// [`I18n::system_lang`], read once. system: OnceLock, + /// [`I18n::format`]; the system region's until [`I18n::set_format`]. + format: RwLock>>, } impl I18n { @@ -107,7 +133,7 @@ impl I18n { l.chain = chain; } } - (I18n { locales, pseudo: Mutex::default(), system: OnceLock::new() }, problems) + (I18n { locales, pseudo: Mutex::default(), system: OnceLock::new(), format: RwLock::default() }, problems) } /// The language with this code (any case): `en`, an offered locale, or in debug builds @@ -163,9 +189,25 @@ impl I18n { self.find(lang, &[("@id", id), ("", label)]).unwrap_or(label) } - /// A count's wording in `lang`, `{n}` filled in. `one` and `other` are the English forms; - /// they are also the key of the `@plural` row, and the fallback. + /// A count's wording in `lang`, `{n}` filled in as [`I18n::format`] writes numbers. `one` and + /// `other` are the English forms; they are also the key of the `@plural` row, and the fallback. pub fn trn(&self, lang: Lang, n: u64, one: &str, other: &str) -> String { + let format = self.format(); + self.plural(lang, PluralOperands::from(n), n == 1, &format.integer(n), one, other) + } + + /// [`I18n::trn`] for a count with `decimals` digits after the decimal separator (`1.5 files`). + /// The plural form follows the digits shown, so `1.0` takes English `other`. + pub fn trn_decimal(&self, lang: Lang, n: f64, decimals: usize, one: &str, other: &str) -> String { + let format = self.format(); + let Some(d) = format::to_decimal(n, decimals) else { + return fmt(other, &[("n", &n.to_string())]); + }; + let english_one = d.to_string() == "1"; + self.plural(lang, PluralOperands::from(&d), english_one, &format.write(&d), one, other) + } + + fn plural(&self, lang: Lang, operands: PluralOperands, english_one: bool, n: &str, one: &str, other: &str) -> String { let key = format!("{one}|{other}"); let chain = self.locale(lang).map_or(&[][..], |l| l.chain.as_slice()); let text = chain @@ -174,19 +216,40 @@ impl I18n { let locale = self.locales.get(i)?; let forms = locale.rows.get("@plural")?.get(&key)?; let (rules, categories) = locale.plural.as_ref()?; - let category = rules.select(n).ok()?; - // A category whole numbers up to 1000 never reach (French `many`) takes the last form. - let index = categories.iter().position(|c| *c == category).unwrap_or(categories.len().saturating_sub(1)); + let category = rules.category_for(operands); + // A category whole numbers up to 1000 never reach has no form of its own. Russian + // and Ukrainian decimals (`other`) take the `few` form (`1,5 файла`); any other + // (French `many` for a million) takes the last. + let index = categories + .iter() + .position(|c| *c == category) + .or_else(|| categories.iter().position(|c| *c == PluralCategory::Few)) + .unwrap_or(categories.len().saturating_sub(1)); forms.split('|').nth(index) }) - .unwrap_or(if n == 1 { one } else { other }); - let text = fmt(text, &[("n", &n.to_string())]); + .unwrap_or(if english_one { one } else { other }); + let text = fmt(text, &[("n", n)]); if lang == Lang::PSEUDO { return marked(&text); } text } + /// How counts, file sizes and measures write numbers: [`Region::system`]'s format with the + /// locale's digits until [`I18n::set_format`]. + pub fn format(&self) -> Arc { + if let Some(f) = self.format.read().unwrap_or_else(PoisonError::into_inner).as_ref() { + return Arc::clone(f); + } + let mut slot = self.format.write().unwrap_or_else(PoisonError::into_inner); + Arc::clone(slot.get_or_insert_with(|| Arc::new(Format::new(&Region::system(), Digits::Locale)))) + } + + /// Write numbers as `format` does from now on: the user's region setting, or Latin digits. + pub fn set_format(&self, format: Format) { + *self.format.write().unwrap_or_else(PoisonError::into_inner) = Some(Arc::new(format)); + } + /// The languages to offer: English first, then the offered locales by code. Never the /// pseudo locale. pub fn languages(&self) -> Vec { @@ -371,15 +434,17 @@ fn bare(code: &str) -> Locale { /// `code`'s CLDR cardinal rules and the categories whole numbers 0..=1000 reach, in CLDR order /// (zero, one, two, few, many, other): one `@plural` form each. Regional and scripted codes fall /// back to the language's rules. CLDR's `pt` is Brazilian (0 is singular), so Portuguese other -/// than `pt-br` takes `pt-PT`'s. +/// than `pt-br` takes `pt-PT`'s. `None` for a language the baked data doesn't cover. fn plural_rules(code: &str) -> Option<(PluralRules, Vec)> { let code = if code == "pt" || (code.starts_with("pt-") && code != "pt-br") { "pt-pt" } else { code }; - let id: LanguageIdentifier = code.parse().ok()?; - let language = LanguageIdentifier::from_parts(id.language, None, None, &[]); - let rules = PluralRules::create(id, PluralRuleType::CARDINAL).or_else(|_| PluralRules::create(language, PluralRuleType::CARDINAL)).ok()?; + let locale = IcuLocale::try_from_str(code).ok()?; + if !data::LANGUAGES.contains(&locale.id.language.as_str()) { + return None; + } + let rules = PluralRules::try_new_cardinal_with_buffer_provider(data::provider()?, (&locale).into()).ok()?; let mut categories = Vec::new(); for n in 0..=1000_u32 { - let c = rules.select(n).ok()?; + let c = rules.category_for(n); if !categories.contains(&c) { categories.push(c); } @@ -390,12 +455,12 @@ fn plural_rules(code: &str) -> Option<(PluralRules, Vec)> { fn rank(c: &PluralCategory) -> u8 { match c { - PluralCategory::ZERO => 0, - PluralCategory::ONE => 1, - PluralCategory::TWO => 2, - PluralCategory::FEW => 3, - PluralCategory::MANY => 4, - PluralCategory::OTHER => 5, + PluralCategory::Zero => 0, + PluralCategory::One => 1, + PluralCategory::Two => 2, + PluralCategory::Few => 3, + PluralCategory::Many => 4, + PluralCategory::Other => 5, } } @@ -486,6 +551,7 @@ mod tests { ("fr", "@plural\t{n} page|{n} pages\t{n} page|{n} pages\n", ""), ("ru", "@plural\t{n} file|{n} files\t{n} файл|{n} файла|{n} файлов\n", ""), ("ja", "@plural\t{n} file|{n} files\t{n} 個\n", ""), + ("ar", "@plural\t{n} file|{n} files\t{n} ملفات|{n} ملف واحد|{n} ملفان|{n} ملفات|{n} ملفًا|{n} ملف\n", ""), ]; #[test] @@ -528,7 +594,39 @@ mod tests { #[test] fn beyond_sweep() { let i = all(P).0; - assert_eq!(i.trn(lang(&i, "fr"), 1_000_000, "{n} page", "{n} pages"), "1000000 pages"); + i.set_format(Format::from_tag("fr")); + assert_eq!(i.trn(lang(&i, "fr"), 1_000_000, "{n} page", "{n} pages"), "1\u{202f}000\u{202f}000 pages"); + } + + #[test] + fn counts_follow_the_region() { + let i = all(P).0; + i.set_format(Format::from_tag("ar-EG")); + assert_eq!(i.trn(lang(&i, "ar"), 1234, "{n} file", "{n} files"), "١٬٢٣٤ ملفًا"); + i.set_format(Format::new(&Region::from_tag("ar-EG"), Digits::Latin)); + assert_eq!(i.trn(lang(&i, "ar"), 1234, "{n} file", "{n} files"), "1,234 ملفًا"); + // An English interface with a German region. + i.set_format(Format::from_tag("de-DE")); + assert_eq!(i.trn(Lang::EN, 12_345, "{n} file", "{n} files"), "12.345 files"); + } + + #[test] + fn decimal_counts() { + let i = all(P).0; + i.set_format(Format::from_tag("en")); + assert_eq!(i.trn_decimal(Lang::EN, 1.5, 1, "{n} file", "{n} files"), "1.5 files"); + assert_eq!(i.trn_decimal(Lang::EN, 1.0, 1, "{n} file", "{n} files"), "1.0 files", "visible decimals are plural"); + assert_eq!(i.trn_decimal(Lang::EN, 1.0, 0, "{n} file", "{n} files"), "1 file"); + // Russian decimals are `other`, which integers never reach: the `few` form. + i.set_format(Format::from_tag("ru")); + let ru = lang(&i, "ru"); + assert_eq!(i.trn_decimal(ru, 1.5, 1, "{n} file", "{n} files"), "1,5 файла"); + assert_eq!(i.trn_decimal(ru, 21.0, 0, "{n} file", "{n} files"), "21 файл"); + // Arabic decimals are `other`. + i.set_format(Format::from_tag("ar-EG")); + let ar = lang(&i, "ar"); + assert_eq!(i.trn_decimal(ar, 2.5, 1, "{n} file", "{n} files"), "٢٫٥ ملف"); + assert_eq!(i.trn_decimal(ar, 3.0, 0, "{n} file", "{n} files"), "٣ ملفات"); } static L: &Embedded = diff --git a/crates/i18n/src/size.rs b/crates/i18n/src/size.rs index 1466672b1..4dad2b57c 100644 --- a/crates/i18n/src/size.rs +++ b/crates/i18n/src/size.rs @@ -103,8 +103,8 @@ impl FileSize { self } - /// The unit `bytes` is written in and the number in it, in English. - fn parts(&self, bytes: u64) -> (SizeUnit, String) { + /// The unit `bytes` is written in, the number in it and its digits after the point. + fn parts(&self, bytes: u64) -> (SizeUnit, f64, usize) { let (smallest, largest) = (self.smallest.min(self.largest), self.largest); let mut unit = smallest; for &u in SizeUnit::ALL.iter().filter(|u| (smallest..=largest).contains(u)) { @@ -128,7 +128,7 @@ impl FileSize { unit = next; continue; } - return (unit, number(rounded, usize::from(decimals))); + return (unit, rounded, usize::from(decimals)); } } @@ -153,19 +153,21 @@ impl FileSize { } impl I18n { - /// `bytes` as a file size in `lang`, written the way `style` says (`1.5 MB`, `512 bytes`). + /// `bytes` as a file size in `lang`, written the way `style` says (`1.5 MB`, `512 bytes`), the + /// number as [`I18n::format`] writes it (`1,5 Mo`). pub fn file_size(&self, lang: Lang, bytes: u64, style: FileSize) -> String { - let (unit, n) = style.parts(bytes); + let (unit, n, decimals) = style.parts(bytes); if unit == SizeUnit::Byte && !style.byte_symbol { return self.trn(lang, bytes, "{n} byte", "{n} bytes"); } - self.unit_text(lang, style.template(unit), &n) + self.unit_text(lang, style.template(unit), &self.format().number(n, decimals)) } - /// `value` with `decimals` digits after the decimal point in the unit template `unit`, an - /// English catalog key with the number as `{n}` (`{n} px`, `{n}%`, `{n} dB`). + /// `value` with `decimals` digits after the decimal separator in the unit template `unit`, an + /// English catalog key with the number as `{n}` (`{n} px`, `{n} dB`), the number as + /// [`I18n::format`] writes it. Percentages are [`crate::Format::percent`]. pub fn measure(&self, lang: Lang, value: f64, decimals: usize, unit: &str) -> String { - self.unit_text(lang, unit, &number(value, decimals)) + self.unit_text(lang, unit, &self.format().number(value, decimals)) } fn unit_text(&self, lang: Lang, template: &str, n: &str) -> String { @@ -173,17 +175,10 @@ impl I18n { } } -/// `value` with `decimals` digits after the decimal point, in ASCII digits with a decimal point. -/// The one place sizes and measures write numbers, for craft-i18n's locale number formatting -/// (digits, separators) to take over. -fn number(value: f64, decimals: usize) -> String { - format!("{value:.decimals$}") -} - #[cfg(test)] mod tests { use super::*; - use crate::Embedded; + use crate::{Embedded, Format}; static E: &Embedded = &[( "fr", @@ -193,12 +188,14 @@ mod tests { fn en(bytes: u64, style: FileSize) -> String { let (i18n, _) = I18n::new(&[], &[]); + i18n.set_format(Format::from_tag("en-US")); i18n.file_size(Lang::EN, bytes, style) } #[test] fn binary_decimal_and_iec_steps() { assert_eq!(en(512, FileSize::BINARY), "512 bytes"); + assert_eq!(en(1023, FileSize::BINARY), "1,023 bytes"); assert_eq!(en(1, FileSize::BINARY), "1 byte"); assert_eq!(en(1536, FileSize::BINARY), "1.5 KB"); assert_eq!(en(1536, FileSize::IEC), "1.5 KiB"); @@ -221,7 +218,7 @@ mod tests { assert_eq!(en(640_400, style), "640 KB"); assert_eq!(en(3_400_000, style), "3.4 MB"); assert_eq!(en(12_600_000, style), "13 MB"); - assert_eq!(en(5_000_000_000, style), "5000 MB"); + assert_eq!(en(5_000_000_000, style), "5,000 MB"); // Only sizes below the smallest unit round up. assert_eq!(en(1025, FileSize::BINARY.smallest(SizeUnit::Kilo).unit_decimals(SizeUnit::Kilo, 0)), "1 KB"); assert_eq!(en(100, FileSize::BINARY.smallest(SizeUnit::Kilo).unit_decimals(SizeUnit::Kilo, 0)), "1 KB"); @@ -232,11 +229,21 @@ mod tests { let (i18n, problems) = I18n::new(E, &["fr"]); assert!(problems.is_empty(), "{problems:?}"); let fr = i18n.from_code("fr").unwrap(); - assert_eq!(i18n.file_size(fr, 1536, FileSize::BINARY), "1.5 Ko"); + i18n.set_format(Format::from_tag("fr-FR")); + assert_eq!(i18n.file_size(fr, 1536, FileSize::BINARY), "1,5 Ko"); assert_eq!(i18n.file_size(fr, 512, FileSize::BINARY), "512 octets"); assert_eq!(i18n.file_size(fr, 512, FileSize::BINARY.byte_symbol()), "512 o", "not the plain row B (bold)"); - assert_eq!(i18n.file_size(fr, 3 << 30, FileSize::BINARY), "3.0 GB", "a missing row is English"); + assert_eq!(i18n.file_size(fr, 3 << 30, FileSize::BINARY), "3,0 GB", "a missing row is English"); assert_eq!(i18n.measure(fr, 50.0, 0, "{n}%"), "50 %"); - assert_eq!(i18n.measure(Lang::EN, 12.34, 1, "{n} px"), "12.3 px"); + assert_eq!(i18n.measure(Lang::EN, 12.34, 1, "{n} px"), "12,3 px", "numbers follow the region, not the language"); + } + + #[test] + fn locale_digits() { + let (i18n, _) = I18n::new(&[], &[]); + i18n.set_format(Format::from_tag("ar-EG")); + assert_eq!(i18n.file_size(Lang::EN, 1536, FileSize::BINARY), "١٫٥ KB"); + i18n.set_format(Format::from_tag("hi")); + assert_eq!(i18n.file_size(Lang::EN, 1_234_567, FileSize::DECIMAL.largest(SizeUnit::Byte).byte_symbol()), "12,34,567 B"); } } From 75b97a9192da103a0e0c6f782de307b42b7b9ec1 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 05:42:51 +0000 Subject: [PATCH 08/43] i18n: non_exhaustive Script and Han; regions that imply a script pa-PK, uz-AF and az-IR are written in Arabic and sd-IN in Devanagari, as CLDR's likely subtags give them. A script subtag still wins. --- crates/i18n/README.md | 10 +++---- crates/i18n/src/scripts.rs | 59 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 61 insertions(+), 8 deletions(-) diff --git a/crates/i18n/README.md b/crates/i18n/README.md index cde3621d5..ac6146b5e 100644 --- a/crates/i18n/README.md +++ b/crates/i18n/README.md @@ -61,11 +61,11 @@ The app keeps its `tl!` macro, its language setting (`"auto"` → `I18N.system_l - **Names:** a language's name comes from its shared catalog's `@id language.name` row, else its code; English is "English". - **Scripts and direction:** for any language tag, `scripts` lists the ISO 15924 scripts its - interface text is written in (`ja` → Hani, Hira, Kana; `sr-latn` → Latn), `direction` is right - to left when the main script is (Arabic, Hebrew, Syriac, Thaana), and `han` gives the regional - forms of its Han characters for picking a CJK face: `zh-CN`/`zh-SG`/`zh-Hans` → SC, `zh-TW`/ - `zh-Hant` → TC, `zh-HK`/`zh-MO` → HK, `ja` → JP, `ko` → KR. `I18n::rtl` is `direction` of the - language's code; putting text into display order is the app's job. + interface text is written in (`ja` → Hani, Hira, Kana; `sr-latn` → Latn; `pa-PK` → Arab), + `direction` is right to left when the main script is (Arabic, Hebrew, Syriac, Thaana), and `han` + gives the regional forms of its Han characters for picking a CJK face: `zh-CN`/`zh-SG`/`zh-Hans` → + SC, `zh-TW`/`zh-Hant` → TC, `zh-HK`/`zh-MO` → HK, `ja` → JP, `ko` → KR. `I18n::rtl` is + `direction` of the language's code; putting text into display order is the app's job. - **System locale:** `system_lang` matches the system's list once and caches it: on desktop Unix `LANGUAGE`, `LC_ALL`, `LC_MESSAGES` and `LANG`, read raw; elsewhere `sys-locale`'s (macOS and Windows preferred languages, the browser's on the web). `fr_CA.UTF-8` → `fr`, `zh-TW` → diff --git a/crates/i18n/src/scripts.rs b/crates/i18n/src/scripts.rs index ecd2f8ddf..037b3d401 100644 --- a/crates/i18n/src/scripts.rs +++ b/crates/i18n/src/scripts.rs @@ -3,6 +3,7 @@ /// A writing system, by its ISO 15924 code. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[non_exhaustive] pub enum Script { Arab, Armn, @@ -90,6 +91,7 @@ pub enum Direction { /// KR). The same code point is drawn differently in each, so a font stack picks its Han face by /// this, not by script. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[non_exhaustive] pub enum Han { /// Simplified Chinese, mainland China and Singapore (SC). Simplified, @@ -105,9 +107,10 @@ pub enum Han { /// The scripts a language tag's interface text is written in (`ja` → Hani, Hira, Kana; `sr` → /// Cyrl; `sr-latn` → Latn), the main one first. Any case and glibc form (`sr_RS.UTF-8@latin`). A -/// script subtag wins over the language's usual script. Latin product names and units appear in -/// every language, so Latn is listed only where it is the language's own script. A language not -/// in the table is taken to be written in Latin. +/// script subtag wins, then a region that implies a script (`pa-PK` → Arab), then the language's +/// usual script. Latin product names and units appear in every language, so Latn is listed only +/// where it is the language's own script. A language not in the table is taken to be written in +/// Latin. // ponytail: the languages a Craft app ships or could plausibly ship; add a row with the locale. pub fn scripts(tag: &str) -> &'static [Script] { use Script::*; @@ -115,6 +118,9 @@ pub fn scripts(tag: &str) -> &'static [Script] { if let Some(s) = tag.script.as_deref().and_then(subtag_scripts) { return s; } + if let Some(s) = tag.region.as_deref().and_then(|region| region_scripts(&tag.language, region)) { + return s; + } match tag.language.as_str() { "ja" => &[Hani, Hira, Kana], "ko" => &[Hang, Hani], @@ -178,6 +184,21 @@ pub fn han(tag: &str) -> Option { } } +/// The scripts a language takes in a region where CLDR's likely subtags give it a script other +/// than its usual one (`pa-PK` → `pa-Arab-PK`). Han regions are left to [`han`]. +// ponytail: mn-CN → Mong is left out until vertical Mongolian layout is supported. +fn region_scripts(language: &str, region: &str) -> Option<&'static [Script]> { + use Script::*; + Some(match (language, region) { + ("az", "iq" | "ir") | ("ha", "cm" | "sd") | ("kk", "af" | "cn" | "ir" | "mn") | ("ku", "iq" | "ir" | "lb") => &[Arab], + ("ky", "cn") | ("ms", "cc") | ("pa", "pk") | ("tg", "pk") | ("uz", "af") => &[Arab], + ("az", "ru") | ("ku", "am" | "az" | "ge" | "tm") | ("ug", "kz" | "mn") | ("uz", "cn") => &[Cyrl], + ("ky", "tr") | ("sr", "me" | "ro" | "tr") => &[Latn], + ("sd", "in") => &[Deva], + _ => return None, + }) +} + /// The scripts a script subtag stands for, including the CLDR unions (`jpan`, `kore`). `None` for /// one not in [`Script`], so the language's own scripts apply. fn subtag_scripts(subtag: &str) -> Option<&'static [Script]> { @@ -352,6 +373,38 @@ mod tests { assert_eq!(direction("he_IL.UTF-8"), Direction::Rtl); } + /// CLDR likely subtags: a region can switch a language to another script. + #[test] + fn region_implies_script() { + for (tag, want) in [ + ("pa-PK", &[Arab][..]), + ("pa_PK.UTF-8", &[Arab]), + ("pa-IN", &[Guru]), + ("uz-AF", &[Arab]), + ("uz-UZ", &[Latn]), + ("uz-CN", &[Cyrl]), + ("az-IR", &[Arab]), + ("az-AZ", &[Latn]), + ("sd-IN", &[Deva]), + ("sd-PK", &[Arab]), + ("ks-IN", &[Arab]), + ("ms-CC", &[Arab]), + ("ms-BN", &[Latn]), + ("sr-ME", &[Latn]), + ("kk-CN", &[Arab]), + ("ug-KZ", &[Cyrl]), + ("pa-Guru-PK", &[Guru]), + ("sd-Arab-IN", &[Arab]), + ] { + assert_eq!(scripts(tag), want, "{tag}"); + } + assert_eq!(direction("pa-PK"), Direction::Rtl); + assert_eq!(direction("uz-AF"), Direction::Rtl); + assert_eq!(direction("az-IR"), Direction::Rtl); + assert_eq!(direction("sd-IN"), Direction::Ltr); + assert_eq!(direction("ug-KZ"), Direction::Ltr); + } + /// The usual scripts tag matching relies on agree with the table. #[test] fn agrees_with_usual_script() { From 705e91e183ab3b63bc5d3aacf25ae6ab7e9d0cc6 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 05:44:27 +0000 Subject: [PATCH 09/43] i18n: isolate only text with a right-to-left letter fmt and trn add isolates only when the template or an inserted value has a strong RTL character (bidi class R or AL). Text with none comes out as before, and a value's own isolate from ltr or isolate is taken off. --- Cargo.lock | 1 + crates/i18n/Cargo.toml | 1 + crates/i18n/README.md | 8 ++- crates/i18n/src/lib.rs | 124 ++++++++++++++++++++++++++--------------- 4 files changed, 85 insertions(+), 49 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4d9de4289..ebc4055fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -659,6 +659,7 @@ dependencies = [ "intl_pluralrules", "sys-locale", "unic-langid", + "unicode-bidi", ] [[package]] diff --git a/crates/i18n/Cargo.toml b/crates/i18n/Cargo.toml index 45e21775e..8283943f8 100644 --- a/crates/i18n/Cargo.toml +++ b/crates/i18n/Cargo.toml @@ -11,6 +11,7 @@ repository.workspace = true intl_pluralrules = "=7.0.2" unic-langid = "0.9" sys-locale = "0.3" +unicode-bidi = "0.3" [target.'cfg(target_arch = "wasm32")'.dependencies] sys-locale = { version = "0.3", features = ["js"] } diff --git a/crates/i18n/README.md b/crates/i18n/README.md index a34ce1830..951a67827 100644 --- a/crates/i18n/README.md +++ b/crates/i18n/README.md @@ -49,12 +49,14 @@ The app keeps its `tl!` macro, its language setting (`"auto"` → `I18N.system_l (one context along the locale chain, so `pt-br` can return a `pt` row, but never the plain row or English: a key cap's name under `key`, or "is there a translation?"), and `fmt` for `{name}` placeholders, filled in one pass. -- **Isolation:** `fmt` wraps each inserted value in a first-strong isolate (FSI…PDI), and `trn` +- **Isolation:** when a message or a value inserted into it has a right-to-left letter (bidi + class R or AL), `fmt` wraps each inserted value in a first-strong isolate (FSI…PDI), and `trn` wraps `{n}` in a left-to-right one (LRI…PDI), so a Latin file name in an Arabic sentence, or a Hebrew name in an English one, can't reorder the words around it. Wrap numbers, paths and codes in `ltr` before passing them; a value that already is one isolate goes in as it is, and a stray - PDI in a value can't close its isolate early. egui draws the marks as nothing. For text that - leaves the interface (file names, logs, terminals, text parsed later), use `fmt_plain` or + PDI in a value can't close its isolate early. Text with no right-to-left letter comes out + without marks, as if `fmt` didn't isolate at all. egui draws the marks as nothing. For text + that leaves the interface (file names, logs, terminals, text parsed later), use `fmt_plain` or `strip_isolates`. - **Bad rows** (malformed, an unknown `@` context, a wrong plural form count, a row defined twice, of which the first is kept) and bad locales (`en`, `pseudo`, a code embedded twice in any case) diff --git a/crates/i18n/src/lib.rs b/crates/i18n/src/lib.rs index a394aef56..8fde98ad5 100644 --- a/crates/i18n/src/lib.rs +++ b/crates/i18n/src/lib.rs @@ -12,6 +12,7 @@ use std::sync::{Mutex, OnceLock, PoisonError}; use intl_pluralrules::{PluralCategory, PluralRuleType, PluralRules}; use unic_langid::LanguageIdentifier; +use unicode_bidi::{BidiClass, bidi_class}; /// The catalogs an app embeds, one entry per locale: (locale, shared catalog, the app's catalog). /// A missing catalog is `""`. @@ -158,9 +159,9 @@ impl I18n { self.find(lang, &[("@id", id), ("", label)]).unwrap_or(label) } - /// A count's wording in `lang`, `{n}` filled in as a left-to-right isolate ([`ltr`]). `one` - /// and `other` are the English forms; they are also the key of the `@plural` row, and the - /// fallback. + /// A count's wording in `lang`, `{n}` filled in, as a left-to-right isolate ([`ltr`]) when the + /// wording has a right-to-left letter. `one` and `other` are the English forms; they are also + /// the key of the `@plural` row, and the fallback. pub fn trn(&self, lang: Lang, n: u64, one: &str, other: &str) -> String { let key = format!("{one}|{other}"); let chain = self.locale(lang).map_or(&[][..], |l| l.chain.as_slice()); @@ -262,18 +263,20 @@ impl I18n { } } -/// Fill `{name}` placeholders from `args` in one pass, each value wrapped in a first-strong -/// isolate (FSI…PDI), so its direction can't reorder the sentence around it: a Latin file name -/// in an Arabic message stays where the translator put it. A value that already is one isolate -/// goes in as it is; pass numbers, paths and codes through [`ltr`]. Unknown placeholders stay as -/// written, and inserted values are never read as templates, so a file name containing `{n}` -/// stays intact. +/// Fill `{name}` placeholders from `args` in one pass. When the template or an inserted value +/// has a right-to-left letter (Hebrew, Arabic and the like), each value goes in as a first-strong +/// isolate (FSI…PDI), so its direction can't reorder the sentence around it: a Latin file name in +/// an Arabic message stays where the translator put it. A value that already is one isolate goes +/// in as it is; pass numbers, paths and codes through [`ltr`]. Text with no right-to-left letter +/// gets no marks, and a value's own isolate is taken off. Unknown placeholders stay as written, +/// and inserted values are never read as templates, so a file name containing `{n}` stays intact. pub fn fmt(template: &str, args: &[(&str, &str)]) -> String { fill(template, args, true) } /// [`fmt`] without isolates, for text that goes where the marks would get in the way: file -/// names, logs, terminals, text compared or parsed later. +/// names, logs, terminals, text compared or parsed later. A value that is one isolate goes in +/// without it. pub fn fmt_plain(template: &str, args: &[(&str, &str)]) -> String { fill(template, args, false) } @@ -284,14 +287,16 @@ const FSI: char = '\u{2068}'; const PDI: char = '\u{2069}'; /// `value` as a first-strong isolate (FSI…PDI): it takes the direction of its first strong -/// letter and can't reorder the text around it. What [`fmt`] does to each value. +/// letter and can't reorder the text around it. What [`fmt`] does to each value in text with a +/// right-to-left letter. pub fn isolate(value: &str) -> String { wrap(FSI, value) } /// `value` as a left-to-right isolate (LRI…PDI), for numbers, paths, file sizes, timecodes and /// codes, which read left to right in every language. In an RTL first-strong isolate, the -/// segments of `/home/مستخدم/file.txt` would run right to left. +/// segments of `/home/مستخدم/file.txt` would run right to left. [`fmt`] takes the isolate off +/// again in text with no right-to-left letter. pub fn ltr(value: &str) -> String { wrap(LRI, value) } @@ -323,6 +328,22 @@ fn wrap(initiator: char, value: &str) -> String { out } +/// Whether `text` has a strong right-to-left character (bidi class R or AL): the text that needs +/// isolates. +fn has_rtl(text: &str) -> bool { + !text.is_ascii() && text.chars().any(|c| matches!(bidi_class(c), BidiClass::R | BidiClass::AL)) +} + +/// `value` without its isolate, if it is exactly one. +fn unwrap_isolate(value: &str) -> &str { + if is_one_isolate(value) { + // The initiator and PDI are three bytes each in UTF-8. + value.get(3..value.len() - 3).unwrap_or(value) + } else { + value + } +} + /// Whether `value` is exactly one isolate: an initiator, then the PDI that closes it last. fn is_one_isolate(value: &str) -> bool { let mut depth = 0_usize; @@ -344,27 +365,32 @@ fn is_one_isolate(value: &str) -> bool { } fn fill(template: &str, args: &[(&str, &str)], isolated: bool) -> String { - let mut out = String::with_capacity(template.len()); + let mut pieces: Vec<(&str, bool)> = Vec::new(); let mut rest = template; while let Some((before, after_open)) = rest.split_once('{') { - out.push_str(before); + pieces.push((before, false)); let Some((name, after_close)) = after_open.split_once('}') else { - out.push('{'); + pieces.push(("{", false)); rest = after_open; break; }; match args.iter().find(|(k, _)| *k == name) { - Some((_, value)) if !isolated || value.is_empty() || is_one_isolate(value) => out.push_str(value), - Some((_, value)) => out.push_str(&isolate(value)), - None => { - out.push('{'); - out.push_str(name); - out.push('}'); - } + Some((_, value)) => pieces.push((value, true)), + None => pieces.extend([("{", false), (name, false), ("}", false)]), } rest = after_close; } - out.push_str(rest); + pieces.push((rest, false)); + let isolated = isolated && pieces.iter().any(|(text, _)| has_rtl(text)); + let mut out = String::with_capacity(template.len()); + for (text, value) in pieces { + match (value, isolated) { + (false, _) => out.push_str(text), + (true, false) => out.push_str(unwrap_isolate(text)), + (true, true) if text.is_empty() || is_one_isolate(text) => out.push_str(text), + (true, true) => out.push_str(&isolate(text)), + } + } out } @@ -562,21 +588,33 @@ mod tests { ("fr", "@plural\t{n} page|{n} pages\t{n} page|{n} pages\n", ""), ("ru", "@plural\t{n} file|{n} files\t{n} файл|{n} файла|{n} файлов\n", ""), ("ja", "@plural\t{n} file|{n} files\t{n} 個\n", ""), + ("fa", "@plural\t{n} page|{n} pages\t{n} صفحه|{n} صفحه\n", ""), ]; #[test] fn fmt_fills_once() { - assert_eq!(fmt_plain("{a} to {b}", &[("a", "x"), ("b", "{a}")]), "x to {a}"); + assert_eq!(fmt("{a} to {b}", &[("a", "x"), ("b", "{a}")]), "x to {a}"); assert_eq!(fmt("{z} {", &[]), "{z} {"); } #[test] - fn inserted_values_are_isolated() { + fn left_to_right_text_gets_no_marks() { + assert_eq!(fmt("Saved {name} in {dir}.", &[("name", "Report 2.pdf"), ("dir", <r("/home/sara"))]), "Saved Report 2.pdf in /home/sara."); + assert_eq!(fmt("{a}", &[("a", "x"), ("b", "שלום")]), "x", "an unused value doesn't count"); + assert_eq!(fmt_plain("{a}", &[("a", &isolate("x"))]), "x"); + let i = all(P).0; + assert_eq!(i.trn(Lang::EN, 2, "{n} page", "{n} pages"), "2 pages"); + } + + #[test] + fn right_to_left_text_isolates_values() { let saved = fmt("تم حفظ {name} في {dir}.", &[("name", "Report 2.pdf"), ("dir", <r("/home/سارة/docs"))]); assert_eq!(saved, "تم حفظ \u{2068}Report 2.pdf\u{2069} في \u{2066}/home/سارة/docs\u{2069}."); assert_eq!(strip_isolates(&saved), "تم حفظ Report 2.pdf في /home/سارة/docs."); - assert_eq!(fmt_plain("{a}", &[("a", "x")]), "x"); - assert_eq!(fmt("[{a}]", &[("a", "")]), "[]"); + assert_eq!(fmt("Opened {name}.", &[("name", "שלום.txt")]), "Opened \u{2068}שלום.txt\u{2069}."); + assert_eq!(fmt("[{a}] {b}", &[("a", ""), ("b", "ש")]), "[] \u{2068}ש\u{2069}"); + let i = all(P).0; + assert_eq!(i.trn(lang(&i, "fa"), 3, "{n} page", "{n} pages"), "\u{2066}3\u{2069} صفحه"); } #[test] @@ -584,15 +622,9 @@ mod tests { assert_eq!(isolate("a\u{2069}b"), "\u{2068}ab\u{2069}"); assert_eq!(isolate("a\u{2067}b"), "\u{2068}a\u{2067}b\u{2069}\u{2069}"); let one = ltr("12"); - assert_eq!(fmt("{n}", &[("n", &one)]), one); + assert_eq!(fmt("ש {n}", &[("n", &one)]), format!("ש {one}")); // Two isolates side by side are not one, so they get wrapped. - assert_eq!(fmt("{v}", &[("v", "\u{2066}a\u{2069}\u{2066}b\u{2069}")]), "\u{2068}\u{2066}a\u{2069}\u{2066}b\u{2069}\u{2069}"); - } - - #[test] - fn counts_are_left_to_right_isolates() { - let i = all(P).0; - assert_eq!(i.trn(lang(&i, "fr"), 2, "{n} page", "{n} pages"), "\u{2066}2\u{2069} pages"); + assert_eq!(fmt("ש{v}", &[("v", "\u{2066}a\u{2069}\u{2066}b\u{2069}")]), "ש\u{2068}\u{2066}a\u{2069}\u{2066}b\u{2069}\u{2069}"); } #[test] @@ -600,8 +632,8 @@ mod tests { let (i, problems) = all(P); assert!(problems.is_empty(), "{problems:?}"); let fr = lang(&i, "fr"); - assert_eq!(strip_isolates(&i.trn(fr, 0, "{n} page", "{n} pages")), "0 page"); - assert_eq!(strip_isolates(&i.trn(fr, 2, "{n} page", "{n} pages")), "2 pages"); + assert_eq!(i.trn(fr, 0, "{n} page", "{n} pages"), "0 page"); + assert_eq!(i.trn(fr, 2, "{n} page", "{n} pages"), "2 pages"); } #[test] @@ -609,27 +641,27 @@ mod tests { let i = all(P).0; let ru = lang(&i, "ru"); for (n, want) in [(1, "1 файл"), (3, "3 файла"), (5, "5 файлов"), (11, "11 файлов"), (21, "21 файл")] { - assert_eq!(strip_isolates(&i.trn(ru, n, "{n} file", "{n} files")), want); + assert_eq!(i.trn(ru, n, "{n} file", "{n} files"), want); } } #[test] fn one_form_ja() { let i = all(P).0; - assert_eq!(strip_isolates(&i.trn(lang(&i, "ja"), 7, "{n} file", "{n} files")), "7 個"); + assert_eq!(i.trn(lang(&i, "ja"), 7, "{n} file", "{n} files"), "7 個"); } #[test] fn english_fallback() { let i = all(P).0; - assert_eq!(strip_isolates(&i.trn(Lang::EN, 1, "{n} file", "{n} files")), "1 file"); - assert_eq!(strip_isolates(&i.trn(lang(&i, "fr"), 2, "{n} x", "{n} xs")), "2 xs"); + assert_eq!(i.trn(Lang::EN, 1, "{n} file", "{n} files"), "1 file"); + assert_eq!(i.trn(lang(&i, "fr"), 2, "{n} x", "{n} xs"), "2 xs"); } #[test] fn beyond_sweep() { let i = all(P).0; - assert_eq!(strip_isolates(&i.trn(lang(&i, "fr"), 1_000_000, "{n} page", "{n} pages")), "1000000 pages"); + assert_eq!(i.trn(lang(&i, "fr"), 1_000_000, "{n} page", "{n} pages"), "1000000 pages"); } static L: &Embedded = @@ -696,7 +728,7 @@ mod tests { assert_eq!(i.t(Lang::PSEUDO, "A\nB"), "⟦A⟧\n⟦B⟧"); assert_eq!(i.tc(Lang::PSEUDO, "menu", "Open"), "⟦Open⟧"); assert_eq!(i.tid(Lang::PSEUDO, "file.save", "Save"), "⟦Save⟧"); - assert_eq!(strip_isolates(&i.trn(Lang::PSEUDO, 2, "{n} x", "{n} xs")), "⟦2 xs⟧"); + assert_eq!(i.trn(Lang::PSEUDO, 2, "{n} x", "{n} xs"), "⟦2 xs⟧"); assert!(!i.languages().contains(&Lang::PSEUDO)); assert_eq!(i.from_code("pseudo"), cfg!(debug_assertions).then_some(Lang::PSEUDO)); assert_eq!(i.get(Lang::PSEUDO, "key", "Esc"), Some("⟦Esc⟧")); @@ -775,9 +807,9 @@ mod tests { #[test] fn european_portuguese_zero_is_plural() { let i = all(PT).0; - assert_eq!(strip_isolates(&i.trn(lang(&i, "pt"), 0, "{n} file", "{n} files")), "0 ficheiros"); - assert_eq!(strip_isolates(&i.trn(lang(&i, "pt"), 1, "{n} file", "{n} files")), "1 ficheiro"); - assert_eq!(strip_isolates(&i.trn(lang(&i, "pt-br"), 0, "{n} file", "{n} files")), "0 arquivo"); + assert_eq!(i.trn(lang(&i, "pt"), 0, "{n} file", "{n} files"), "0 ficheiros"); + assert_eq!(i.trn(lang(&i, "pt"), 1, "{n} file", "{n} files"), "1 ficheiro"); + assert_eq!(i.trn(lang(&i, "pt-br"), 0, "{n} file", "{n} files"), "0 arquivo"); } /// The same counts as craft-i18n's checker (`plural_form_counts`), which rejects a catalog From 3e4cca97375628e1c5b007240b5b6fc29e4f13ca Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 05:44:34 +0000 Subject: [PATCH 10/43] i18n: Region separators only where the system sets them; C in LC_* is en-US Region::decimal and grouping are None unless Windows' or macOS's own settings set them, so the number locale's CLDR data decides otherwise. A cleared Windows sThousand is no grouping. LC_ALL=C and LC_NUMERIC=C (and the other LC_* variables) mean the C locale; only LANG=C gives way to the preferred language. --- crates/i18n/README.md | 6 +- crates/i18n/src/region.rs | 117 +++++++++++++++++--------------------- 2 files changed, 56 insertions(+), 67 deletions(-) diff --git a/crates/i18n/README.md b/crates/i18n/README.md index d916bbbb8..7520f4aeb 100644 --- a/crates/i18n/README.md +++ b/crates/i18n/README.md @@ -70,11 +70,11 @@ The app keeps its `tl!` macro, its language setting (`"auto"` → `I18N.system_l picks `sr`. - **Formatting region:** `Region` holds how numbers, times and measurements are written, apart from the interface language: the number and time locales (BCP 47), metric or US measurement, A4 - or Letter, the decimal separator, and the grouping separator and clock when the system sets - them. `Region::system()` reads it once: Windows' regional format with the user's changes + or Letter, and the decimal and grouping separators (or no grouping) and clock when the system + 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, the preferred language on the web and Android. `Region::from_setting` takes + `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-`. Ported from WordCraft's `Region`. diff --git a/crates/i18n/src/region.rs b/crates/i18n/src/region.rs index f9bb47206..bbebe4c9e 100644 --- a/crates/i18n/src/region.rs +++ b/crates/i18n/src/region.rs @@ -2,8 +2,9 @@ //! interface language. An English interface in Poland still writes `2,54 cm` on A4. //! //! The territory lists come from Unicode CLDR's supplemental `measurementData` (Unicode License): -//! `paperSize` US-Letter and `measurementSystem` US. The decimal separators follow CLDR's number -//! symbols for the common locales; the system's own separator wins where the platform has one. +//! `paperSize` US-Letter and `measurementSystem` US. The separators numbers are written with +//! follow the number locale's CLDR data ([`crate::Format`]); the region carries them only where +//! the system sets its own. use std::sync::OnceLock; @@ -13,23 +14,6 @@ const LETTER: &[&str] = &["BZ", "CA", "CL", "CO", "CR", "GT", "MX", "NI", "PA", /// Territories that measure in inches (CLDR `measurementSystem type="US"`). const INCHES: &[&str] = &["LR", "MM", "US"]; -/// Languages whose numbers use a decimal comma in most of their territories. -const COMMA_LANGUAGES: &[&str] = &[ - "af", "az", "be", "bg", "bs", "ca", "cs", "da", "de", "el", "es", "et", "eu", "fi", "fo", "fr", "gl", "hr", "hu", "hy", "id", "is", "it", "ka", - "kk", "ky", "lb", "lt", "lv", "mk", "nb", "nl", "nn", "no", "pl", "pt", "ro", "ru", "sk", "sl", "sq", "sr", "sv", "tr", "uk", "uz", "vi", -]; - -/// Territories where those languages use a decimal point after all. -const POINT_EXCEPTIONS: &[(&str, &[&str])] = - &[("de", &["CH", "LI"]), ("it", &["CH"]), ("es", &["CU", "DO", "GT", "HN", "MX", "NI", "PA", "PE", "PR", "SV", "US", "419"])]; - -/// Territories where English is written with a decimal comma: most of continental Europe, South -/// Africa and much of South America. -const COMMA_TERRITORIES: &[&str] = &[ - "AR", "AT", "BE", "BG", "BO", "BR", "CL", "CO", "CZ", "DE", "DK", "EC", "EE", "ES", "FI", "FR", "GR", "HR", "HU", "ID", "IS", "IT", "LT", "LU", - "LV", "NL", "NO", "PL", "PT", "PY", "RO", "RS", "RU", "SE", "SI", "SK", "TR", "UA", "UY", "VE", "VN", "ZA", -]; - /// How lengths, distances and weights are measured. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum Measurement { @@ -66,16 +50,17 @@ pub struct Region { pub time: String, pub measurement: Measurement, pub paper: Paper, - /// The decimal separator (`,` in Poland). - pub decimal: char, - /// The grouping separator the system sets (Windows, macOS); `None` follows the locale. - pub grouping: Option, + /// The decimal separator the system sets (Windows, macOS); `None` follows the locale. + pub decimal: Option, + /// The grouping separator the system sets (Windows, macOS): `Some(None)` for none at all + /// (`1234567`); `None` follows the locale. + pub grouping: Option>, /// The clock the system sets (Windows, macOS); `None` follows the locale. pub hour_cycle: Option, } impl Default for Region { - /// `en-US`: decimal point, Letter, inches. + /// `en-US`: Letter, inches. fn default() -> Self { Region::from_parts(&Tag::default()) } @@ -114,21 +99,13 @@ impl Region { fn from_parts(tag: &Tag) -> Region { let territory = tag.territory.as_str(); - let language = tag.language.as_str(); let measurement = tag.measurement.unwrap_or(if INCHES.contains(&territory) { Measurement::Us } else { Measurement::Metric }); - let decimal = if COMMA_LANGUAGES.contains(&language) { - if POINT_EXCEPTIONS.iter().any(|(l, ts)| *l == language && ts.contains(&territory)) { '.' } else { ',' } - } else if language == "en" && COMMA_TERRITORIES.contains(&territory) { - ',' - } else { - '.' - }; Region { numbers: tag.bcp47(), time: tag.bcp47(), measurement, paper: if LETTER.contains(&territory) { Paper::Letter } else { Paper::A4 }, - decimal, + decimal: None, grouping: None, hour_cycle: tag.hour_cycle, } @@ -239,22 +216,23 @@ fn title_case(p: &str) -> String { chars.next().map(|c| c.to_ascii_uppercase().to_string() + chars.as_str()).unwrap_or_default() } -/// A locale variable that names a locale (not empty, `C` or `POSIX`). +/// 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 usable_locale(v: &str) -> bool { +fn is_c_locale(v: &str) -> bool { let v = v.trim(); - !v.is_empty() && !v.eq_ignore_ascii_case("c") && !v.eq_ignore_ascii_case("posix") && !v.starts_with("C.") + v.eq_ignore_ascii_case("c") || v.eq_ignore_ascii_case("posix") || v.starts_with("C.") } -/// The region from the POSIX locale variables `var` reads and, when neither `LC_ALL` nor the -/// variable for a convention nor `LANG` is set, the platform's preferred locale. +/// 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. #[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| usable_locale(v)); + let var = |name: &str| var(name).filter(|v| !v.trim().is_empty()); if let Some(all) = var("LC_ALL") { return Region::from_tag(&all); } - let lang = var("LANG").or_else(platform).unwrap_or_default(); + let lang = var("LANG").filter(|v| !is_c_locale(v)).or_else(platform).unwrap_or_default(); let tag = |name: &str| var(name).unwrap_or_else(|| lang.clone()); let time = Region::from_tag(&tag("LC_TIME")); Region { @@ -272,12 +250,8 @@ fn from_posix(var: impl Fn(&str) -> Option, platform: impl FnOnce() -> O #[cfg(any(test, windows))] fn from_windows(value: impl Fn(&str) -> Option, platform: impl FnOnce() -> Option) -> Region { let mut region = Region::from_tag(&value("LocaleName").or_else(platform).unwrap_or_default()); - if let Some(c) = value("sDecimal").as_deref().and_then(single_char) { - region.decimal = c; - } - if let Some(s) = value("sThousand") { - region.grouping = single_char(&s); - } + region.decimal = value("sDecimal").as_deref().and_then(single_char); + region.grouping = value("sThousand").as_deref().and_then(grouping); match value("iMeasure").as_deref().map(str::trim) { Some("0") => region.measurement = Measurement::Metric, Some("1") => region.measurement = Measurement::Us, @@ -299,14 +273,19 @@ fn from_windows(value: impl Fn(&str) -> Option, platform: impl FnOnce() fn from_apple(identifier: &str, metric: bool, decimal: &str, grouping: &str, hours: Option<&str>) -> Region { let mut region = Region::from_tag(identifier); region.measurement = if metric { Measurement::Metric } else { Measurement::Us }; - if let Some(c) = single_char(decimal) { - region.decimal = c; - } - region.grouping = single_char(grouping); + region.decimal = single_char(decimal); + region.grouping = self::grouping(grouping); region.hour_cycle = hours.and_then(hour_cycle_of).or(region.hour_cycle); region } +/// A grouping separator the system sets: none at all when empty (Windows' `sThousand` the user +/// cleared), else one character. +#[cfg(any(test, windows, target_vendor = "apple"))] +fn grouping(s: &str) -> Option> { + if s.is_empty() { Some(None) } else { single_char(s).map(Some) } +} + /// A separator that is one character; Windows allows up to three, which the region can't carry. #[cfg(any(test, windows, target_vendor = "apple"))] fn single_char(s: &str) -> Option { @@ -369,8 +348,8 @@ fn read_system() -> Region { mod tests { use super::*; - fn conventions(r: &Region) -> (Paper, Measurement, char) { - (r.paper, r.measurement, r.decimal) + fn conventions(r: &Region) -> (Paper, Measurement) { + (r.paper, r.measurement) } #[test] @@ -398,15 +377,15 @@ mod tests { assert_eq!(Region::from_tag("en_US@rg=plzzzz;ms=metric").numbers, "en-PL"); } - /// Paper and measurement follow the territory, numbers the language and territory. + /// Paper and measurement follow the territory; separators are left to the number locale. #[test] fn conventions_follow_the_territory() { - assert_eq!(conventions(&Region::from_tag("pl-PL")), (Paper::A4, Measurement::Metric, ',')); - assert_eq!(conventions(&Region::from_tag("en-US")), (Paper::Letter, Measurement::Us, '.')); - assert_eq!(conventions(&Region::from_tag("fr-CA")), (Paper::Letter, Measurement::Metric, ',')); - assert_eq!(conventions(&Region::from_tag("en-DE")), (Paper::A4, Measurement::Metric, ',')); - assert_eq!(conventions(&Region::from_tag("en-GB")), (Paper::A4, Measurement::Metric, '.')); - assert_eq!(Region::from_tag("de-CH").decimal, '.'); + assert_eq!(conventions(&Region::from_tag("pl-PL")), (Paper::A4, Measurement::Metric)); + assert_eq!(conventions(&Region::from_tag("en-US")), (Paper::Letter, Measurement::Us)); + assert_eq!(conventions(&Region::from_tag("fr-CA")), (Paper::Letter, Measurement::Metric)); + assert_eq!(conventions(&Region::from_tag("en-GB")), (Paper::A4, Measurement::Metric)); + let r = Region::from_tag("de-CH"); + assert_eq!((r.decimal, r.grouping), (None, None)); assert_eq!(Region::from_tag("es-MX").paper, Paper::Letter); assert_eq!(Region::from_tag("ja-JP").paper, Paper::A4); } @@ -423,12 +402,17 @@ mod tests { let env = |pairs: &'static [(&'static str, &'static str)]| move |n: &str| pairs.iter().find(|(k, _)| *k == n).map(|(_, v)| v.to_string()); let none = || None; let r = from_posix(env(&[("LANG", "en_US.UTF-8"), ("LC_NUMERIC", "pl_PL.UTF-8"), ("LC_TIME", "en_GB.UTF-8"), ("LC_PAPER", "de_DE")]), none); - assert_eq!((r.numbers.as_str(), r.time.as_str(), r.decimal), ("pl-PL", "en-GB", ',')); + assert_eq!((r.numbers.as_str(), r.time.as_str(), r.decimal), ("pl-PL", "en-GB", None)); assert_eq!((r.paper, r.measurement), (Paper::A4, Measurement::Us), "paper from LC_PAPER, measurement from LANG"); assert_eq!(from_posix(env(&[("LANG", "it_IT.UTF-8"), ("LC_ALL", "en_US"), ("LC_PAPER", "de_DE")]), none).paper, Paper::Letter); assert_eq!(from_posix(env(&[("LANG", "en_US"), ("LC_MEASUREMENT", "it_IT")]), none).measurement, Measurement::Metric); assert_eq!(from_posix(env(&[("LANG", "C.UTF-8"), ("LC_ALL", "")]), || Some("it-IT".into())).numbers, "it-IT"); assert_eq!(from_posix(env(&[]), none), Region::default()); + // `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", "C"), ("LC_NUMERIC", "")]), || Some("it-IT".into())).numbers, "it-IT"); } #[test] @@ -438,15 +422,20 @@ mod tests { reg(&[("LocaleName", "pl-PL"), ("sDecimal", ","), ("sThousand", "\u{a0}"), ("iMeasure", "0"), ("sShortTime", "HH:mm")]), || None, ); - assert_eq!((r.numbers.as_str(), r.decimal, r.grouping, r.hour_cycle), ("pl-PL", ',', Some('\u{a0}'), Some(HourCycle::H23))); + assert_eq!((r.numbers.as_str(), r.decimal, r.grouping, r.hour_cycle), ("pl-PL", Some(','), Some(Some('\u{a0}')), Some(HourCycle::H23))); // An English format in the US with the user's metric and A4 choices and a decimal comma. let r = from_windows( reg(&[("LocaleName", "en-US"), ("sDecimal", ","), ("iMeasure", "0"), ("iPaperSize", "9"), ("sShortTime", "h:mm tt")]), || None, ); - assert_eq!((r.decimal, r.measurement, r.paper, r.hour_cycle), (',', Measurement::Metric, Paper::A4, Some(HourCycle::H12))); + assert_eq!( + (r.decimal, r.grouping, r.measurement, r.paper, r.hour_cycle), + (Some(','), None, Measurement::Metric, Paper::A4, Some(HourCycle::H12)) + ); let r = from_windows(reg(&[("sDecimal", "dec"), ("sThousand", "")]), || Some("de-DE".into())); - assert_eq!((r.numbers.as_str(), r.decimal, r.grouping), ("de-DE", ',', None), "a separator longer than one character keeps the locale's"); + assert_eq!((r.numbers.as_str(), r.decimal), ("de-DE", None), "a separator longer than one character keeps the locale's"); + assert_eq!(r.grouping, Some(None), "an empty grouping separator is no grouping"); + assert_eq!(from_windows(reg(&[("sThousand", "abc")]), || None).grouping, None); } #[test] @@ -454,7 +443,7 @@ mod tests { let r = from_apple("en_PL", true, ",", "\u{a0}", Some("HH")); assert_eq!( (r.numbers.as_str(), r.paper, r.decimal, r.grouping, r.hour_cycle), - ("en-PL", Paper::A4, ',', Some('\u{a0}'), Some(HourCycle::H23)) + ("en-PL", Paper::A4, Some(','), Some(Some('\u{a0}')), Some(HourCycle::H23)) ); let r = from_apple("en_US", false, ".", ",", Some("h a")); assert_eq!((r.measurement, r.hour_cycle), (Measurement::Us, Some(HourCycle::H12))); From c1f64180c99b50e397f30fa38fba9b5e45bb9ff3 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 05:44:52 +0000 Subject: [PATCH 11/43] i18n: check whole_from after rounding 9,960,000 bytes in DECIMAL with whole_from(10) rounds to 10.0 MB, which is written 10 MB. --- crates/i18n/src/size.rs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/crates/i18n/src/size.rs b/crates/i18n/src/size.rs index 1466672b1..be724bded 100644 --- a/crates/i18n/src/size.rs +++ b/crates/i18n/src/size.rs @@ -114,12 +114,17 @@ impl FileSize { } loop { let value = bytes as f64 / self.scale(unit); - let mut decimals = self.decimals.get(unit as usize).copied().unwrap_or(0); - if unit == SizeUnit::Byte || (self.whole_from > 0 && value >= f64::from(self.whole_from)) { + let round = |decimals: u8| { + let factor = 10f64.powi(i32::from(decimals)); + (if unit == smallest && bytes > 0 && value < 1.0 { (value * factor).ceil() } else { (value * factor).round() }) / factor + }; + let mut decimals = if unit == SizeUnit::Byte { 0 } else { self.decimals.get(unit as usize).copied().unwrap_or(0) }; + let mut rounded = round(decimals); + // After rounding, so 9.96 MB, which rounds to 10.0, is written 10 MB. + if self.whole_from > 0 && rounded >= f64::from(self.whole_from) { decimals = 0; + rounded = round(0); } - let factor = 10f64.powi(i32::from(decimals)); - let rounded = if unit == smallest && bytes > 0 && value < 1.0 { (value * factor).ceil() } else { (value * factor).round() } / factor; // 1023.96 KB rounds to 1024.0 KB: the next unit writes it as 1.0 MB. if rounded >= self.step as f64 && let Some(&next) = SizeUnit::ALL.get(unit as usize + 1) @@ -221,6 +226,8 @@ mod tests { assert_eq!(en(640_400, style), "640 KB"); assert_eq!(en(3_400_000, style), "3.4 MB"); assert_eq!(en(12_600_000, style), "13 MB"); + assert_eq!(en(9_960_000, style), "10 MB", "whole once rounded to 10"); + assert_eq!(en(9_940_000, style), "9.9 MB"); assert_eq!(en(5_000_000_000, style), "5000 MB"); // Only sizes below the smallest unit round up. assert_eq!(en(1025, FileSize::BINARY.smallest(SizeUnit::Kilo).unit_decimals(SizeUnit::Kilo, 0)), "1 KB"); From 44dbe322265ec67f0834362a468a395ce0faebc4 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 05:45:40 +0000 Subject: [PATCH 12/43] i18n: glibc names in Collator; fa and hi collation; Persian letters and digits in folding Collator::new takes sv_SE.UTF-8 and sr_RS@latin. Folding maps every script's decimal digits to ASCII and, ignoring accents, Persian keheh, Farsi yeh, alef maksura, teh marbuta and heh with hamza to the Arabic letters. The collation data grows 4.8 KB (465 bytes gzipped). --- crates/i18n/README.md | 16 ++++--- crates/i18n/data/collation.postcard | Bin 749171 -> 753949 bytes crates/i18n/data/generate-collation.sh | 8 ++-- crates/i18n/src/collate.rs | 44 +++++++++++++++-- crates/i18n/src/fold.rs | 63 +++++++++++++++++++++++-- 5 files changed, 113 insertions(+), 18 deletions(-) diff --git a/crates/i18n/README.md b/crates/i18n/README.md index 77e3d9967..996d92684 100644 --- a/crates/i18n/README.md +++ b/crates/i18n/README.md @@ -73,12 +73,14 @@ The app keeps its `tl!` macro, its language setting (`"auto"` → `I18N.system_l translation. `looked_up()` lists the source texts looked up so far, for an audit against a complete catalog. - **Search** (`Folder`): `Folder::new(locale).fold(text)` gives a key for case- and - accent-insensitive matching (`keep_accents()` makes accents count) and a map back to the - original text, so `Folded::find` returns the byte range to highlight. Compatibility forms fold - to their plain letters (`fi`, full-width `A`), `ß` to `ss`, `ς` to `σ`; invisible format - characters (soft hyphens, joiners, bidi isolates) are dropped. Turkish and Azerbaijani fold `I` - to `ı` when accents count; ignoring accents, `I`, `i`, `İ` and `ı` all match. + accent-insensitive matching (`keep_accents()` makes accents count) and a map back to the original + text, so `Folded::find` returns the byte range to highlight. Compatibility forms fold to their + plain letters (`fi`, full-width `A`), `ß` to `ss`, `ς` to `σ`, digits of every script to ASCII + (`Layer ١٢` finds `layer 12`); ignoring accents, Persian and Arabic letter forms match (`ک` and + `ك`, `ی` and `ي`, `ة` and `ه`, `أ` and `ا`); invisible format characters (soft hyphens, joiners, + bidi isolates) are dropped. Turkish and Azerbaijani fold `I` to `ı` when accents count; ignoring + accents, `I`, `i`, `İ` and `ı` all match. - **Sorting** (`Collator`, feature `collate`): ICU4X collation for the locales craft-i18n ships, - with digits compared by value (`Layer 2` before `Layer 10`) and `-u-co-pinyin` / - `-u-co-stroke` for Chinese. The data (750 KB) is regenerated by `data/generate-collation.sh`; + Persian and Hindi, with digits compared by value (`Layer 2` before `Layer 10`) and `-u-co-pinyin` + / `-u-co-stroke` for Chinese. The data (754 KB) is regenerated by `data/generate-collation.sh`; the feature adds about 1 MB to a release binary or wasm module (320 KB gzipped). diff --git a/crates/i18n/data/collation.postcard b/crates/i18n/data/collation.postcard index 0ea89d72b5b70cff1b93486909e2f2515ae38509..7eabc2f2f834412c2870fe1438d9b0e83d076a7f 100644 GIT binary patch delta 1501 zcmah{ZD?C%6h7zPyt!$c*``a^G)cSGjZxcWbRTTcjn1xm^Hrs`orMafiEFR79qE>S z;16dT)zrh+Llg zoO9myp7-4Md4B)i^WKX)cwOi_QG(;}Jj9^^)0n|a;mF|7v%@2?vGEDfTw=9XZ`sze zJI3t_w;kmVgtZiNSRgxD2304@pqfxa`0T{&Tf)F&SfMT3S}h?s4a-oD+D*YH z%$Lrh*^1gR@vez6m!hHBt_^Io2pQC7eEs{FZ%$bR00Sw^TwA%ylcx zY6LK8(-EBt2seBV|E=OkB@kF>_|xPFLKmg}Ums zJAz|&s!I^883afPlA01FmHzb31`CZW162T~-L!vrHq`Z?Sn`PIhDF4?f;Pokaw^>b zXI`cA@J@(z)rVjdrr=G|?<1Ip%Wwm3!=F%wPHe(f3}CV%#Oj5|^>5m?wu7G@HTTvd z_dlN3;E(^%-o*SXiuUB@t)$?deY#kN`Z^oR>oR4B*#A4gNB8a}1e{7T{;ODZP;M?Z zWNbawn=`h*xR%d5iaC?}=;Hg76kWKVsw>58MUmLLLn)Hi9lEC6@6a_R?@+R26B04cWd9d2n)vb#~-kd1t2V z5HW3$m*kNl!NN;+$PN(*iCUJkQBV+j2tkmJ9=du91YbJ*PXEh)`BC9;U!&Z^Eja>J z;BXI;_!{5f5B-iYRkOJ5xNJ7Ti?CZkS=*DZt6)Jf^Uy&GZ`>Xl9ETX>s2moF60 zUl<;_dVQ8ZsWxm+r|j2hW;+;H8rqF(+DzRxXFbmgOpcy*ag=%j?xK&VE4jgZ@yyvv zC6`mX&#*gVQbK4Q0DobR`8E- zvix@pVDbgR;s(O2O@wz_2p>KoY=1)dwvF)XGs5l{gg-k72LZyrCW6tF;rs12NQR?O zs>ETb7KfL@vM_9|1=2(bd;M0T=KsDqu%_ISq?MI%Z1fxJ{RR%zo|aoENP;Ycgf<~8 PM1-giTQ9fTf4)Bka1)rK diff --git a/crates/i18n/data/generate-collation.sh b/crates/i18n/data/generate-collation.sh index cc7bbd3d3..35f605ebc 100755 --- a/crates/i18n/data/generate-collation.sh +++ b/crates/i18n/data/generate-collation.sh @@ -1,7 +1,7 @@ #!/bin/sh # Regenerates the collation data `collate` embeds, a postcard blob for the locales craft-i18n -# ships (its `locales/` directory) plus English: each locale's default order, and Chinese -# pinyin and stroke orders. +# ships (its `locales/` directory) plus English, Persian and Hindi: each locale's default order, +# and Chinese pinyin and stroke orders. # Needs icu4x-datagen at the version of the icu_collator dependency: # cargo install icu4x-datagen --version 2.3.0 --locked set -eu @@ -11,5 +11,5 @@ icu4x-datagen --format blob --overwrite --out collation.postcard --deduplication --attribute-filter 'collator=/^(stroke|pinyin)?$/' \ --markers CollationRootV1 CollationTailoringV1 CollationDiacriticsV1 CollationJamoV1 \ CollationMetadataV1 CollationReorderingV1 CollationSpecialPrimariesV1 \ - --locales en ar bg cs da de el es et fi fr hr hu id it ja ka kk ko lt nl pl pt-BR ru sl sr \ - sr-Latn sv te tr uk vi zh-Hans zh-Hant + --locales en ar bg cs da de el es et fa fi fr hi hr hu id it ja ka kk ko lt nl pl pt-BR ru sl \ + sr sr-Latn sv te tr uk vi zh-Hans zh-Hant diff --git a/crates/i18n/src/collate.rs b/crates/i18n/src/collate.rs index d73b1efce..408f40b19 100644 --- a/crates/i18n/src/collate.rs +++ b/crates/i18n/src/collate.rs @@ -28,10 +28,10 @@ pub struct Collator(Option); impl Collator { /// Sorting for `locale`, a language code such as `sv`, `pt-br` or `zh-hant` (any case, `-` - /// or `_`). A BCP 47 collation type picks another order where there is one: - /// `zh-hant-u-co-pinyin`, `zh-hans-u-co-stroke`. + /// or `_`, glibc forms such as `sv_SE.UTF-8` and `sr_RS@latin`). A BCP 47 collation type + /// picks another order where there is one: `zh-hant-u-co-pinyin`, `zh-hans-u-co-stroke`. pub fn new(locale: &str) -> Collator { - let mut prefs: CollatorPreferences = Locale::try_from_str(&locale.replace('_', "-")).unwrap_or(Locale::UNKNOWN).into(); + let mut prefs: CollatorPreferences = Locale::try_from_str(&bcp47(locale)).unwrap_or(Locale::UNKNOWN).into(); prefs.numeric_ordering = Some(CollationNumericOrdering::True); let options = CollatorOptions::default(); let collator = Data::new().ok().and_then(|data| { @@ -58,6 +58,25 @@ impl Collator { } } +/// `locale` as a BCP 47 tag: `_` → `-`, a glibc codeset dropped and its script modifier made a +/// subtag (`sr_RS.UTF-8@latin` → `sr-latn-RS`). +fn bcp47(locale: &str) -> String { + let locale = locale.trim(); + let mut parts: Vec<&str> = locale.split(['.', '@']).next().unwrap_or_default().split(['-', '_']).collect(); + let script = match locale.split_once('@').map(|(_, m)| m.to_ascii_lowercase()).as_deref() { + Some("latin") => Some("latn"), + Some("cyrillic") => Some("cyrl"), + _ => None, + }; + let has_script = parts.get(1).is_some_and(|p| p.len() == 4 && p.bytes().all(|b| b.is_ascii_alphabetic())); + if let Some(script) = script + && !has_script + { + parts.insert(1.min(parts.len()), script); + } + parts.join("-") +} + /// The embedded collation data, with locale fallback, and the decomposition tables the collator /// also needs taken from `icu_normalizer`'s compiled data rather than embedded a second time. struct Data(LocaleFallbackProvider); @@ -123,8 +142,10 @@ mod tests { "el", "es", "et", + "fa", "fi", "fr", + "hi", "hr", "hu", "id", @@ -177,6 +198,23 @@ mod tests { assert_eq!(sorted("ja", &["漢字", "カメラ", "あめ", "いぬ", "Zoom"]), ["Zoom", "あめ", "いぬ", "カメラ", "漢字"]); } + #[test] + fn glibc_locale_names() { + let words = ["öl", "zebra", "år", "äpple", "apa", "oxe"]; + assert_eq!(sorted("sv_SE.UTF-8", &words), sorted("sv", &words)); + assert_ne!(sorted("sv_SE.UTF-8", &words), sorted("en", &words)); + let cities = ["天津", "北京", "上海"]; + assert_eq!(sorted("zh_TW.UTF-8", &cities), sorted("zh-hant", &cities)); + assert_eq!(sorted("zh_CN.UTF-8", &cities), sorted("zh-hans", &cities)); + // Serbian Cyrillic sorts Cyrillic first; Latin Serbian sorts Latin first, `lj` after `lu`. + let mixed = ["ljubav", "lula", "абв"]; + assert_eq!(sorted("sr_RS.UTF-8", &mixed), ["абв", "ljubav", "lula"]); + assert_eq!(sorted("sr_RS.UTF-8@latin", &mixed), ["lula", "ljubav", "абв"]); + assert_eq!(bcp47("sr_RS.UTF-8@latin"), "sr-latn-RS"); + assert_eq!(bcp47("sr_Latn_RS@latin"), "sr-Latn-RS"); + assert_eq!(bcp47("de_DE@euro"), "de-DE"); + } + #[test] fn chinese_sorts_by_pinyin_or_stroke() { // 北 běi (5 strokes), 上 shàng (3), 天 tiān (4). diff --git a/crates/i18n/src/fold.rs b/crates/i18n/src/fold.rs index 19289fe32..3a3330b13 100644 --- a/crates/i18n/src/fold.rs +++ b/crates/i18n/src/fold.rs @@ -4,8 +4,10 @@ //! [`Folder::fold`] turns text into a search key: compatibility forms decomposed (`fi` → `fi`, //! full-width `A` → `a`, `㎏` → `kg`), lower case, `ß` → `ss`, final `ς` → `σ`, and with accents //! ignored (the default) combining accents, Hebrew points and Arabic vowel marks dropped and the -//! few letters with built-in strokes mapped to their base (`ø`, `ł`, `đ`, `ı`, `æ` → `ae`). -//! Invisible format characters (soft hyphens, zero-width joiners, bidi isolates) are dropped too. +//! few letters with built-in strokes mapped to their base (`ø`, `ł`, `đ`, `ı`, `æ` → `ae`), and +//! Persian letter forms mapped to the Arabic ones (`ک` → `ك`, `ی` → `ي`, `ة` → `ه`). Decimal +//! digits of every script fold to ASCII (`١٢` → `12`). Invisible format characters (soft +//! hyphens, zero-width joiners, bidi isolates) are dropped too. //! //! Dotted and dotless i follow the locale when accents count. In Turkish and Azerbaijani, `I` //! folds to `ı` and `İ` to `i`; elsewhere both fold to `i` and `ı` stays a letter of its own. @@ -33,7 +35,7 @@ impl Folder { /// Case- and accent-insensitive folding for `locale`, a language code such as `tr`, `pt-br` /// or `zh-hant` (any case, `-` or `_`). pub fn new(locale: &str) -> Folder { - let language = locale.split(['-', '_']).next().unwrap_or_default(); + let language = locale.split(['-', '_', '.', '@']).next().unwrap_or_default(); Folder { turkic: language.eq_ignore_ascii_case("tr") || language.eq_ignore_ascii_case("az"), accents: false } } @@ -87,7 +89,13 @@ impl Folder { out.push('i'); } 'I' if self.turkic && self.accents => out.push('ı'), + // Persian heh with hamza above (ۀ) decomposes to ae (ە) and hamza. + '\u{6D5}' if !self.accents && chars.peek() == Some(&'\u{654}') => { + chars.next(); + out.push('ه'); + } 'ß' | 'ẞ' => out.push_str("ss"), + c if let Some(d) = ascii_digit(c) => out.push(d), 'Σ' | 'σ' | 'ς' => out.push('σ'), c if ignorable(c) || (!self.accents && accent(c)) => {} c => { @@ -103,9 +111,15 @@ impl Folder { } } -/// The base of a lower-case letter whose diacritic is built in rather than combining. +/// The base of a lower-case letter whose diacritic is built in rather than combining, and the +/// Arabic form of a letter that Persian and Urdu write differently: keheh (ک) is kaf (ك), Farsi +/// yeh (ی) and alef maksura (ى) are yeh (ي), teh marbuta (ة) is heh (ه), alef wasla (ٱ) is alef. fn base_letter(c: char) -> Option<&'static str> { Some(match c { + 'ک' => "ك", + 'ی' | 'ى' => "ي", + 'ة' => "ه", + 'ٱ' => "ا", 'ı' => "i", 'ø' => "o", 'ł' => "l", @@ -139,6 +153,27 @@ fn accent(c: char) -> bool { | '\u{6DF}'..='\u{6E4}' | '\u{6E7}' | '\u{6E8}' | '\u{6EA}'..='\u{6ED}') } +/// The first code point of each run of ten Unicode decimal digits (general category Nd), Unicode +/// 16.0. +const ZEROS: [u32; 76] = [ + 0x30, 0x660, 0x6F0, 0x7C0, 0x966, 0x9E6, 0xA66, 0xAE6, 0xB66, 0xBE6, 0xC66, 0xCE6, 0xD66, 0xDE6, 0xE50, 0xED0, 0xF20, 0x1040, 0x1090, 0x17E0, + 0x1810, 0x1946, 0x19D0, 0x1A80, 0x1A90, 0x1B50, 0x1BB0, 0x1C40, 0x1C50, 0xA620, 0xA8D0, 0xA900, 0xA9D0, 0xA9F0, 0xAA50, 0xABF0, 0xFF10, 0x104A0, + 0x10D30, 0x10D40, 0x11066, 0x110F0, 0x11136, 0x111D0, 0x112F0, 0x11450, 0x114D0, 0x11650, 0x116C0, 0x116D0, 0x116DA, 0x11730, 0x118E0, 0x11950, + 0x11BF0, 0x11C50, 0x11D50, 0x11DA0, 0x11F50, 0x16130, 0x16A60, 0x16AC0, 0x16B50, 0x16D70, 0x1CCF0, 0x1D7CE, 0x1D7D8, 0x1D7E2, 0x1D7EC, 0x1D7F6, + 0x1E140, 0x1E2F0, 0x1E4F0, 0x1E5F1, 0x1E950, 0x1FBF0, +]; + +/// A decimal digit of any script as its ASCII digit: `١٢` and `१२` search as `12`. +fn ascii_digit(c: char) -> Option { + let c = u32::from(c); + let zero = match ZEROS.binary_search(&c) { + Ok(i) => ZEROS.get(i), + Err(i) => ZEROS.get(i.checked_sub(1)?), + }?; + let value = c - zero; + if value < 10 { char::from_digit(value, 10) } else { None } +} + /// Folded text and where each part came from. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Folded { @@ -245,6 +280,10 @@ mod tests { ("he", "שָׁלוֹם", "שלום".into(), nfd("שָׁלוֹם")), ("en", "soft\u{AD}ware \u{2068}name\u{2069}", "software name".into(), "software name".into()), ("ru", "Ёлка", "елка".into(), nfd("ёлка")), + ("ar", "Layer ١٢ ۳۴ १२", "layer 12 34 12".into(), "layer 12 34 12".into()), + ("fa", "کتاب یک", "كتاب يك".into(), "کتاب یک".into()), + ("fa", "خانۀ", "خانه".into(), nfd("خانۀ")), + ("ar", "مدرسة على إسلام آمن", "مدرسه علي اسلام امن".into(), nfd("مدرسة على إسلام آمن")), ]; for (locale, input, ignoring, keeping) in table { assert_eq!(key(locale, input), ignoring, "{locale} {input}"); @@ -287,6 +326,22 @@ mod tests { assert_eq!(all, [0..2, 3..5, 6..8]); } + #[test] + fn persian_and_arabic_letters_match() { + let f = Folder::new("fa"); + // Persian keheh and Farsi yeh against Arabic kaf and yeh, and hamza forms of alef. + assert!(f.contains("كتاب عربي", "کتاب عربی")); + assert!(f.contains("کتاب عربی", "كتاب عربي")); + assert!(f.contains("أحمد إبراهيم", "احمد ابراهیم")); + assert!(f.contains("مدرسة", "مدرسه")); + assert_eq!(f.fold("Layer ١٢").find(&f.fold("layer 12")), Some(0.."Layer ١٢".len())); + assert!(Folder::new("en").keep_accents().contains("Layer ١٢", "layer 12")); + let digits = ZEROS.iter().map(|&zero| char::from_u32(zero + 7).unwrap()); + assert!(digits.clone().all(|d| d.is_numeric() && ascii_digit(d) == Some('7'))); + assert_eq!(ascii_digit('\u{1D800}'), None); + assert_eq!(ascii_digit('/'), None); + } + #[test] fn accents_count_only_whole_letters() { let f = Folder::new("fr").keep_accents(); From 233e0b7aa0f2d3b1718241df143246dab641e849 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 05:49:38 +0000 Subject: [PATCH 13/43] i18n: plural rules by language, CLDR data for every locale, trn_plain Plural rules are looked up by language alone: CLDR's parent of sr-Latn and zh-Hant is the root, whose only category is other, so sr-latn rows were rejected. The baked data covers every CLDR locale (10 KB): plural rules for he, ro, nb, ca, sk, th, ms and the rest, and decimal symbols for nb-NO, sk-SK, bn-BD, ar-MA and English in Europe. Format applies a Region's separators only where the system set them, and an empty grouping separator writes none. trn_plain writes counts in 0-9 without grouping, for documents and file names. reset_format goes back to the system region. trn_decimal and Format write infinities and NaN as CLDR does and take the other form. Tests that don't set a format use en-US, not the machine's region. The ICU4X crates are pinned to ~2.3, the minor version the data is for. --- crates/i18n/Cargo.toml | 17 ++-- crates/i18n/README.md | 42 ++++---- crates/i18n/data/generate.sh | 15 ++- crates/i18n/data/{percent.py => locales.py} | 18 ++-- crates/i18n/data/locales.rs | 69 ++++++++++++- crates/i18n/data/numbers.postcard | Bin 5664 -> 9553 bytes crates/i18n/src/format.rs | 68 +++++++++---- crates/i18n/src/lib.rs | 103 +++++++++++++++----- 8 files changed, 248 insertions(+), 84 deletions(-) rename crates/i18n/data/{percent.py => locales.py} (75%) diff --git a/crates/i18n/Cargo.toml b/crates/i18n/Cargo.toml index 3daaca103..88e52d027 100644 --- a/crates/i18n/Cargo.toml +++ b/crates/i18n/Cargo.toml @@ -8,15 +8,16 @@ rust-version.workspace = true repository.workspace = true [dependencies] -# Numbers and plural rules, on CLDR data for the shipped locales (data/numbers.postcard). +# Numbers and plural rules, on CLDR data (data/numbers.postcard) generated for one ICU4X minor +# version, hence `~2.3`. fixed_decimal = { version = "0.7.2", default-features = false } -icu_decimal = { version = "2.3", default-features = false, features = ["serde"] } -icu_locale_core = { version = "2.3", default-features = false } -icu_locale_fallback = { version = "2.3", default-features = false, features = ["compiled_data"] } -icu_plurals = { version = "2.3", default-features = false, features = ["serde"] } -icu_provider = { version = "2.3", default-features = false, features = ["sync"] } -icu_provider_adapters = { version = "2.3", default-features = false } -icu_provider_blob = { version = "2.3", default-features = false } +icu_decimal = { version = "~2.3", default-features = false, features = ["serde"] } +icu_locale_core = { version = "~2.3", default-features = false } +icu_locale_fallback = { version = "~2.3", default-features = false, features = ["compiled_data"] } +icu_plurals = { version = "~2.3", default-features = false, features = ["serde"] } +icu_provider = { version = "~2.3", default-features = false, features = ["sync"] } +icu_provider_adapters = { version = "~2.3", default-features = false } +icu_provider_blob = { version = "~2.3", default-features = false } writeable = "0.6" sys-locale = "0.3" diff --git a/crates/i18n/README.md b/crates/i18n/README.md index d9f97cb7e..63eb71840 100644 --- a/crates/i18n/README.md +++ b/crates/i18n/README.md @@ -46,8 +46,7 @@ The app keeps its `tl!` macro, its language setting (`"auto"` → `I18N.system_l translation never errors. - **Lookups:** `t` (plain), `tc` (with a context such as `menu` or `@msg`, else plain), `tid` (an `@id` command id, else the English label), `trn` and `trn_decimal` (a count, `@plural` rows, - `{n}` filled), `get` - (one context along the locale chain, so `pt-br` can return a `pt` row, but never the plain row + `{n}` filled), `get` (one context along the locale chain, so `pt-br` can return a `pt` row, but never the plain row or English: a key cap's name under `key`, or "is there a translation?"), and `fmt` for `{name}` placeholders, filled in one pass. - **Bad rows** (malformed, an unknown `@` context, a wrong plural form count, a row defined twice, @@ -59,8 +58,9 @@ The app keeps its `tl!` macro, its language setting (`"auto"` → `I18N.system_l craft-i18n's checker expects. `trn_decimal` picks the form from the digits shown (ICU4X plural operands), so English `1.0` is plural and French `1,5` singular. A count in a category integers never reach takes the `few` form if there is one (Russian and Ukrainian decimals, `1,5 файла`), - else the last. CLDR's `pt` is Brazilian, so `pt` uses `pt-PT`'s rules (0 is plural) and `pt-br` - `pt`'s. A language outside the baked data (below) has no plural rules and shows English forms. + else the last. Rules are per language, so `sr-latn` and `zh-hant` take `sr`'s and `zh`'s. CLDR's + `pt` is Brazilian, so `pt` uses `pt-PT`'s rules (0 is plural) and `pt-br` `pt`'s. A language + CLDR has no plural rules for shows English forms. - **Names:** a language's name comes from its shared catalog's `@id language.name` row, else its code; English is "English". - **Right to left:** `rtl` is true for `ar`, `ckb`, `dv`, `fa`, `he`, `ps`, `sd`, `ug`, `ur` and @@ -83,24 +83,28 @@ The app keeps its `tl!` macro, its language setting (`"auto"` → `I18N.system_l (`1.234.567`, Indian `12,34,567`), `number(value, decimals)` and `percent(ratio, decimals)` (`50 %`, Turkish `%50`). It follows the region, not the interface language, with the locale's digits (`١٬٢٣٤٫٥` in Egypt, `۱٬۲۳۴` in Iran) or, with `Digits::Latin`, `0`–`9`. With Latin - digits, a decimal or grouping separator the region sets replaces the locale's (English in Poland: - `1.234,5`). `I18n::format` is the format `trn`, `file_size` and `measure` use: the system - region's until `I18n::set_format` (the user's region setting or digit choice). -- **CLDR data:** decimal symbols, digits and plural rules (`data/numbers.postcard`, about 6 KB) and - percent patterns (`data/locales.rs`) cover the languages the catalogs and apps ship, with their - regional variants; `data/generate.sh` lists them and rebuilds both from CLDR. Add a language - there when a catalog adds one. The ICU4X code and data add about 90 KB to a stripped release - build and 80 KB to wasm. + digits, a decimal or grouping separator the system sets (Windows' and macOS's own settings) + replaces the locale's. `I18n::format` is the format `trn`, `file_size` and `measure` use: the + system region's until `I18n::set_format` (the user's region setting or digit choice), and again + after `I18n::reset_format`. A count in `trn` is therefore grouped and in the region's digits + (`1.234 Dateien`, `١٬٢٣٤ ملفًا`); for names written into documents or files (`Copy 1234`), use + `trn_plain`, which writes `0`–`9` without grouping. +- **CLDR data:** decimal symbols, digits and plural rules (`data/numbers.postcard`, about 10 KB) and + percent patterns (`data/locales.rs`) cover every CLDR locale; `data/generate.sh` rebuilds both + from CLDR. The ICU4X crates are pinned to one minor version (`~2.3`), the one the data is + generated for. The ICU4X code and data add about 90 KB to a stripped release build and 80 KB to + wasm. - **Formatting region:** `Region` holds how numbers, times and measurements are written, apart from the interface language: the number and time locales (BCP 47), metric or US measurement, A4 or Letter, and the decimal and grouping separators (or no grouping) and clock when the system - 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-`. Ported from WordCraft's - `Region`. + 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-`. + 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 translation. `looked_up()` lists the source texts looked up so far, for an audit against a diff --git a/crates/i18n/data/generate.sh b/crates/i18n/data/generate.sh index 695a698ae..91b6ab94f 100755 --- a/crates/i18n/data/generate.sh +++ b/crates/i18n/data/generate.sh @@ -1,23 +1,22 @@ #!/usr/bin/env bash -# Regenerates the number and plural data in this directory from Unicode CLDR, for the languages -# the craft-i18n catalogs and the apps ship (each with its regional variants). +# Regenerates the number and plural data in this directory from Unicode CLDR, for every CLDR +# locale: 10 KB, of which CLDR's modern-coverage locales alone would be 7 KB. Lookups fall back +# at run time, so a locale whose data matches its parent's is left out. # -# Needs `cargo install icu4x-datagen --version 2.3.0` (the ICU4X version in Cargo.toml) and -# python3. Downloads CLDR's JSON release once into $CACHE. +# Needs `cargo install icu4x-datagen --version 2.3.0` (the ICU4X minor version in Cargo.toml) +# and python3. Downloads CLDR's JSON release once into $CACHE. set -euo pipefail CLDR=48.2.1 -LANGUAGES=(en ar bg cs da de el es et fa fi fr hi hr hu id it ja ka kk ko lt nl pl pt ru sl sr sv te tr uk vi zh) HERE=$(cd "$(dirname "$0")" && pwd) CACHE=${CACHE:-${XDG_CACHE_HOME:-$HOME/.cache}/craft-i18n-data} mkdir -p "$CACHE" -# `sr-Latn` and `zh-Hant` are named as well: their CLDR parent is the root, not `sr` or `zh`. TMPDIR=$CACHE icu4x-datagen --format blob --overwrite \ --cldr-tag "$CLDR" \ --markers DecimalSymbolsV1 DecimalDigitsV1 PluralsCardinalV1 \ - --locales "${LANGUAGES[@]}" sr-Latn zh-Hant \ + --locales full --deduplication maximal \ --out "$HERE/numbers.postcard" ZIP=$CACHE/icu4x-source-cache/github.com/unicode-org/cldr-json/releases/download/$CLDR/cldr-$CLDR-json-full.zip -python3 -I "$HERE/percent.py" "$ZIP" "$CLDR" "${LANGUAGES[@]}" > "$HERE/locales.rs" +python3 -I "$HERE/locales.py" "$ZIP" "$CLDR" > "$HERE/locales.rs" diff --git a/crates/i18n/data/percent.py b/crates/i18n/data/locales.py similarity index 75% rename from crates/i18n/data/percent.py rename to crates/i18n/data/locales.py index 3c00bb449..337bcfb90 100644 --- a/crates/i18n/data/percent.py +++ b/crates/i18n/data/locales.py @@ -1,6 +1,7 @@ -"""Writes locales.rs: the covered languages and the percent affixes from CLDR's JSON release. +"""Writes locales.rs from CLDR's JSON release: the languages with plural rules and every locale's +percent affixes. -Usage: percent.py ... +Usage: locales.py """ import json @@ -21,12 +22,14 @@ def affixes(numbers, numsys): def main(): - path, version, languages = sys.argv[1], sys.argv[2], sys.argv[3:] + path, version = sys.argv[1], sys.argv[2] found = {} with zipfile.ZipFile(path) as z: + plurals = json.loads(z.read("cldr-core/supplemental/plurals.json"))["supplemental"]["plurals-type-cardinal"] + languages = sorted(l for l in plurals if l != "root" and "-" not in l) for name in z.namelist(): m = re.fullmatch(r"cldr-numbers-full/main/([^/]+)/numbers\.json", name) - if not m or m.group(1).split("-")[0] not in languages: + if not m or m.group(1) == "root": continue locale = m.group(1) numbers = json.loads(z.read(name))["main"][locale]["numbers"] @@ -49,8 +52,11 @@ def main(): print(f"// @generated by generate.sh from Unicode CLDR {version} (Unicode License).") print() - print("/// The languages the baked number and plural data cover, each with its regional variants.") - print("pub(crate) const LANGUAGES: &[&str] = &[" + ", ".join(rust(l) for l in languages) + "];") + print("/// The languages CLDR has plural rules for; the baked data has them all.") + print("pub(crate) const PLURAL_LANGUAGES: &[&str] = &[") + for i in range(0, len(languages), 16): + print(" " + ", ".join(rust(l) for l in languages[i : i + 16]) + ",") + print("];") print() print("/// Percent signs around the number (CLDR's standard percent pattern and sign): locale, Latin") print("/// digits or the locale's own, text before the number, text after it. A locale missing here") diff --git a/crates/i18n/data/locales.rs b/crates/i18n/data/locales.rs index a2ad6e72b..205a96610 100644 --- a/crates/i18n/data/locales.rs +++ b/crates/i18n/data/locales.rs @@ -1,7 +1,22 @@ // @generated by generate.sh from Unicode CLDR 48.2.1 (Unicode License). -/// The languages the baked number and plural data cover, each with its regional variants. -pub(crate) const LANGUAGES: &[&str] = &["en", "ar", "bg", "cs", "da", "de", "el", "es", "et", "fa", "fi", "fr", "hi", "hr", "hu", "id", "it", "ja", "ka", "kk", "ko", "lt", "nl", "pl", "pt", "ru", "sl", "sr", "sv", "te", "tr", "uk", "vi", "zh"]; +/// The languages CLDR has plural rules for; the baked data has them all. +pub(crate) const PLURAL_LANGUAGES: &[&str] = &[ + "af", "ak", "am", "an", "ar", "ars", "as", "asa", "ast", "az", "bal", "be", "bem", "bez", "bg", "bho", + "blo", "bm", "bn", "bo", "br", "brx", "bs", "ca", "ce", "ceb", "cgg", "chr", "ckb", "cs", "csw", "cv", + "cy", "da", "de", "doi", "dsb", "dv", "dz", "ee", "el", "en", "eo", "es", "et", "eu", "fa", "ff", + "fi", "fil", "fo", "fr", "fur", "fy", "ga", "gd", "gl", "gsw", "gu", "guw", "gv", "ha", "haw", "he", + "hi", "hnj", "hr", "hsb", "hu", "hy", "ia", "id", "ie", "ig", "ii", "io", "is", "it", "iu", "ja", + "jbo", "jgo", "jmc", "jv", "jw", "ka", "kab", "kaj", "kcg", "kde", "kea", "kk", "kkj", "kl", "km", "kn", + "ko", "kok", "ks", "ksb", "ksh", "ku", "kw", "ky", "lag", "lb", "lg", "lij", "lkt", "lld", "ln", "lo", + "lt", "lv", "mas", "mg", "mgo", "mk", "ml", "mn", "mo", "mr", "ms", "mt", "my", "nah", "naq", "nb", + "nd", "ne", "nl", "nn", "nnh", "no", "nqo", "nr", "nso", "ny", "nyn", "om", "or", "os", "osa", "pa", + "pap", "pcm", "pl", "prg", "ps", "pt", "rm", "ro", "rof", "ru", "rwk", "sah", "saq", "sat", "sc", "scn", + "sd", "sdh", "se", "seh", "ses", "sg", "sgs", "sh", "shi", "si", "sk", "sl", "sma", "smi", "smj", "smn", + "sms", "sn", "so", "sq", "sr", "ss", "ssy", "st", "su", "sv", "sw", "syr", "ta", "te", "teo", "th", + "ti", "tig", "tk", "tl", "tn", "to", "tpi", "tr", "ts", "tzm", "ug", "uk", "und", "ur", "uz", "ve", + "vec", "vi", "vo", "vun", "wa", "wae", "wo", "xh", "xog", "yi", "yo", "yue", "zh", "zu", +]; /// Percent signs around the number (CLDR's standard percent pattern and sign): locale, Latin /// digits or the locale's own, text before the number, text after it. A locale missing here @@ -31,11 +46,27 @@ pub(crate) const PERCENT: &[(&str, bool, &str, &str)] = &[ ("ar-SY", false, "", "\u{66a}\u{61c}"), ("ar-TD", false, "", "\u{66a}\u{61c}"), ("ar-YE", false, "", "\u{66a}\u{61c}"), + ("az-Arab", false, "", "\u{66a}"), + ("bas", true, "", "\u{a0}%"), + ("be", true, "", "\u{a0}%"), + ("bgn", false, "", "\u{66a}"), + ("bgn", true, "", "\u{66a}"), + ("blo", true, "%\u{a0}", ""), + ("br", true, "", "\u{a0}%"), + ("bua", true, "", "\u{a0}%"), + ("ca", true, "", "\u{a0}%"), + ("ce", true, "", "\u{a0}%"), + ("ckb", false, "", "\u{a0}\u{66a}"), ("cs", true, "", "\u{a0}%"), + ("cv", true, "", "\u{a0}%"), ("da", true, "", "\u{a0}%"), ("de", true, "", "\u{a0}%"), ("de-CH", true, "", "%"), ("de-LI", true, "", "%"), + ("dsb", true, "", "\u{a0}%"), + ("dua", true, "", "\u{a0}%"), + ("dz", false, "", "\u{a0}%"), + ("dz", true, "", "\u{a0}%"), ("en-AT", true, "", "\u{a0}%"), ("en-DE", true, "", "\u{a0}%"), ("en-DK", true, "", "\u{a0}%"), @@ -66,14 +97,48 @@ pub(crate) const PERCENT: &[(&str, bool, &str, &str)] = &[ ("es-US", true, "", "%"), ("es-UY", true, "", "%"), ("es-VE", true, "", "%"), + ("eu", true, "%\u{a0}", ""), ("fa", false, "", "\u{66a}"), ("fi", true, "", "\u{a0}%"), + ("fo", true, "", "\u{a0}%"), ("fr", true, "", "\u{a0}%"), ("fr-CH", true, "", "%"), + ("gl", true, "", "\u{a0}%"), + ("gsw", true, "", "\u{a0}%"), ("hr", true, "", "\u{a0}%"), + ("hsb", true, "", "\u{a0}%"), + ("ht", true, "", "\u{a0}%"), + ("kl", true, "", "\u{a0}%"), + ("ks", false, "", "\u{66a}"), + ("ksh", true, "", "\u{a0}%"), + ("ku", true, "%", ""), + ("ku-Arab", true, "", "%"), + ("lb", true, "", "\u{a0}%"), + ("lrc", false, "", "\u{66a}"), ("lt", true, "", "\u{a0}%"), + ("mk", true, "", "\u{a0}%"), + ("mzn", false, "", "\u{66a}"), + ("nb", true, "", "\u{a0}%"), + ("nds", true, "", "\u{a0}%"), + ("nn", true, "", "\u{a0}%"), + ("no", true, "", "\u{a0}%"), + ("oc", true, "", "\u{202f}%"), + ("pa-Arab", false, "", "\u{66a}"), + ("ps", false, "", "\u{66a}"), + ("qu", true, "", "\u{a0}%"), + ("rn", true, "", "\u{a0}%"), + ("ro", true, "", "\u{a0}%"), ("ru", true, "", "\u{a0}%"), + ("sd", false, "", "\u{66a}\u{61c}"), + ("sdh", false, "", "\u{66a}\u{61c}"), + ("se", true, "", "\u{a0}%"), + ("sk", true, "", "\u{a0}%"), ("sl", true, "", "\u{a0}%"), + ("smn", true, "", "\u{a0}%"), ("sv", true, "", "\u{a0}%"), + ("tk", true, "", "\u{a0}%"), ("tr", true, "%", ""), + ("tt", true, "", "\u{a0}%"), + ("uz-Arab", false, "", "\u{66a}"), + ("zgh", true, "", "\u{a0}%"), ]; diff --git a/crates/i18n/data/numbers.postcard b/crates/i18n/data/numbers.postcard index 53764d4cc9c73c84627d11699de9e5f564923c92..5ee84e037e3467ca23300e955e2ba8ea6846436b 100644 GIT binary patch literal 9553 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 A*Z=?k literal 5664 zcmcJTTTola8OP7rBgEC%*sdMNvSJg!iH!)()wy&WA%sMP5FoB@XHwZvqacJw2-j|> zjbwwtsgtblCP^FLu&gSSD1_J!w6zaVr&(ZF0zuoLdJZzYsb#kwjZ{;pX=~= zcl)~iJ$ru4aHVP0D;<|MGiU1x>At5;&+h1*nlIRDEcfw}`5k(Lpmq6NJ9zPYm$9te zT=|Tx%I>JCb=J2C@k>;$EZq|wN6!D@Z)vOVfAW+&+TA zetS)cGo{9uQZsEVcQ{+}@*gTsY5F!QUn{g>#ST~&vkNT1LJzGn{V}ayZr)V%dHl+%IwyQ@(wG=RKpzYO^=D#2a&! zdcE4p3#QB#n;jjMKIk`Tu5&UCFMy$&#-#0a6jk|5F^k^E?*>+5cQ&-dhRa>HT!~hc zh^dS%+mNJuUT~w_TxG9z)W_$h&TIqPbdzR#4aQRZDRX5tMO33qn~c^Uaekw{P_tUMlMi zS`WR<9xzt$Qhn@^Z{kIUkC7WnBj!jO)zq@GbSurH=cj!y4h~(4Oq(kzpQ%#zuZYKg zZWznyH-U~@Mph1{8mnJRcf2ea-KUKe)it$?`_=OzJ1=VBIvi%~Y^}0dM99Td}!bE!FCC123xJMF>CT_W663uUX4C(285z_PF@^Dq8mj zzR^%-y{O zuElC_Z@{Ai*}`MJ@?RA%?7_%WWj1>QMrc773)=A;Q_c&Fyl~Rv+rvuveF#5YW~#C` zOWAAnMy}iE^BC;dZ(1ncUuDe_Ds9RUK*={5WMhTY%r{l?O>&zrlW)-b+dMt|UdSfA z>EsSC25IH_Zmw&`0n!^X+g)CFYuiqK|D@IKtnHL-F1(KPiCVAR?Z3w$TkT71$<;Wp z3(jqU>@CTSvZG~Dz~nYd@`pd)>2CpzHs`Jln@S&i^1ItAZQFma%k6u4SFrmK-udE- z{ZsTZ7nv07-``KiUt|aSdnet#9z47h#NLa60at)FT4cdsz~ATT$EJxd95B?>5A+GJQ>WaRAYb?+mRm;$N&Za5wk~_!O8$ zK{yR2z)euY1mQe*RuY7d!C{Rc+yJfUcolpI{sA(zg0K-dz>mP|;0E{-tWFn%ZD1F8 z8C(W)AU#769so_?c@P30fq#R0G6lf`_JIia3-}U=6)GMey(Ea+; zWlIyE<-`*nu;|!@jA0-Qa6MiTAN5-vV0F>}%7`?oGUB&9ke(qg*O2E#1_Q15KnY$! zro4Leh!ff_UHXCKLZ?V}?Hv2Z49b>cau-VIO_6nQ5%H{%WF39Hgv$+Fou9waA zvAKh6uAj{fu(?Ch&7q4R1TKLv_!WqNH^4CXH5id@hTw(Zh2VwYh2VwYh2VwYh2VwY zh2e$ah2e$ah2e$ah2e$ah2e$aMc_r?Mc_r?Mc_r?Mc_r?Mc_r?4Z|CTHwt`fH$Hx^*QW-Fo*h?uVq$UwswN$EDBvhVl6T z?h)zM)n7jj5=Br=!1*iy8v7d?q zRP<8ON5w%Z`l%S8;t+}Hnh#bB-|GA3yZn~OKnw_QmR z7ivnru7)L?l9XO*O_EbgUoa)M5V@pSiJV%KG3?v1N*- zh4oorB(5!!B#kCb``N(J&Bf|k?oh|_)Etx)$G^H^*gU0ky3#o@@SQ5zN=ee<3;rjs z89sS!^Gsys4f-FXh_F(l$;ime`YiC00W3`6w)%K7Byp8io0XNF0~do*k3`E_YRSo6 MXi-POcP0$, - /// The region's separators where the system set them apart from the locale's. + /// The system's separators where they differ from the locale's; an empty `group` is none. group: Option, point: Option, percent: (&'static str, &'static str), @@ -37,8 +37,7 @@ pub struct Format { impl Format { /// The format for `region`'s number locale. With Latin digits, a decimal or grouping separator - /// the region sets (Windows' and macOS's own settings, English in continental Europe) replaces - /// the locale's. + /// the region carries (Windows' and macOS's own settings) replaces the locale's. pub fn new(region: &Region, digits: Digits) -> Format { let mut locale = Locale::try_from_str(®ion.numbers).unwrap_or(Locale::UNKNOWN); if digits == Digits::Latin { @@ -50,15 +49,14 @@ impl Format { let probe = format.parts(&Decimal::try_from_str("-1234567.5").unwrap_or_default()); let latin = probe.integer.starts_with(|c: char| c.is_ascii_digit()); if latin { - let point = region.decimal.to_string(); - if probe.point != point { + if let Some(point) = region.decimal.map(String::from).filter(|p| *p != probe.point) { // `1,234.5` with a decimal comma becomes `1.234,5`, never `1,234,5`. if probe.group == point && region.grouping.is_none() { format.group = Some(probe.point.clone()); } format.point = Some(point); } - if let Some(g) = region.grouping.map(String::from).filter(|g| *g != probe.group) { + if let Some(g) = region.grouping.map(|g| g.map(String::from).unwrap_or_default()).filter(|g| *g != probe.group) { format.group = Some(g); } } @@ -76,12 +74,12 @@ impl Format { self.write(&Decimal::from(n.into())) } - /// `value` rounded to `decimals` digits after the decimal separator. - /// Infinities and NaN are written as Rust writes them. + /// `value` rounded to `decimals` digits after the decimal separator; infinities and NaN are + /// `∞`, `-∞` and `NaN`. pub fn number(&self, value: f64, decimals: usize) -> String { match to_decimal(value, decimals) { Some(d) => self.write(&d), - None => value.to_string(), + None => non_finite(value).to_owned(), } } @@ -89,7 +87,7 @@ impl Format { /// `50%`, `50 %` (French, German), `%50` (Turkish), `٥٠٪؜` (Egyptian Arabic). pub fn percent(&self, ratio: f64, decimals: usize) -> String { let Some(d) = to_decimal(ratio * 100.0, decimals) else { - return format!("{}%", ratio * 100.0); + return format!("{}{}{}", self.percent.0, non_finite(ratio), self.percent.1); }; let p = self.parts(&d); format!("{}{}{}{}", p.sign, self.percent.0, p.text, self.percent.1) @@ -200,6 +198,15 @@ pub(crate) fn to_decimal(value: f64, decimals: usize) -> Option { Some(d) } +/// CLDR's root symbols for a value that isn't finite. +fn non_finite(value: f64) -> &'static str { + match value { + f64::INFINITY => "∞", + f64::NEG_INFINITY => "-∞", + _ => "NaN", + } +} + /// The percent affixes for a number locale (`ar-EG`), found by dropping trailing subtags. fn percent_affixes(tag: &str, latin: bool) -> (&'static str, &'static str) { let mut tag = tag; @@ -248,17 +255,39 @@ mod tests { } #[test] - fn region_separators() { - // English in Poland writes a decimal comma; the grouping moves out of its way. - assert_eq!(Format::from_tag("en-PL").number(1234.5, 1), "1.234,5"); + fn cldr_separators_for_every_region() { + for (tag, want) in [ + ("en-PL", "1.234.567,5"), + ("en-DE", "1.234.567,5"), + ("ar-MA", "1.234.567,5"), + ("de-CH", "1'234'567.5"), + ("nb-NO", "1\u{a0}234\u{a0}567,5"), + ("sk-SK", "1\u{a0}234\u{a0}567,5"), + ("bn-BD", "১২,৩৪,৫৬৭.৫"), + ("he-IL", "1,234,567.5"), + ("km", "1,234,567.5"), + ("lo", "1.234.567,5"), + ] { + assert_eq!(Format::from_tag(tag).number(1_234_567.5, 1), want, "{tag}"); + } + } + + #[test] + fn system_separators() { + // A decimal comma the system sets moves the grouping out of its way. + let mut region = Region::from_tag("en-US"); + region.decimal = Some(','); + assert_eq!(Format::new(®ion, Digits::Locale).number(1234.5, 1), "1.234,5"); let mut region = Region::from_tag("de-DE"); - region.grouping = Some('\u{a0}'); + region.grouping = Some(Some('\u{a0}')); assert_eq!(Format::new(®ion, Digits::Locale).number(1234.5, 1), "1\u{a0}234,5"); - region.decimal = '.'; + region.decimal = Some('.'); assert_eq!(Format::new(®ion, Digits::Locale).number(1234.5, 1), "1\u{a0}234.5"); + region.grouping = Some(None); + assert_eq!(Format::new(®ion, Digits::Locale).integer(1_234_567), "1234567", "no grouping"); // A system's separators don't apply to Arabic-Indic digits. let mut region = Region::from_tag("ar-EG"); - region.grouping = Some(','); + region.grouping = Some(Some(',')); assert_eq!(Format::new(®ion, Digits::Locale).number(1234.5, 1), "١٬٢٣٤٫٥"); } @@ -280,7 +309,10 @@ mod tests { let en = Format::from_tag("en"); assert_eq!(en.number(-1234.5, 0), "-1,234"); assert_eq!(en.number(-0.04, 1), "0.0", "no sign on zero"); - assert_eq!(en.number(f64::INFINITY, 1), "inf"); + assert_eq!(en.number(f64::INFINITY, 1), "∞"); + assert_eq!(en.number(f64::NEG_INFINITY, 1), "-∞"); + assert_eq!(en.number(f64::NAN, 1), "NaN"); + assert_eq!(Format::from_tag("de").percent(f64::NAN, 0), "NaN\u{a0}%"); assert_eq!(en.integer(-5_i64), "-5"); } } diff --git a/crates/i18n/src/lib.rs b/crates/i18n/src/lib.rs index 910041edf..a3924fe91 100644 --- a/crates/i18n/src/lib.rs +++ b/crates/i18n/src/lib.rs @@ -3,6 +3,11 @@ //! An app embeds the [craft-i18n](https://github.com/benandez/craft-i18n) catalogs at build time //! as an [`Embedded`] static, builds one [`I18n`] from it, and looks English source text up in a //! [`Lang`]. A missing translation is the English itself; nothing here fails. +//! +//! Counts ([`I18n::trn`]), file sizes and measures write their numbers as the formatting +//! [`Region`] does ([`I18n::format`]): grouped (`1.234`, `12,34,567`) and in the region's digits +//! (`١٬٢٣٤`). Text that goes into documents or file names should use [`I18n::trn_plain`], which +//! writes `0`–`9` without grouping. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod catalog; @@ -10,7 +15,7 @@ mod format; mod region; mod size; -/// The CLDR data for the shipped languages, from `data/generate.sh`. +/// The CLDR number and plural data for every CLDR locale, from `data/generate.sh`. mod data { use std::sync::OnceLock; @@ -189,25 +194,34 @@ impl I18n { self.find(lang, &[("@id", id), ("", label)]).unwrap_or(label) } - /// A count's wording in `lang`, `{n}` filled in as [`I18n::format`] writes numbers. `one` and - /// `other` are the English forms; they are also the key of the `@plural` row, and the fallback. + /// A count's wording in `lang`, `{n}` filled in as [`I18n::format`] writes numbers: grouped and + /// in the region's digits. `one` and `other` are the English forms; they are also the key of + /// the `@plural` row, and the fallback. pub fn trn(&self, lang: Lang, n: u64, one: &str, other: &str) -> String { let format = self.format(); - self.plural(lang, PluralOperands::from(n), n == 1, &format.integer(n), one, other) + self.plural(lang, Some(PluralOperands::from(n)), n == 1, &format.integer(n), one, other) + } + + /// [`I18n::trn`] with `{n}` in `0`–`9` without grouping (`Copy 1234`), for text that goes into + /// documents, file names or anything read back later. + pub fn trn_plain(&self, lang: Lang, n: u64, one: &str, other: &str) -> String { + self.plural(lang, Some(PluralOperands::from(n)), n == 1, &n.to_string(), one, other) } /// [`I18n::trn`] for a count with `decimals` digits after the decimal separator (`1.5 files`). - /// The plural form follows the digits shown, so `1.0` takes English `other`. + /// The plural form follows the digits shown, so `1.0` takes English `other`; infinities and NaN + /// (`∞`, `NaN`) take `other` too. pub fn trn_decimal(&self, lang: Lang, n: f64, decimals: usize, one: &str, other: &str) -> String { let format = self.format(); let Some(d) = format::to_decimal(n, decimals) else { - return fmt(other, &[("n", &n.to_string())]); + return self.plural(lang, None, false, &format.number(n, decimals), one, other); }; let english_one = d.to_string() == "1"; - self.plural(lang, PluralOperands::from(&d), english_one, &format.write(&d), one, other) + self.plural(lang, Some(PluralOperands::from(&d)), english_one, &format.write(&d), one, other) } - fn plural(&self, lang: Lang, operands: PluralOperands, english_one: bool, n: &str, one: &str, other: &str) -> String { + /// `operands` `None` is the `other` category. + fn plural(&self, lang: Lang, operands: Option, english_one: bool, n: &str, one: &str, other: &str) -> String { let key = format!("{one}|{other}"); let chain = self.locale(lang).map_or(&[][..], |l| l.chain.as_slice()); let text = chain @@ -216,7 +230,7 @@ impl I18n { let locale = self.locales.get(i)?; let forms = locale.rows.get("@plural")?.get(&key)?; let (rules, categories) = locale.plural.as_ref()?; - let category = rules.category_for(operands); + let category = operands.map_or(PluralCategory::Other, |o| rules.category_for(o)); // A category whole numbers up to 1000 never reach has no form of its own. Russian // and Ukrainian decimals (`other`) take the `few` form (`1,5 файла`); any other // (French `many` for a million) takes the last. @@ -236,7 +250,7 @@ impl I18n { } /// How counts, file sizes and measures write numbers: [`Region::system`]'s format with the - /// locale's digits until [`I18n::set_format`]. + /// locale's digits until [`I18n::set_format`], and again after [`I18n::reset_format`]. pub fn format(&self) -> Arc { if let Some(f) = self.format.read().unwrap_or_else(PoisonError::into_inner).as_ref() { return Arc::clone(f); @@ -250,6 +264,11 @@ impl I18n { *self.format.write().unwrap_or_else(PoisonError::into_inner) = Some(Arc::new(format)); } + /// Write numbers as the system region does again, undoing [`I18n::set_format`]. + pub fn reset_format(&self) { + *self.format.write().unwrap_or_else(PoisonError::into_inner) = None; + } + /// The languages to offer: English first, then the offered locales by code. Never the /// pseudo locale. pub fn languages(&self) -> Vec { @@ -432,15 +451,18 @@ fn bare(code: &str) -> Locale { } /// `code`'s CLDR cardinal rules and the categories whole numbers 0..=1000 reach, in CLDR order -/// (zero, one, two, few, many, other): one `@plural` form each. Regional and scripted codes fall -/// back to the language's rules. CLDR's `pt` is Brazilian (0 is singular), so Portuguese other -/// than `pt-br` takes `pt-PT`'s. `None` for a language the baked data doesn't cover. +/// (zero, one, two, few, many, other): one `@plural` form each. The rules are looked up by +/// language alone: CLDR's parent of `sr-Latn` and `zh-Hant` is the root, whose only category is +/// `other`. CLDR's `pt` is Brazilian (0 is singular), so Portuguese other than `pt-br` takes +/// `pt-PT`'s. `None` for a language CLDR has no plural rules for. fn plural_rules(code: &str) -> Option<(PluralRules, Vec)> { - let code = if code == "pt" || (code.starts_with("pt-") && code != "pt-br") { "pt-pt" } else { code }; - let locale = IcuLocale::try_from_str(code).ok()?; - if !data::LANGUAGES.contains(&locale.id.language.as_str()) { + let id = IcuLocale::try_from_str(code).ok()?.id; + let language = id.language.as_str(); + if !data::PLURAL_LANGUAGES.contains(&language) { return None; } + let brazil = id.region.is_some_and(|r| r.as_str() == "BR"); + let locale = IcuLocale::try_from_str(if language == "pt" && !brazil { "pt-PT" } else { language }).ok()?; let rules = PluralRules::try_new_cardinal_with_buffer_provider(data::provider()?, (&locale).into()).ok()?; let mut categories = Vec::new(); for n in 0..=1000_u32 { @@ -475,10 +497,12 @@ mod tests { ("de", "\tBad\n@nope\tA\tB\n\tUndo\tRückgängig\n", ""), ]; - /// `embedded`, offering every locale in it. + /// `embedded`, offering every locale in it, with `en-US` numbers rather than the machine's. fn all(embedded: &'static Embedded) -> (I18n, Vec) { let codes: Vec<&str> = embedded.iter().map(|(c, _, _)| *c).collect(); - I18n::new(embedded, &codes) + let (i, problems) = I18n::new(embedded, &codes); + i.set_format(Format::from_tag("en-US")); + (i, problems) } fn i18n() -> I18n { @@ -610,6 +634,20 @@ mod tests { assert_eq!(i.trn(Lang::EN, 12_345, "{n} file", "{n} files"), "12.345 files"); } + #[test] + fn plain_counts_and_the_system_format() { + let i = all(P).0; + i.set_format(Format::from_tag("ar-EG")); + assert_eq!(i.trn_plain(lang(&i, "ar"), 1234, "{n} file", "{n} files"), "1234 ملفًا"); + i.set_format(Format::from_tag("de-DE")); + assert_eq!(i.trn_plain(Lang::EN, 12_345, "copy {n}", "copy {n}"), "copy 12345"); + i.reset_format(); + assert_eq!( + i.trn(Lang::EN, 5, "{n} file", "{n} files"), + fmt("{n} files", &[("n", &Format::new(&Region::system(), Digits::Locale).integer(5))]) + ); + } + #[test] fn decimal_counts() { let i = all(P).0; @@ -627,6 +665,9 @@ mod tests { let ar = lang(&i, "ar"); assert_eq!(i.trn_decimal(ar, 2.5, 1, "{n} file", "{n} files"), "٢٫٥ ملف"); assert_eq!(i.trn_decimal(ar, 3.0, 0, "{n} file", "{n} files"), "٣ ملفات"); + // Not a number: the translated `other` form. + assert_eq!(i.trn_decimal(ru, f64::NAN, 1, "{n} file", "{n} files"), "NaN файла"); + assert_eq!(i.trn_decimal(Lang::EN, f64::INFINITY, 1, "{n} file", "{n} files"), "∞ files"); } static L: &Embedded = @@ -777,15 +818,31 @@ mod tests { assert_eq!(i.trn(lang(&i, "pt-br"), 0, "{n} file", "{n} files"), "0 arquivo"); } - /// The same counts as craft-i18n's checker (`plural_form_counts`), which rejects a catalog - /// whose `@plural` rows list any other. + /// The counts craft-i18n's checker expects, which rejects a catalog whose `@plural` rows list + /// any other: every directory in craft-i18n's `locales/`, then languages it may add. #[test] fn plural_form_counts() { - for (locale, n) in - [("en", 2), ("fr", 2), ("ja", 1), ("vi", 1), ("ru", 3), ("pl", 3), ("cs", 3), ("sl", 4), ("ar", 6), ("pt", 2), ("pt-br", 2), ("tr", 2)] - { + #[rustfmt::skip] + let catalogs = [ + ("ar", 6), ("bg", 2), ("cs", 3), ("da", 2), ("de", 2), ("el", 2), ("es", 2), ("et", 2), + ("fi", 2), ("fr", 2), ("hr", 3), ("hu", 2), ("id", 1), ("it", 2), ("ja", 1), ("ka", 2), + ("kk", 2), ("ko", 1), ("lt", 3), ("nl", 2), ("pl", 3), ("pt-br", 2), ("ru", 3), ("sl", 4), + ("sr", 3), ("sr-latn", 3), ("sv", 2), ("te", 2), ("tr", 2), ("uk", 3), ("vi", 1), + ("zh-hans", 1), ("zh-hant", 1), + ]; + let more = [("en", 2), ("pt", 2), ("he", 3), ("ro", 3), ("nb", 2), ("ca", 2), ("sk", 3), ("th", 1), ("ms", 1), ("fa", 2), ("hi", 2)]; + for (locale, n) in catalogs.into_iter().chain(more) { assert_eq!(plural_rules(locale).map(|(_, c)| c.len()), Some(n), "{locale}"); } + assert!(plural_rules("tlh").is_none(), "no CLDR rules"); + } + + #[test] + fn serbian_latin_plurals() { + let i = all(&[("sr-latn", "@plural\t{n} file|{n} files\t{n} fajl|{n} fajla|{n} fajlova\n", "")]).0; + let sr = lang(&i, "sr-latn"); + let files = |n| i.trn(sr, n, "{n} file", "{n} files"); + assert_eq!((files(1), files(3), files(5)), ("1 fajl".into(), "3 fajla".into(), "5 fajlova".into())); } #[test] From f2ab6dc7f3df71ad523a465e0ab5b0483ee323d0 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 05:49:46 +0000 Subject: [PATCH 14/43] i18n: rewrap the Region README paragraph --- crates/i18n/README.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/crates/i18n/README.md b/crates/i18n/README.md index 7520f4aeb..85f75c8bf 100644 --- a/crates/i18n/README.md +++ b/crates/i18n/README.md @@ -71,13 +71,14 @@ The app keeps its `tl!` macro, its language setting (`"auto"` → `I18N.system_l - **Formatting region:** `Region` holds how numbers, times and measurements are written, apart from the interface language: the number and time locales (BCP 47), metric or US measurement, A4 or Letter, and the decimal and grouping separators (or no grouping) and clock when the system - 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-`. Ported from WordCraft's - `Region`. + 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-`. + 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 translation. `looked_up()` lists the source texts looked up so far, for an audit against a From b010389df25cbb0b0425b48e4c19d554e6ca8d91 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 05:54:08 +0000 Subject: [PATCH 15/43] i18n: Format::parse on the format's own marks; lone decimal marks Format::parse reads what a Format writes: its digits, its decimal and grouping separators (the system's included) and the locale's Latin marks in an Arabic-Indic format, plus every digit set. parse_number is Format::from_tag(locale).parse, so the decimal marks come from CLDR's data alone and the hand-made table is gone. A lone . in a comma locale is the decimal mark when it can't be grouping (1.5, 0.250, 1234.5, .5); 1.234 and 1.234.567 stay grouped in German. The same holds for , in a point locale. Percent signs and units are documented as the caller's to strip. --- crates/i18n/README.md | 14 +- crates/i18n/src/format.rs | 14 +- crates/i18n/src/number.rs | 271 ++++++++++++++++++++++++-------------- 3 files changed, 191 insertions(+), 108 deletions(-) diff --git a/crates/i18n/README.md b/crates/i18n/README.md index d2cc7fdaf..e486ae02a 100644 --- a/crates/i18n/README.md +++ b/crates/i18n/README.md @@ -65,12 +65,16 @@ The app keeps its `tl!` macro, its language setting (`"auto"` → `I18N.system_l code; English is "English". - **Right to left:** `rtl` is true for `ar`, `ckb`, `dv`, `fa`, `he`, `ps`, `sd`, `ug`, `ur` and `yi`; putting text into display order is the app's job. -- **Reading numbers:** `parse_number(text, locale)` reads what people type or paste: any Unicode +- **Reading numbers:** `Format::parse(text)` reads what the format writes (its digits, decimal + and grouping separators, the system's included) and what people type or paste: any Unicode decimal digits (ASCII, Arabic-Indic, Persian, Devanagari, Bengali, Thai, full-width, …) from one - set; the locale's decimal mark (`.` or `,`) and `٫` everywhere; grouping by the locale's other - mark, spaces (NBSP, narrow NBSP), `'`, `’` or `٬`, in threes or the Indian 2-2-3; a leading `-`, - `−` or `+`; bidi marks ignored. Input that doesn't fit is an error, never a guess: `1,234` is - 1234 in English, 1.234 in German and an error for a locale whose decimal mark isn't known. + set; `٫` as a decimal mark everywhere; grouping by the locale's other mark of `.` and `,`, spaces + (NBSP, narrow NBSP), `'`, `’` or `٬`, in threes or the Indian 2-2-3; a leading `-`, `−` or `+`; + bidi marks ignored. A lone `.` in a comma locale is decimal when it can't be grouping (`1.5`, + `0.250`, `1234.5`), while `1.234` stays 1234 in German; the same holds for `,` in a point + locale. Percent signs and units (`50 %`, `12 px`) are not read: strip them first. Other input + that doesn't fit is an error, never a guess. `parse_number(text, locale)` is the same for + `Format::from_tag(locale)`. - **System locale:** `system_lang` matches the system's list once and caches it: on desktop Unix `LANGUAGE`, `LC_ALL`, `LC_MESSAGES` and `LANG`, read raw; elsewhere `sys-locale`'s (macOS and Windows preferred languages, the browser's on the web). `fr_CA.UTF-8` → `fr`, `zh-TW` → diff --git a/crates/i18n/src/format.rs b/crates/i18n/src/format.rs index 6def7d18c..bcb20d052 100644 --- a/crates/i18n/src/format.rs +++ b/crates/i18n/src/format.rs @@ -11,6 +11,7 @@ use writeable::{Part, PartsWrite, Writeable}; use crate::Region; use crate::data::{self, PERCENT}; +use crate::number::Marks; /// Which digits numbers are written in. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] @@ -33,6 +34,8 @@ pub struct Format { group: Option, point: Option, percent: (&'static str, &'static str), + /// The marks [`Format::parse`] reads. + pub(crate) marks: Marks, } impl Format { @@ -44,7 +47,7 @@ impl Format { locale.extensions.unicode.keywords.set(key!("nu"), value!("latn")); } let decimal = data::provider().and_then(|p| DecimalFormatter::try_new_with_buffer_provider(p, (&locale).into(), Default::default()).ok()); - let mut format = Format { decimal, group: None, point: None, percent: ("", "%") }; + let mut format = Format { decimal, group: None, point: None, percent: ("", "%"), marks: Marks::default() }; // The locale's own separators and digits, read from a number that shows them all. let probe = format.parts(&Decimal::try_from_str("-1234567.5").unwrap_or_default()); let latin = probe.integer.starts_with(|c: char| c.is_ascii_digit()); @@ -61,6 +64,15 @@ impl Format { } } format.percent = percent_affixes(®ion.numbers, latin); + let point = format.point.clone().unwrap_or(probe.point); + let group = format.group.clone().unwrap_or(probe.group); + // Latin digits typed into an Arabic-Indic format take the locale's Latin marks. + let comma = match (latin, digits) { + (true, _) => point == ",", + (false, Digits::Locale) => !Format::new(region, Digits::Latin).marks.point, + (false, Digits::Latin) => false, + }; + format.marks = Marks::new(&point, &group, !comma); format } diff --git a/crates/i18n/src/number.rs b/crates/i18n/src/number.rs index dfeb2e748..3de9c988f 100644 --- a/crates/i18n/src/number.rs +++ b/crates/i18n/src/number.rs @@ -2,7 +2,9 @@ use std::fmt; -/// Why [`parse_number`] rejected a text. +use crate::Format; + +/// Why [`Format::parse`] rejected a text. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum NumberError { /// No digits: empty, or only spaces and a sign. @@ -11,8 +13,8 @@ pub enum NumberError { Unexpected(char), /// Digits from more than one set (`1٢`). MixedDigits, - /// A decimal or grouping mark where the locale puts none (`1,5` in English, `1.5` in German), - /// or one that could be read either way (`1,234` when the locale's decimal mark is unknown). + /// A decimal or grouping mark where the locale puts none (`1.234,5` in English, `1,2.5` in + /// German). Separator, /// Too large for an `f64`. TooLarge, @@ -32,68 +34,103 @@ impl fmt::Display for NumberError { impl std::error::Error for NumberError {} -/// The number in `text`, as someone using `locale` (a language tag, `de-CH`, `ar_EG.UTF-8`) writes -/// it. -/// -/// - Digits: any Unicode decimal digits (ASCII, Arabic-Indic, Persian, Devanagari, Bengali, Thai, -/// full-width, …), all from one set. -/// - Decimal mark: the locale's `.` or `,`; `٫` in every locale. A locale this crate doesn't know -/// takes either, when only one reading fits (`1,5`, `1.234,5`). -/// - Grouping: the locale's other mark of `.` and `,`, spaces (NBSP, narrow NBSP, thin), `'`, `’` -/// and `٬`, one kind per number, in groups of three or the Indian 2-2-3 (`12,34,567`). Never in -/// the fraction. -/// - Sign: a leading `-`, `−` (U+2212), full-width `-` or `+`. -/// - Bidi marks and isolates (LRM, RLM, ALM, LRI…PDI) around or inside are ignored, so a number -/// formatted for an RTL interface reads back. -/// -/// Anything else is an error rather than a guess: `1,234` is 1234 in English, 1.234 in German and -/// [`NumberError::Separator`] for an unknown locale. -pub fn parse_number(text: &str, locale: &str) -> Result { - let text: String = text.chars().filter(|c| !is_bidi_mark(*c)).collect(); - let mut rest = text.trim(); - let negative = match rest.chars().next() { - Some(c @ ('-' | '−' | '-' | '+' | '+')) => { - rest = rest.get(c.len_utf8()..).unwrap_or(""); - matches!(c, '-' | '−' | '-') - } - _ => false, - }; - let mut tokens = Vec::with_capacity(rest.len()); - let mut zero = None; - for c in rest.chars() { - let token = match c { - '.' | '.' => Token::Point, - ',' | ',' => Token::Comma, - '٫' => Token::Decimal, - '٬' => Token::Group('٬'), - '\'' | '’' => Token::Group('\''), - ' ' | '\u{a0}' | '\u{202f}' | '\u{2009}' => Token::Group(' '), - _ => { - let (z, d) = digit(c).ok_or(NumberError::Unexpected(c))?; - if *zero.get_or_insert(z) != z { - return Err(NumberError::MixedDigits); - } - Token::Digit(d) - } +/// The marks a [`Format`] writes, as [`Format::parse`] reads them. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct Marks { + /// `.` is the decimal mark and `,` grouping; else the other way round. For a format in + /// Arabic-Indic digits, as the locale writes Latin ones. + pub(crate) point: bool, + /// The format's own decimal and grouping marks when they are neither `.` nor `,` (`٫`, `'`, a + /// mark the system sets). + decimal: Option, + group: Option, +} + +impl Default for Marks { + fn default() -> Self { + Marks { point: true, decimal: None, group: None } + } +} + +impl Marks { + pub(crate) fn new(decimal: &str, group: &str, point: bool) -> Marks { + let own = |s: &str| { + let mut chars = s.chars(); + chars.next().filter(|c| !matches!(c, '.' | ',') && chars.next().is_none()) }; - tokens.push(token); + Marks { point, decimal: own(decimal), group: own(group) } } - if zero.is_none() { - return Err(if tokens.is_empty() { NumberError::Empty } else { NumberError::Separator }); +} + +impl Format { + /// The number in `text`: what this format writes, and what people type or paste. + /// + /// - Digits: any Unicode decimal digits (ASCII, Arabic-Indic, Persian, Devanagari, Bengali, + /// Thai, full-width, …), all from one set. + /// - Decimal mark: the format's, `٫` in every format, and the locale's `.` or `,` when Latin + /// digits are typed into an Arabic-Indic format. A lone `.` in a comma locale is the decimal + /// mark when it can't be grouping (`1.5`, `0.250`, `1234.5`, `.5`), but `1.234` and + /// `1.234.567` stay grouped; the same holds for `,` in a point locale. + /// - Grouping: the format's, the other of `.` and `,`, spaces (NBSP, narrow NBSP, thin), `'`, + /// `’` and `٬`, one kind per number, in groups of three or the Indian 2-2-3 (`12,34,567`). + /// Never in the fraction. + /// - Sign: a leading `-`, `−` (U+2212), full-width `-` or `+`. + /// - Bidi marks and isolates (LRM, RLM, ALM, LRI…PDI) around or inside are ignored, so a + /// number formatted for an RTL interface reads back. + /// + /// Percent signs, units and the space before a unit (`50 %`, `12 px`) are not read; strip them + /// first. Anything else is an error rather than a guess. + pub fn parse(&self, text: &str) -> Result { + let marks = self.marks; + let text: String = text.chars().filter(|c| !is_bidi_mark(*c)).collect(); + let mut rest = text.trim(); + let negative = match rest.chars().next() { + Some(c @ ('-' | '−' | '-' | '+' | '+')) => { + rest = rest.get(c.len_utf8()..).unwrap_or(""); + matches!(c, '-' | '−' | '-') + } + _ => false, + }; + let mut tokens = Vec::with_capacity(rest.len()); + let mut zero = None; + for c in rest.chars() { + let token = match c { + c if Some(c) == marks.decimal => Token::Decimal, + c if Some(c) == marks.group => Token::Group(c), + '.' | '.' => Token::Point, + ',' | ',' => Token::Comma, + '٫' => Token::Decimal, + '٬' => Token::Group('٬'), + '\'' | '’' => Token::Group('\''), + ' ' | '\u{a0}' | '\u{202f}' | '\u{2009}' => Token::Group(' '), + _ => { + let (z, d) = digit(c).ok_or(NumberError::Unexpected(c))?; + if *zero.get_or_insert(z) != z { + return Err(NumberError::MixedDigits); + } + Token::Digit(d) + } + }; + tokens.push(token); + } + if zero.is_none() { + return Err(if tokens.is_empty() { NumberError::Empty } else { NumberError::Separator }); + } + let point = if lone_decimal(&tokens, marks.point) { !marks.point } else { marks.point }; + let digits = read(&tokens, point).ok_or(NumberError::Separator)?; + let value: f64 = digits.parse().map_err(|_| NumberError::Separator)?; + if !value.is_finite() { + return Err(NumberError::TooLarge); + } + Ok(if negative { -value } else { value }) } - let digits = match decimal_mark(locale) { - Some(point) => read(&tokens, point), - None => match (read(&tokens, true), read(&tokens, false)) { - (Some(a), Some(b)) if a != b => None, - (a, b) => a.or(b), - }, - } - .ok_or(NumberError::Separator)?; - let value: f64 = digits.parse().map_err(|_| NumberError::Separator)?; - if !value.is_finite() { - return Err(NumberError::TooLarge); - } - Ok(if negative { -value } else { value }) +} + +/// The number in `text` as `locale` (a language tag, `de-CH`, `ar_EG.UTF-8`) writes it: +/// [`Format::parse`] for [`Format::from_tag`]. Build the [`Format`] once to read many numbers. A +/// locale CLDR doesn't know reads as CLDR's root does (`1,234.5`). +pub fn parse_number(text: &str, locale: &str) -> Result { + Format::from_tag(locale).parse(text) } #[derive(Clone, Copy, PartialEq)] @@ -101,12 +138,26 @@ enum Token { Digit(u8), Point, Comma, - /// `٫`, a decimal mark in every locale. + /// A decimal mark in every reading: `٫`, or the format's own. Decimal, - /// A grouping mark in every locale, by kind (space, apostrophe, `٬`). + /// A grouping mark in every reading, by kind (space, apostrophe, `٬`, the format's own). Group(char), } +/// Whether the locale's grouping mark of `.` and `,` (`.` when `point` is false) is the decimal +/// mark here because it can't be grouping: it is the only mark of the two, there is one of it, and +/// the digits before it are none, start with 0 or are more than three, or the digits after it +/// aren't three. +fn lone_decimal(tokens: &[Token], point: bool) -> bool { + let (group, decimal) = if point { (Token::Comma, Token::Point) } else { (Token::Point, Token::Comma) }; + if tokens.contains(&decimal) || tokens.contains(&Token::Decimal) || tokens.iter().filter(|t| **t == group).count() != 1 { + return false; + } + let Some(at) = tokens.iter().position(|t| *t == group) else { return false }; + let (int, frac) = (tokens.get(..at).unwrap_or_default(), tokens.get(at + 1..).unwrap_or_default()); + int.is_empty() || int.first() == Some(&Token::Digit(0)) || int.len() > 3 || frac.len() != 3 +} + /// The ASCII number `tokens` spell when the decimal mark is `.` (`point`) or `,`; `None` when the /// marks don't fit that reading. fn read(tokens: &[Token], point: bool) -> Option { @@ -160,36 +211,6 @@ fn read(tokens: &[Token], point: bool) -> Option { Some(format!("{}.{}", if int.is_empty() { "0" } else { &int }, if frac.is_empty() { "0" } else { &frac })) } -/// Whether `locale` writes decimals with `.` (`Some(true)`) or `,` (`Some(false)`); `None` for a -/// language not in the table (CLDR's decimal symbol for its Latin digits). -// ponytail: the languages a Craft app ships or could plausibly ship; add a row with the locale. -fn decimal_mark(locale: &str) -> Option { - let locale = locale.trim().to_ascii_lowercase(); - let base = locale.split(['.', '@']).next().unwrap_or("").replace('_', "-"); - let mut parts = base.split('-').filter(|p| !p.is_empty()); - let language = parts.next().unwrap_or(""); - let region = parts.find(|p| super::is_region(p)).unwrap_or(""); - Some(match (language, region) { - ("de" | "it", "ch" | "li") => true, - ("es", "419" | "mx" | "us" | "pr" | "do" | "gt" | "hn" | "ni" | "pa" | "pe" | "sv") => true, - ("en", "za" | "150" | "at" | "be" | "de" | "dk" | "fi" | "nl" | "se" | "si") => false, - ("ar", "dz" | "eh" | "ly" | "ma" | "tn") => false, - ( - "af" | "az" | "be" | "bg" | "bs" | "ca" | "cs" | "da" | "de" | "el" | "eo" | "es" | "et" | "eu" | "fi" | "fo" | "fr" | "fy" | "gl" | "hr" - | "hu" | "hy" | "id" | "is" | "it" | "ka" | "kk" | "km" | "ky" | "lb" | "lo" | "lt" | "lv" | "mk" | "nb" | "nl" | "nn" | "no" | "pl" - | "pt" | "ro" | "ru" | "sk" | "sl" | "sq" | "sr" | "sv" | "tg" | "tk" | "tr" | "tt" | "uk" | "uz" | "vi", - _, - ) => false, - ( - "am" | "ar" | "as" | "bn" | "bo" | "ckb" | "cy" | "dv" | "en" | "fa" | "fil" | "ga" | "gsw" | "gu" | "he" | "hi" | "ja" | "kn" | "ko" - | "ks" | "ml" | "mn" | "mr" | "ms" | "mt" | "my" | "ne" | "or" | "pa" | "ps" | "rm" | "sd" | "si" | "sw" | "ta" | "te" | "th" | "tl" - | "ug" | "ur" | "yue" | "zh" | "zu", - _, - ) => true, - _ => return None, - }) -} - /// The first code point of each run of ten Unicode decimal digits (general category Nd), Unicode /// 16.0. const ZEROS: [u32; 76] = [ @@ -219,6 +240,7 @@ fn is_bidi_mark(c: char) -> bool { #[cfg(test)] mod tests { use super::*; + use crate::{Digits, Region}; #[test] fn every_digit_set() { @@ -269,11 +291,12 @@ mod tests { (" 42 ", "en", 42.0), ("\u{2066}-1,5\u{2069}", "de", -1.5), ("\u{061c}-١٢", "ar", -12.0), - ("1,5", "xx", 1.5), - ("1.234,5", "", 1234.5), ("1,234.5", "xx", 1234.5), - ("1.234.567", "xx", 1_234_567.0), ("12", "xx", 12.0), + ("1,5", "en-PL", 1.5), + ("1.234,5", "en-DE", 1234.5), + ("1.234,5", "lo", 1234.5), + ("1,234.5", "km", 1234.5), ] { assert_eq!(parse_number(text, locale), Ok(want), "{text:?} in {locale}"); } @@ -286,12 +309,7 @@ mod tests { (" ", "en", NumberError::Empty), ("-", "en", NumberError::Empty), (",", "en", NumberError::Separator), - ("1,234", "xx", NumberError::Separator), - ("1,5", "en", NumberError::Separator), - ("1.5", "de", NumberError::Separator), ("1.2.3", "en", NumberError::Separator), - ("12,34", "en", NumberError::Separator), - ("1234,567", "en", NumberError::Separator), ("1,,234", "en", NumberError::Separator), ("1,234,", "en", NumberError::Separator), ("1.234,5", "en", NumberError::Separator), @@ -312,5 +330,54 @@ mod tests { assert_eq!(parse_number(text, locale), Err(want), "{text:?} in {locale}"); } assert_eq!(parse_number(&"9".repeat(400), "en"), Err(NumberError::TooLarge)); + for unit in ["50 %", "50%", "12 px", "1,5 MB"] { + assert!(parse_number(unit, "de").is_err(), "{unit}: units are the caller's to strip"); + } + } + + /// A lone mark that can't be grouping is the decimal mark; one that can stays grouping. + #[test] + fn lone_marks() { + for (text, locale, want) in [ + ("1.5", "de", 1.5), + ("0.250", "de", 0.25), + ("1234.5", "de", 1234.5), + (".5", "de", 0.5), + ("-12.34", "ru", -12.34), + ("1.234", "de", 1234.0), + ("1.234.567", "de", 1_234_567.0), + ("1,5", "en", 1.5), + ("0,5", "en", 0.5), + ("12,34", "en", 12.34), + ("1234,567", "en", 1234.567), + ("1,234", "en", 1234.0), + ("12,345", "en", 12345.0), + ] { + assert_eq!(parse_number(text, locale), Ok(want), "{text:?} in {locale}"); + } + } + + /// What a format writes reads back, in its digits and with its separators, the system's + /// included. + #[test] + fn reads_what_format_writes() { + let mut formats: Vec = ["en", "de", "de-CH", "fr", "hi", "ar-EG", "fa", "bn-BD", "ar-MA", "en-PL", "nb", "pt-PT", "sv"] + .iter() + .flat_map(|t| [Format::from_tag(t), Format::new(&Region::from_tag(t), Digits::Latin)]) + .collect(); + for (decimal, grouping) in [(Some(','), None), (Some('.'), Some(Some('\u{a0}'))), (None, Some(None)), (Some('·'), Some(Some('_')))] { + let mut region = Region::from_tag("en-US"); + region.decimal = decimal; + region.grouping = grouping; + formats.push(Format::new(®ion, Digits::Locale)); + } + for f in &formats { + for value in [0.0, 1.5, -1234.25, 1_234_567.89, 12.0, 1234.0] { + let text = f.number(value, 2); + assert_eq!(f.parse(&text), Ok(value), "{text:?} ({f:?})"); + } + let text = f.integer(1_234_567); + assert_eq!(f.parse(&text), Ok(1_234_567.0), "{text:?}"); + } } } From 502f7997167d3602920b63ac73be9f89c409ab17 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 05:54:12 +0000 Subject: [PATCH 16/43] i18n: han and catalog matching follow region-implied scripts han(zh-US), zh-AU and the other regions CLDR writes in Traditional characters give Traditional, and pa-PK picks a pa-arab catalog, never Gurmukhi pa, as pa-Arab-PK does. --- crates/i18n/README.md | 10 ++++----- crates/i18n/src/lib.rs | 30 +++++++++++++++++++++++-- crates/i18n/src/scripts.rs | 46 +++++++++++++++++++++----------------- 3 files changed, 59 insertions(+), 27 deletions(-) diff --git a/crates/i18n/README.md b/crates/i18n/README.md index ac6146b5e..4bf29acec 100644 --- a/crates/i18n/README.md +++ b/crates/i18n/README.md @@ -64,14 +64,14 @@ The app keeps its `tl!` macro, its language setting (`"auto"` → `I18N.system_l interface text is written in (`ja` → Hani, Hira, Kana; `sr-latn` → Latn; `pa-PK` → Arab), `direction` is right to left when the main script is (Arabic, Hebrew, Syriac, Thaana), and `han` gives the regional forms of its Han characters for picking a CJK face: `zh-CN`/`zh-SG`/`zh-Hans` → - SC, `zh-TW`/`zh-Hant` → TC, `zh-HK`/`zh-MO` → HK, `ja` → JP, `ko` → KR. `I18n::rtl` is + SC, `zh-TW`/`zh-US`/`zh-Hant` → TC, `zh-HK`/`zh-MO` → HK, `ja` → JP, `ko` → KR. `I18n::rtl` is `direction` of the language's code; putting text into display order is the app's job. - **System locale:** `system_lang` matches the system's list once and caches it: on desktop Unix `LANGUAGE`, `LC_ALL`, `LC_MESSAGES` and `LANG`, read raw; elsewhere `sys-locale`'s (macOS and - Windows preferred languages, the browser's on the web). `fr_CA.UTF-8` → `fr`, `zh-TW` → - `zh-hant`, `pt-PT` → `pt-br` when that is the only Portuguese, `C`/`POSIX` → English. A script - is never crossed: `sr_RS@latin` and `sr-Latn-RS` pick `sr-latn`, never `sr`, while `sr-Cyrl-RS` - picks `sr`. + Windows preferred languages, the browser's on the web). `fr_CA.UTF-8` → `fr`, `zh-TW` → `zh-hant`, + `pt-PT` → `pt-br` when that is the only Portuguese, `C`/`POSIX` → English. A script is never + crossed: `sr_RS@latin` and `sr-Latn-RS` pick `sr-latn`, never `sr`, while `sr-Cyrl-RS` picks `sr`. + A region that implies a script counts as that script: `pa-PK` picks `pa-arab`, not Gurmukhi `pa`. - **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 translation. `looked_up()` lists the source texts looked up so far, for an audit against a diff --git a/crates/i18n/src/lib.rs b/crates/i18n/src/lib.rs index 84295a163..6c841d1e4 100644 --- a/crates/i18n/src/lib.rs +++ b/crates/i18n/src/lib.rs @@ -292,7 +292,9 @@ pub fn fmt(template: &str, args: &[(&str, &str)]) -> String { /// Chinese without a script picks one by region; Portuguese falls back to `pt-br` last, when /// there is no `pt`. A glibc script modifier (`sr_RS@latin`) becomes the script subtag. With a /// script (`sr-Latn-RS`), a code without one is tried only when that is its language's usual -/// script, so Latin Serbian never picks Cyrillic `sr`, while `sr-Cyrl-RS` does. +/// script, so Latin Serbian never picks Cyrillic `sr`, while `sr-Cyrl-RS` does. A region that +/// implies a script (CLDR likely subtags) counts as that script after the tag as written: +/// `pa-PK` → `pa-pk`, `pa-arab-pk`, `pa-arab`. fn candidates(tag: &str) -> Vec { let tag = tag.trim().to_ascii_lowercase(); let base = tag.split(['.', '@']).next().unwrap_or("").replace('_', "-"); @@ -309,6 +311,16 @@ fn candidates(tag: &str) -> Vec { parts.insert(1, s); } let script = parts.get(1).copied().filter(|s| s.len() == 4 && s.bytes().all(|b| b.is_ascii_alphabetic())); + // Chinese picks its script by region below, keeping plain `zh` as the last resort. + if script.is_none() + && primary != "zh" + && let Some(implied) = parts.get(1).filter(|r| is_region(r)).and_then(|r| scripts::region_script(primary, r)) + { + let mut out = vec![parts.join("-")]; + parts.insert(1, implied); + out.extend(candidates(&parts.join("-"))); + return out; + } let mut out: Vec = (1..=parts.len()).rev().map(|n| parts.get(..n).unwrap_or_default().join("-")).collect(); if let Some(s) = script && usual_script(primary).is_some_and(|usual| usual != s) @@ -316,7 +328,8 @@ fn candidates(tag: &str) -> Vec { out.retain(|c| c.split('-').any(|p| p == s)); } if primary == "zh" && !parts.iter().any(|p| matches!(*p, "hans" | "hant")) { - let script = if parts.iter().any(|p| matches!(*p, "tw" | "hk" | "mo")) { "zh-hant" } else { "zh-hans" }; + let hant = parts.iter().skip(1).any(|p| scripts::region_script("zh", p) == Some("hant")); + let script = if hant { "zh-hant" } else { "zh-hans" }; out.insert(out.len().saturating_sub(1), script.to_owned()); } if primary == "pt" && !out.iter().any(|c| c == "pt-br") { @@ -565,6 +578,8 @@ mod tests { ("zh", Some("zh-hans")), ("zh-Hant-MO", Some("zh-hant")), ("zh_CN", Some("zh-hans")), + ("zh-US", Some("zh-hant")), + ("zh_SG", Some("zh-hans")), ("C", Some("en")), ("POSIX", Some("en")), ("en_US", Some("en")), @@ -576,6 +591,17 @@ mod tests { } } + #[test] + fn region_implies_script() { + assert_eq!(candidates("pa-PK"), ["pa-pk", "pa-arab-pk", "pa-arab"]); + assert_eq!(candidates("pa_PK.UTF-8"), ["pa-pk", "pa-arab-pk", "pa-arab"]); + assert_eq!(candidates("pa-IN"), ["pa-in", "pa"]); + assert_eq!(candidates("sd-IN"), ["sd-in", "sd-deva-in", "sd-deva"]); + assert_eq!(candidates("pa-Guru-PK"), ["pa-guru-pk", "pa-guru", "pa"]); + assert_eq!(candidates("zh-US"), ["zh-us", "zh-hant", "zh"]); + assert_eq!(candidates("zh-SG"), ["zh-sg", "zh-hans", "zh"]); + } + #[test] fn match_tags_takes_first_match() { let i = all(L).0; diff --git a/crates/i18n/src/scripts.rs b/crates/i18n/src/scripts.rs index 037b3d401..56a1ba1fc 100644 --- a/crates/i18n/src/scripts.rs +++ b/crates/i18n/src/scripts.rs @@ -118,7 +118,7 @@ pub fn scripts(tag: &str) -> &'static [Script] { if let Some(s) = tag.script.as_deref().and_then(subtag_scripts) { return s; } - if let Some(s) = tag.region.as_deref().and_then(|region| region_scripts(&tag.language, region)) { + if let Some(s) = tag.region.as_deref().and_then(|region| region_script(&tag.language, region)).and_then(subtag_scripts) { return s; } match tag.language.as_str() { @@ -162,39 +162,38 @@ pub fn direction(tag: &str) -> Direction { } /// The forms a language tag's Han characters take: `zh-CN`, `zh-SG`, `zh-Hans` and plain `zh` → -/// Simplified; `zh-TW` and `zh-Hant` → Traditional; `zh-HK`, `zh-MO` and `zh-Hant-HK` → HongKong; -/// `ja` → Japanese; `ko` → Korean. `None` for a language not written with Han, which leaves the -/// choice to the font stack. +/// Simplified; `zh-TW`, `zh-Hant` and the other regions CLDR writes in Traditional characters +/// (`zh-US`, `zh-AU`) → Traditional; `zh-HK`, `zh-MO` and `zh-Hant-HK` → HongKong; `ja` → +/// Japanese; `ko` → Korean. `None` for a language not written with Han, which leaves the choice +/// to the font stack. pub fn han(tag: &str) -> Option { let tag = Subtags::parse(tag); let hong_kong = matches!(tag.region.as_deref(), Some("hk" | "mo")); - match (tag.language.as_str(), tag.script.as_deref()) { + let script = tag.script.as_deref().or_else(|| region_script(&tag.language, tag.region.as_deref()?)); + match (tag.language.as_str(), script) { ("ja", _) => Some(Han::Japanese), ("ko", _) => Some(Han::Korean), ("zh" | "yue" | "wuu", Some("hans")) => Some(Han::Simplified), ("zh" | "yue" | "wuu", Some("hant")) => Some(if hong_kong { Han::HongKong } else { Han::Traditional }), // Cantonese is written in Traditional characters with Hong Kong forms unless it says otherwise. - ("yue", _) => Some(if tag.region.as_deref() == Some("cn") { Han::Simplified } else { Han::HongKong }), - ("zh" | "wuu", _) => Some(match tag.region.as_deref() { - Some("tw") => Han::Traditional, - _ if hong_kong => Han::HongKong, - _ => Han::Simplified, - }), + ("yue", _) => Some(Han::HongKong), + ("zh" | "wuu", _) => Some(Han::Simplified), _ => None, } } -/// The scripts a language takes in a region where CLDR's likely subtags give it a script other -/// than its usual one (`pa-PK` → `pa-Arab-PK`). Han regions are left to [`han`]. +/// The script subtag of a language in a region where CLDR's likely subtags give it a script +/// other than its usual one: `pa-PK` → `arab` (`pa-Arab-PK`), `zh-TW` → `hant`. // ponytail: mn-CN → Mong is left out until vertical Mongolian layout is supported. -fn region_scripts(language: &str, region: &str) -> Option<&'static [Script]> { - use Script::*; +pub(crate) fn region_script(language: &str, region: &str) -> Option<&'static str> { Some(match (language, region) { - ("az", "iq" | "ir") | ("ha", "cm" | "sd") | ("kk", "af" | "cn" | "ir" | "mn") | ("ku", "iq" | "ir" | "lb") => &[Arab], - ("ky", "cn") | ("ms", "cc") | ("pa", "pk") | ("tg", "pk") | ("uz", "af") => &[Arab], - ("az", "ru") | ("ku", "am" | "az" | "ge" | "tm") | ("ug", "kz" | "mn") | ("uz", "cn") => &[Cyrl], - ("ky", "tr") | ("sr", "me" | "ro" | "tr") => &[Latn], - ("sd", "in") => &[Deva], + ("az", "iq" | "ir") | ("ha", "cm" | "sd") | ("kk", "af" | "cn" | "ir" | "mn") | ("ku", "iq" | "ir" | "lb") => "arab", + ("ky", "cn") | ("ms", "cc") | ("pa", "pk") | ("tg", "pk") | ("uz", "af") => "arab", + ("az", "ru") | ("ku", "am" | "az" | "ge" | "tm") | ("ug", "kz" | "mn") | ("uz", "cn") => "cyrl", + ("ky", "tr") | ("sr", "me" | "ro" | "tr") => "latn", + ("sd", "in") => "deva", + ("zh", "au" | "bn" | "gb" | "gf" | "hk" | "id" | "mo" | "pa" | "pf" | "ph" | "sr" | "th" | "tw" | "us" | "vn") => "hant", + ("yue", "cn") => "hans", _ => return None, }) } @@ -340,6 +339,13 @@ mod tests { ("zh-hant-hk", Han::HongKong), ("zh-Hans-HK", Han::Simplified), ("zh-SG", Han::Simplified), + ("zh-US", Han::Traditional), + ("zh_AU.UTF-8", Han::Traditional), + ("zh-GB", Han::Traditional), + ("zh-Hans-US", Han::Simplified), + ("zh-MY", Han::Simplified), + ("yue-CN", Han::Simplified), + ("yue-HK", Han::HongKong), ("zh-Hans-SG", Han::Simplified), ("yue", Han::HongKong), ("yue-Hans", Han::Simplified), From 56bbf840a7ff71acd7f75f2bc5318df5ed8b3129 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 06:06:08 +0000 Subject: [PATCH 17/43] i18n: strip every isolate mark in fmt_plain --- crates/i18n/src/lib.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/i18n/src/lib.rs b/crates/i18n/src/lib.rs index 8fde98ad5..2935b88f7 100644 --- a/crates/i18n/src/lib.rs +++ b/crates/i18n/src/lib.rs @@ -275,10 +275,10 @@ pub fn fmt(template: &str, args: &[(&str, &str)]) -> String { } /// [`fmt`] without isolates, for text that goes where the marks would get in the way: file -/// names, logs, terminals, text compared or parsed later. A value that is one isolate goes in -/// without it. +/// names, logs, terminals, text compared or parsed later. Every isolate mark is taken off, in +/// the template and in the values. pub fn fmt_plain(template: &str, args: &[(&str, &str)]) -> String { - fill(template, args, false) + strip_isolates(&fill(template, args, false)) } const LRI: char = '\u{2066}'; @@ -602,6 +602,7 @@ mod tests { assert_eq!(fmt("Saved {name} in {dir}.", &[("name", "Report 2.pdf"), ("dir", <r("/home/sara"))]), "Saved Report 2.pdf in /home/sara."); assert_eq!(fmt("{a}", &[("a", "x"), ("b", "שלום")]), "x", "an unused value doesn't count"); assert_eq!(fmt_plain("{a}", &[("a", &isolate("x"))]), "x"); + assert_eq!(fmt_plain("\u{2066}{a} {b}", &[("a", "x\u{2069}"), ("b", &isolate(<r("y")))]), "x y"); let i = all(P).0; assert_eq!(i.trn(Lang::EN, 2, "{n} page", "{n} pages"), "2 pages"); } From b7d5394802298329b2541a070a60f4f234023122 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 06:06:29 +0000 Subject: [PATCH 18/43] i18n: a grouping dot stays grouping beside a system decimal mark --- crates/i18n/src/format.rs | 2 +- crates/i18n/src/number.rs | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/crates/i18n/src/format.rs b/crates/i18n/src/format.rs index bcb20d052..a0ac4cda1 100644 --- a/crates/i18n/src/format.rs +++ b/crates/i18n/src/format.rs @@ -68,7 +68,7 @@ impl Format { let group = format.group.clone().unwrap_or(probe.group); // Latin digits typed into an Arabic-Indic format take the locale's Latin marks. let comma = match (latin, digits) { - (true, _) => point == ",", + (true, _) => point == "," || group == ".", (false, Digits::Locale) => !Format::new(region, Digits::Latin).marks.point, (false, Digits::Latin) => false, }; diff --git a/crates/i18n/src/number.rs b/crates/i18n/src/number.rs index 3de9c988f..0b44850ef 100644 --- a/crates/i18n/src/number.rs +++ b/crates/i18n/src/number.rs @@ -365,8 +365,14 @@ mod tests { .iter() .flat_map(|t| [Format::from_tag(t), Format::new(&Region::from_tag(t), Digits::Latin)]) .collect(); - for (decimal, grouping) in [(Some(','), None), (Some('.'), Some(Some('\u{a0}'))), (None, Some(None)), (Some('·'), Some(Some('_')))] { - let mut region = Region::from_tag("en-US"); + for (tag, decimal, grouping) in [ + ("en-US", Some(','), None), + ("en-US", Some('.'), Some(Some('\u{a0}'))), + ("en-US", None, Some(None)), + ("en-US", Some('·'), Some(Some('_'))), + ("de-DE", Some('·'), None), + ] { + let mut region = Region::from_tag(tag); region.decimal = decimal; region.grouping = grouping; formats.push(Format::new(®ion, Digits::Locale)); From 31b5f953ee28d691162f2933c9aa7e88a80cc1e9 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 06:06:44 +0000 Subject: [PATCH 19/43] i18n: plain wording for where Region separators come from --- crates/i18n/src/region.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/i18n/src/region.rs b/crates/i18n/src/region.rs index bbebe4c9e..723428361 100644 --- a/crates/i18n/src/region.rs +++ b/crates/i18n/src/region.rs @@ -2,9 +2,9 @@ //! interface language. An English interface in Poland still writes `2,54 cm` on A4. //! //! The territory lists come from Unicode CLDR's supplemental `measurementData` (Unicode License): -//! `paperSize` US-Letter and `measurementSystem` US. The separators numbers are written with -//! follow the number locale's CLDR data ([`crate::Format`]); the region carries them only where -//! the system sets its own. +//! `paperSize` US-Letter and `measurementSystem` US. Decimal and grouping separators come from +//! the number locale's CLDR data ([`crate::Format`]); the region carries its own only where the +//! system sets them. use std::sync::OnceLock; From c9013e08c7752a12c14f228e2d45ebf452c96a5b Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 06:06:36 +0000 Subject: [PATCH 20/43] i18n: Hani and yue-Hant pick Han forms by region; usual scripts for kk, ky, tg, ku, ms, ug --- crates/i18n/src/lib.rs | 9 ++++++--- crates/i18n/src/scripts.rs | 15 +++++++++++---- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/crates/i18n/src/lib.rs b/crates/i18n/src/lib.rs index 6c841d1e4..db13b6db1 100644 --- a/crates/i18n/src/lib.rs +++ b/crates/i18n/src/lib.rs @@ -358,10 +358,10 @@ fn marked(text: &str) -> String { // ponytail: the multi-script languages a Craft app could plausibly ship; add a row with the locale. fn usual_script(language: &str) -> Option<&'static str> { Some(match language { - "be" | "mn" | "sr" => "cyrl", + "be" | "kk" | "ky" | "mn" | "sr" | "tg" => "cyrl", "zh" => "hans", - "az" | "bs" | "ha" | "uz" => "latn", - "ks" | "sd" => "arab", + "az" | "bs" | "ha" | "ku" | "ms" | "uz" => "latn", + "ks" | "sd" | "ug" => "arab", "pa" => "guru", _ => return None, }) @@ -600,6 +600,9 @@ mod tests { assert_eq!(candidates("pa-Guru-PK"), ["pa-guru-pk", "pa-guru", "pa"]); assert_eq!(candidates("zh-US"), ["zh-us", "zh-hant", "zh"]); assert_eq!(candidates("zh-SG"), ["zh-sg", "zh-hans", "zh"]); + assert_eq!(candidates("kk-CN"), ["kk-cn", "kk-arab-cn", "kk-arab"]); + assert_eq!(candidates("kk-KZ"), ["kk-kz", "kk"]); + assert_eq!(candidates("ug-KZ"), ["ug-kz", "ug-cyrl-kz", "ug-cyrl"]); } #[test] diff --git a/crates/i18n/src/scripts.rs b/crates/i18n/src/scripts.rs index 56a1ba1fc..ebe324f54 100644 --- a/crates/i18n/src/scripts.rs +++ b/crates/i18n/src/scripts.rs @@ -163,18 +163,21 @@ pub fn direction(tag: &str) -> Direction { /// The forms a language tag's Han characters take: `zh-CN`, `zh-SG`, `zh-Hans` and plain `zh` → /// Simplified; `zh-TW`, `zh-Hant` and the other regions CLDR writes in Traditional characters -/// (`zh-US`, `zh-AU`) → Traditional; `zh-HK`, `zh-MO` and `zh-Hant-HK` → HongKong; `ja` → +/// (`zh-US`, `zh-AU`) → Traditional; `zh-HK`, `zh-MO`, `zh-Hant-HK` and `yue` → HongKong; `ja` → /// Japanese; `ko` → Korean. `None` for a language not written with Han, which leaves the choice /// to the font stack. pub fn han(tag: &str) -> Option { let tag = Subtags::parse(tag); let hong_kong = matches!(tag.region.as_deref(), Some("hk" | "mo")); - let script = tag.script.as_deref().or_else(|| region_script(&tag.language, tag.region.as_deref()?)); + // `Hani` names Han without saying which forms, so the region still decides. + let script = tag.script.as_deref().filter(|s| *s != "hani"); + let script = script.or_else(|| region_script(&tag.language, tag.region.as_deref()?)); match (tag.language.as_str(), script) { ("ja", _) => Some(Han::Japanese), ("ko", _) => Some(Han::Korean), ("zh" | "yue" | "wuu", Some("hans")) => Some(Han::Simplified), - ("zh" | "yue" | "wuu", Some("hant")) => Some(if hong_kong { Han::HongKong } else { Han::Traditional }), + ("yue", Some("hant")) => Some(if tag.region.as_deref() == Some("tw") { Han::Traditional } else { Han::HongKong }), + ("zh" | "wuu", Some("hant")) => Some(if hong_kong { Han::HongKong } else { Han::Traditional }), // Cantonese is written in Traditional characters with Hong Kong forms unless it says otherwise. ("yue", _) => Some(Han::HongKong), ("zh" | "wuu", _) => Some(Han::Simplified), @@ -348,6 +351,10 @@ mod tests { ("yue-HK", Han::HongKong), ("zh-Hans-SG", Han::Simplified), ("yue", Han::HongKong), + ("yue-Hant", Han::HongKong), + ("yue-Hant-TW", Han::Traditional), + ("zh-Hani-HK", Han::HongKong), + ("zh-Hani", Han::Simplified), ("yue-Hans", Han::Simplified), ("ja-JP", Han::Japanese), ("ko_KR.UTF-8", Han::Korean), @@ -414,7 +421,7 @@ mod tests { /// The usual scripts tag matching relies on agree with the table. #[test] fn agrees_with_usual_script() { - for language in ["be", "mn", "sr", "zh", "az", "bs", "ha", "uz", "ks", "sd", "pa"] { + for language in ["be", "kk", "ky", "mn", "sr", "tg", "zh", "az", "bs", "ha", "ku", "ms", "uz", "ks", "sd", "ug", "pa"] { let usual = crate::usual_script(language).and_then(subtag_scripts); assert_eq!(usual, Some(scripts(language)), "{language}"); } From 540dafb9c35f609a7401f24f1a00b3b8d7e2e2c9 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 06:07:07 +0000 Subject: [PATCH 21/43] i18n: generate.sh keeps locales.rs when locales.py fails --- crates/i18n/data/generate.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/i18n/data/generate.sh b/crates/i18n/data/generate.sh index 91b6ab94f..2fac15d7a 100755 --- a/crates/i18n/data/generate.sh +++ b/crates/i18n/data/generate.sh @@ -19,4 +19,5 @@ TMPDIR=$CACHE icu4x-datagen --format blob --overwrite \ --out "$HERE/numbers.postcard" ZIP=$CACHE/icu4x-source-cache/github.com/unicode-org/cldr-json/releases/download/$CLDR/cldr-$CLDR-json-full.zip -python3 -I "$HERE/locales.py" "$ZIP" "$CLDR" > "$HERE/locales.rs" +python3 -I "$HERE/locales.py" "$ZIP" "$CLDR" > "$HERE/locales.rs.tmp" +mv "$HERE/locales.rs.tmp" "$HERE/locales.rs" From 61db8b71b58d9fc33a5a6907a8829c8d760bdc7a Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 06:07:40 +0000 Subject: [PATCH 22/43] i18n: break collation ties by code point --- crates/i18n/src/collate.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/crates/i18n/src/collate.rs b/crates/i18n/src/collate.rs index 408f40b19..8d849299d 100644 --- a/crates/i18n/src/collate.rs +++ b/crates/i18n/src/collate.rs @@ -42,12 +42,13 @@ impl Collator { Collator(collator) } - /// `a` against `b`. Text that differs only in case or accents still has an order, so a - /// sort is stable across runs. Without collation data (never with the embedded data) this is - /// code point order. + /// `a` against `b`. Text that differs only in case or accents still has an order, and text + /// the collation counts as equal (`Layer 2` and `Layer 02`) is in code point order, so a sort + /// doesn't depend on the input order. Without collation data (never with the embedded data) + /// this is code point order. pub fn compare(&self, a: &str, b: &str) -> Ordering { match &self.0 { - Some(c) => c.as_borrowed().compare(a, b), + Some(c) => c.as_borrowed().compare(a, b).then_with(|| a.cmp(b)), None => a.cmp(b), } } @@ -178,7 +179,9 @@ mod tests { #[test] fn digits_sort_by_value() { let layers = ["Layer 10", "Layer 2", "layer 1", "Layer 1", "Layer 2b", "Layer 02"]; - assert_eq!(sorted("en", &layers), ["layer 1", "Layer 1", "Layer 2", "Layer 02", "Layer 2b", "Layer 10"]); + assert_eq!(sorted("en", &layers), ["layer 1", "Layer 1", "Layer 02", "Layer 2", "Layer 2b", "Layer 10"]); + let reversed: Vec<&str> = layers.iter().rev().copied().collect(); + assert_eq!(sorted("en", &reversed), sorted("en", &layers)); assert_eq!(sorted("de", &["Ebene ٣", "Ebene 10", "Ebene 2"]), ["Ebene 2", "Ebene ٣", "Ebene 10"]); } From 365f7672b59b211481b3fbbf3faf5691ce9e67f4 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 06:07:40 +0000 Subject: [PATCH 23/43] i18n: don't match part of a Hangul syllable --- crates/i18n/src/fold.rs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/crates/i18n/src/fold.rs b/crates/i18n/src/fold.rs index 3a3330b13..aa662fa7f 100644 --- a/crates/i18n/src/fold.rs +++ b/crates/i18n/src/fold.rs @@ -21,8 +21,8 @@ use std::ops::Range; use icu_normalizer::DecomposingNormalizerBorrowed; -use icu_properties::CodePointSetData; -use icu_properties::props::GraphemeExtend; +use icu_properties::props::{GraphemeExtend, HangulSyllableType}; +use icu_properties::{CodePointMapData, CodePointSetData}; /// Folds text for one locale. Cheap to build and copy. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -216,7 +216,14 @@ impl Folded { /// not leave out a mark that follows a letter, so `re` doesn't match `ré` when accents count. pub fn matches<'a>(&'a self, needle: &'a Folded) -> impl Iterator> + 'a { let extend = CodePointSetData::new::(); - let starts_mark = move |at: usize| self.text[at..].chars().next().is_some_and(|c| extend.contains(c)); + let hangul = CodePointMapData::::new(); + // The vowel and final consonant of a decomposed Hangul syllable continue it, like a mark. + let starts_mark = move |at: usize| { + self.text[at..] + .chars() + .next() + .is_some_and(|c| extend.contains(c) || matches!(hangul.get(c), HangulSyllableType::VowelJamo | HangulSyllableType::TrailingJamo)) + }; let n = needle.text.as_str(); let mut from = 0; let mut done = false; @@ -350,6 +357,10 @@ mod tests { assert!(f.contains("ré", "é")); // Kana voicing marks stay even with accents ignored: か doesn't find が. assert!(!Folder::new("ja").contains("が", "か")); + // Nor does a Hangul syllable without its final consonant: 하 doesn't find 한. + let ko = Folder::new("ko"); + assert!(!ko.contains("한국", "하") && !ko.keep_accents().contains("한국", "하")); + assert!(ko.contains("한국", "한") && ko.contains("하늘", "하")); } #[test] From 2ff3f1165410a75d9051e9679446b8711f8a097b Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 06:13:49 +0000 Subject: [PATCH 24/43] i18n: base_direction of a paragraph from its first strong character --- Cargo.lock | 1 + crates/i18n/Cargo.toml | 1 + crates/i18n/src/lib.rs | 2 +- crates/i18n/src/scripts.rs | 26 ++++++++++++++++++++++++++ 4 files changed, 29 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 4d9de4289..ebc4055fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -659,6 +659,7 @@ dependencies = [ "intl_pluralrules", "sys-locale", "unic-langid", + "unicode-bidi", ] [[package]] diff --git a/crates/i18n/Cargo.toml b/crates/i18n/Cargo.toml index 45e21775e..8283943f8 100644 --- a/crates/i18n/Cargo.toml +++ b/crates/i18n/Cargo.toml @@ -11,6 +11,7 @@ repository.workspace = true intl_pluralrules = "=7.0.2" unic-langid = "0.9" sys-locale = "0.3" +unicode-bidi = "0.3" [target.'cfg(target_arch = "wasm32")'.dependencies] sys-locale = { version = "0.3", features = ["js"] } diff --git a/crates/i18n/src/lib.rs b/crates/i18n/src/lib.rs index db13b6db1..ca955f854 100644 --- a/crates/i18n/src/lib.rs +++ b/crates/i18n/src/lib.rs @@ -14,7 +14,7 @@ use std::sync::{Mutex, OnceLock, PoisonError}; use intl_pluralrules::{PluralCategory, PluralRuleType, PluralRules}; use unic_langid::LanguageIdentifier; -pub use scripts::{Direction, Han, Script, direction, han, scripts}; +pub use scripts::{Direction, Han, Script, base_direction, direction, han, scripts}; /// The catalogs an app embeds, one entry per locale: (locale, shared catalog, the app's catalog). /// A missing catalog is `""`. diff --git a/crates/i18n/src/scripts.rs b/crates/i18n/src/scripts.rs index ebe324f54..1ae0f29bc 100644 --- a/crates/i18n/src/scripts.rs +++ b/crates/i18n/src/scripts.rs @@ -161,6 +161,18 @@ pub fn direction(tag: &str) -> Direction { } } +/// The direction of a paragraph of text, from its first strong character (UAX #9 rules P2 and +/// P3): the contents of isolates (LRI, RLI or FSI up to the matching PDI) are skipped, and the +/// search stops at the first paragraph separator. `None` when the paragraph has no strong +/// character; P3 then reads it as left to right, and an app may use the interface direction. +pub fn base_direction(text: &str) -> Option { + match unicode_bidi::get_base_direction(text) { + unicode_bidi::Direction::Ltr => Some(Direction::Ltr), + unicode_bidi::Direction::Rtl => Some(Direction::Rtl), + unicode_bidi::Direction::Mixed => None, + } +} + /// The forms a language tag's Han characters take: `zh-CN`, `zh-SG`, `zh-Hans` and plain `zh` → /// Simplified; `zh-TW`, `zh-Hant` and the other regions CLDR writes in Traditional characters /// (`zh-US`, `zh-AU`) → Traditional; `zh-HK`, `zh-MO`, `zh-Hant-HK` and `yue` → HongKong; `ja` → @@ -277,6 +289,20 @@ mod tests { use super::Script::*; use super::*; + #[test] + fn base_direction_of_text() { + assert_eq!(base_direction("Hello"), Some(Direction::Ltr)); + assert_eq!(base_direction("123 שלום"), Some(Direction::Rtl)); + assert_eq!(base_direction("(مرحبا) hi"), Some(Direction::Rtl)); + // The isolate's Hebrew is skipped; the Latin after it decides. + assert_eq!(base_direction("\u{2067}שלום\u{2069} hello"), Some(Direction::Ltr)); + assert_eq!(base_direction("\u{2068}abc\u{2069} שלום"), Some(Direction::Rtl)); + // Only the first paragraph counts. + assert_eq!(base_direction("123\nשלום"), None); + assert_eq!(base_direction("1920 × 1080"), None); + assert_eq!(base_direction(""), None); + } + /// Every locale the craft-i18n catalogs or an app ship, as the apps spell them. #[test] fn shipped_locales() { From b37040cb9358ea3361aca0ff512ba347d7b2e2bc Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 21 Oct 2026 06:16:08 +0000 Subject: [PATCH 25/43] Add craft-fonts-build: embed craft-fonts faces from an app's build.rs --- Cargo.lock | 14 ++ Cargo.toml | 1 + README.md | 3 + crates/fonts-build/Cargo.toml | 14 ++ crates/fonts-build/README.md | 73 ++++++ crates/fonts-build/src/embed.rs | 228 ++++++++++++++++++ crates/fonts-build/src/lib.rs | 108 +++++++++ crates/fonts-build/src/manifest.rs | 103 ++++++++ crates/fonts-build/src/tests.rs | 139 +++++++++++ crates/fonts-build/tests/consumer/Cargo.toml | 18 ++ crates/fonts-build/tests/consumer/build.rs | 16 ++ crates/fonts-build/tests/consumer/src/lib.rs | 7 + .../fixture/craft-fonts/fonts/manifest.txt | 10 + .../craft-fonts/fonts/sans-arabic/OFL.txt | 1 + .../fonts/sans-arabic/SansArabic.ttf | 1 + .../craft-fonts/fonts/sans-cjk/OFL.txt | 1 + .../craft-fonts/fonts/sans-cjk/SansCJK.ttc | 1 + .../fixture/craft-fonts/fonts/sans-jp/OFL.txt | 1 + .../craft-fonts/fonts/sans-jp/SansJP-Bold.ttf | 1 + .../fonts/sans-jp/SansJP-Regular.ttf | 1 + .../craft-fonts/fonts/sans-thai/OFL.txt | 1 + .../craft-fonts/fonts/sans-thai/SansThai.ttf | 1 + crates/fonts-build/tests/generated.rs | 16 ++ 23 files changed, 759 insertions(+) create mode 100644 crates/fonts-build/Cargo.toml create mode 100644 crates/fonts-build/README.md create mode 100644 crates/fonts-build/src/embed.rs create mode 100644 crates/fonts-build/src/lib.rs create mode 100644 crates/fonts-build/src/manifest.rs create mode 100644 crates/fonts-build/src/tests.rs create mode 100644 crates/fonts-build/tests/consumer/Cargo.toml create mode 100644 crates/fonts-build/tests/consumer/build.rs create mode 100644 crates/fonts-build/tests/consumer/src/lib.rs create mode 100644 crates/fonts-build/tests/fixture/craft-fonts/fonts/manifest.txt create mode 100644 crates/fonts-build/tests/fixture/craft-fonts/fonts/sans-arabic/OFL.txt create mode 100644 crates/fonts-build/tests/fixture/craft-fonts/fonts/sans-arabic/SansArabic.ttf create mode 100644 crates/fonts-build/tests/fixture/craft-fonts/fonts/sans-cjk/OFL.txt create mode 100644 crates/fonts-build/tests/fixture/craft-fonts/fonts/sans-cjk/SansCJK.ttc create mode 100644 crates/fonts-build/tests/fixture/craft-fonts/fonts/sans-jp/OFL.txt create mode 100644 crates/fonts-build/tests/fixture/craft-fonts/fonts/sans-jp/SansJP-Bold.ttf create mode 100644 crates/fonts-build/tests/fixture/craft-fonts/fonts/sans-jp/SansJP-Regular.ttf create mode 100644 crates/fonts-build/tests/fixture/craft-fonts/fonts/sans-thai/OFL.txt create mode 100644 crates/fonts-build/tests/fixture/craft-fonts/fonts/sans-thai/SansThai.ttf create mode 100644 crates/fonts-build/tests/generated.rs diff --git a/Cargo.lock b/Cargo.lock index 4d9de4289..35c43199d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -652,6 +652,20 @@ dependencies = [ name = "craft-docpath" version = "0.1.0" +[[package]] +name = "craft-fonts-build" +version = "0.1.0" +dependencies = [ + "craft-fonts-build-consumer", +] + +[[package]] +name = "craft-fonts-build-consumer" +version = "0.1.0" +dependencies = [ + "craft-fonts-build", +] + [[package]] name = "craft-i18n" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index ca53f61a6..77042f9e4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ thiserror = "2" craft-atomic = { path = "crates/atomic" } craft-cms = { path = "crates/cms" } craft-docpath = { path = "crates/docpath" } +craft-fonts-build = { path = "crates/fonts-build" } craft-i18n = { path = "crates/i18n" } craft-media = { path = "crates/media" } craft-print = { path = "crates/print" } diff --git a/README.md b/README.md index 3289aba62..3272416e0 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,9 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = "` names face `n` of a font +collection (`fonts/noto-sans-cjk/NotoSansCJK.ttc#2`); without it the index is 0. Fields after the +eighth are ignored. diff --git a/crates/fonts-build/src/embed.rs b/crates/fonts-build/src/embed.rs new file mode 100644 index 000000000..bfa37d635 --- /dev/null +++ b/crates/fonts-build/src/embed.rs @@ -0,0 +1,228 @@ +//! The build-script side: read `CRAFT_FONTS_DIR`, select faces, write the generated table. + +use std::ffi::OsString; +use std::fmt::{self, Write as _}; +use std::io; +use std::path::{Path, PathBuf}; + +use crate::manifest::{ManifestEntry, ParseError, parse_manifest, script_matches}; + +const DIR_VAR: &str = "CRAFT_FONTS_DIR"; +const REQUIRED_VAR: &str = "CRAFT_FONTS_REQUIRED"; +const EMPTY_TABLE: &str = "&[]\n"; + +/// Embed every face in `CRAFT_FONTS_DIR`'s manifest into `out_file` (relative paths are under +/// `OUT_DIR`). Shorthand for `Embed::new().write(out_file)`. +pub fn embed(out_file: impl AsRef) -> Result, Error> { + Embed::new().write(out_file) +} + +/// Whether the build script is building for wasm32 (web builds usually embed fewer faces). +pub fn target_is_wasm() -> bool { + std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|arch| arch == "wasm32") +} + +type Filter = Box bool>; + +/// Which faces to embed, and from where. [`Embed::write`] does the work. +#[derive(Default)] +pub struct Embed { + scripts: Option>, + filter: Option, + dir: Option, + required: Option, +} + +impl Embed { + /// Every face, from `CRAFT_FONTS_DIR`, required when `CRAFT_FONTS_REQUIRED` is set. + pub fn new() -> Self { + Self::default() + } + + /// Embed only faces with at least one of these script tags (see + /// [`ManifestEntry::has_script`]). With `CRAFT_FONTS_REQUIRED`, each tag must match a face. + pub fn scripts(mut self, tags: &[&str]) -> Self { + self.scripts = Some(tags.iter().map(|t| (*t).to_owned()).collect()); + self + } + + /// Embed only faces for which `keep` returns true (applied after [`Embed::scripts`]). + pub fn filter(mut self, keep: impl Fn(&ManifestEntry) -> bool + 'static) -> Self { + self.filter = Some(Box::new(keep)); + self + } + + /// Read this checkout instead of `CRAFT_FONTS_DIR`. + pub fn dir(mut self, dir: impl Into) -> Self { + self.dir = Some(dir.into()); + self + } + + /// Override `CRAFT_FONTS_REQUIRED`. + pub fn required(mut self, required: bool) -> Self { + self.required = Some(required); + self + } + + /// Write the face table to `out_file` and print the `cargo::` lines; returns the faces + /// embedded. + /// + /// Without a checkout the table is empty. A checkout that can't be read is a + /// `cargo::warning` and an empty table, or an [`Error`] when required. The table is written + /// in every case, so on an error the build fails only on the message the caller prints. + pub fn write(&self, out_file: impl AsRef) -> Result, Error> { + let env = Env { dir: std::env::var_os(DIR_VAR), required: std::env::var_os(REQUIRED_VAR), out_dir: std::env::var_os("OUT_DIR") }; + let mut cargo = Vec::new(); + let result = self.run(&env, out_file.as_ref(), &mut cargo); + for line in cargo { + println!("{line}"); + } + result + } + + pub(crate) fn run(&self, env: &Env, out_file: &Path, cargo: &mut Vec) -> Result, Error> { + cargo.push(format!("cargo::rerun-if-env-changed={DIR_VAR}")); + cargo.push(format!("cargo::rerun-if-env-changed={REQUIRED_VAR}")); + let out = match (out_file.is_absolute(), &env.out_dir) { + (true, _) => out_file.to_path_buf(), + (false, Some(out_dir)) => Path::new(out_dir).join(out_file), + (false, None) => return Err(Error::NoOutDir), + }; + let required = self.required.unwrap_or_else(|| env.required.as_ref().is_some_and(|v| !v.is_empty() && v != "0")); + let dir = self.dir.clone().or_else(|| env.dir.clone().filter(|d| !d.is_empty()).map(PathBuf::from)); + + let selected = match &dir { + None => Err(Error::NotSet), + Some(dir) => self.select(dir, cargo).and_then(|faces| if required { self.check_required(dir, faces) } else { Ok(faces) }), + }; + let (table, faces) = match selected { + Ok(faces) => (table(&faces), faces.into_iter().map(|(entry, _)| entry).collect()), + Err(e) if required => { + write(&out, EMPTY_TABLE)?; + return Err(e); + } + Err(Error::NotSet) => (EMPTY_TABLE.to_owned(), Vec::new()), + Err(e) => { + cargo.push(format!("cargo::warning=building without craft-fonts: {e}")); + (EMPTY_TABLE.to_owned(), Vec::new()) + } + }; + write(&out, &table)?; + Ok(faces) + } + + /// The wanted manifest entries with their canonical file paths. + fn select(&self, dir: &Path, cargo: &mut Vec) -> Result, Error> { + let manifest = dir.join("fonts/manifest.txt"); + cargo.push(format!("cargo::rerun-if-changed={}", manifest.display())); + let text = std::fs::read_to_string(&manifest).map_err(|source| Error::Read { path: manifest.clone(), source })?; + let entries = parse_manifest(&text).map_err(|error| Error::Parse { manifest: manifest.clone(), error })?; + let mut faces = Vec::new(); + for entry in entries { + let wanted = self.scripts.as_ref().is_none_or(|tags| tags.iter().any(|t| entry.has_script(t))); + if !wanted || self.filter.as_ref().is_some_and(|keep| !keep(&entry)) { + continue; + } + let file = dir.join(&entry.file); + // `include_bytes!` resolves relative paths from the including source file. + let path = file.canonicalize().map_err(|source| Error::Read { path: file, source })?; + cargo.push(format!("cargo::rerun-if-changed={}", path.display())); + faces.push((entry, path)); + } + Ok(faces) + } + + fn check_required(&self, dir: &Path, faces: Vec<(ManifestEntry, PathBuf)>) -> Result, Error> { + let manifest = dir.join("fonts/manifest.txt"); + if faces.is_empty() { + return Err(Error::NoFaces { manifest }); + } + let missing: Vec = self + .scripts + .iter() + .flatten() + .filter(|tag| !faces.iter().any(|(entry, _)| entry.scripts.iter().any(|s| script_matches(s, tag)))) + .cloned() + .collect(); + if missing.is_empty() { Ok(faces) } else { Err(Error::MissingScripts { manifest, scripts: missing }) } + } +} + +/// The environment a build script sees, separated out for tests. +pub(crate) struct Env { + pub dir: Option, + pub required: Option, + pub out_dir: Option, +} + +fn table(faces: &[(ManifestEntry, PathBuf)]) -> String { + let mut src = String::from("// Generated by craft-fonts-build. Do not edit.\n&[\n"); + for (entry, path) in faces { + let scripts: Vec = entry.scripts.iter().map(|s| format!("{s:?}")).collect(); + let _ = writeln!( + src, + " EmbeddedFace {{ family: {:?}, style: {:?}, scripts: &[{}], index: {}, bytes: include_bytes!({:?}) }},", + entry.family, + entry.style, + scripts.join(", "), + entry.index, + path.display().to_string(), + ); + } + src.push_str("]\n"); + src +} + +fn write(path: &Path, contents: &str) -> Result<(), Error> { + std::fs::write(path, contents).map_err(|source| Error::Write { path: path.to_path_buf(), source }) +} + +/// Why the faces could not be embedded. Print it as `cargo::error={error}`. +#[derive(Debug)] +#[non_exhaustive] +pub enum Error { + /// `CRAFT_FONTS_REQUIRED` is set but `CRAFT_FONTS_DIR` is unset or empty. + NotSet, + /// The manifest or a font file it lists can't be read. + Read { path: PathBuf, source: io::Error }, + /// The manifest has a malformed line. + Parse { manifest: PathBuf, error: ParseError }, + /// Required, but no face in the manifest was selected. + NoFaces { manifest: PathBuf }, + /// Required, but no face in the manifest has these requested script tags. + MissingScripts { manifest: PathBuf, scripts: Vec }, + /// The generated table can't be written. + Write { path: PathBuf, source: io::Error }, + /// `out_file` is relative and `OUT_DIR` is unset (not running as a build script). + NoOutDir, +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NotSet => { + write!(f, "{REQUIRED_VAR} is set but {DIR_VAR} is not: set {DIR_VAR} to the absolute path of a storytold/craft-fonts checkout") + } + Self::Read { path, source } => { + write!(f, "cannot read {} ({DIR_VAR} must be the absolute path of a storytold/craft-fonts checkout): {source}", path.display()) + } + Self::Parse { manifest, error } => write!(f, "{}: {error}", manifest.display()), + Self::NoFaces { manifest } => write!(f, "{REQUIRED_VAR} is set but no face in {} was selected", manifest.display()), + Self::MissingScripts { manifest, scripts } => { + write!(f, "{REQUIRED_VAR} is set but {} has no face for {}", manifest.display(), scripts.join(", ")) + } + Self::Write { path, source } => write!(f, "cannot write {}: {source}", path.display()), + Self::NoOutDir => write!(f, "OUT_DIR is not set: call craft-fonts-build from a build script, or pass an absolute out_file"), + } + } +} + +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Read { source, .. } | Self::Write { source, .. } => Some(source), + Self::Parse { error, .. } => Some(error), + _ => None, + } + } +} diff --git a/crates/fonts-build/src/lib.rs b/crates/fonts-build/src/lib.rs new file mode 100644 index 000000000..1b8d1363a --- /dev/null +++ b/crates/fonts-build/src/lib.rs @@ -0,0 +1,108 @@ +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +//! Embed the optional [craft-fonts](https://github.com/storytold/craft-fonts) faces from an app's +//! `build.rs`. +//! +//! Apps never depend on craft-fonts in `Cargo.toml` (Cargo would fetch the font repository for +//! every build). The build reads a local checkout named by `CRAFT_FONTS_DIR` instead: +//! +//! - **Unset or empty:** the table is empty and the app uses its own and system fonts. +//! - **Set:** every selected face in `fonts/manifest.txt` is embedded with `include_bytes!`. +//! - **Set, but not readable as a checkout:** a `cargo::warning` and an empty table. +//! - **`CRAFT_FONTS_REQUIRED=1`** (release builds): a missing or unreadable checkout, no selected +//! face, or a requested script without a face is an [`Error`]. +//! +//! Add this crate both as a build-dependency (for [`embed`]) and as a dependency (for +//! [`EmbeddedFace`]). In `build.rs`'s `main`: +//! +//! ```no_run +//! println!("cargo::rerun-if-changed=build.rs"); +//! let wasm = craft_fonts_build::target_is_wasm(); +//! let embedded = craft_fonts_build::Embed::new() +//! // Web builds have a size budget: embed only the Japanese UI face there. +//! .filter(move |face| !wasm || (face.family.as_str(), face.style.as_str()) == ("BIZ UDPGothic", "Regular")) +//! .write("craft_fonts.rs"); +//! if let Err(e) = embedded { +//! println!("cargo::error={e}"); +//! } +//! ``` +//! +//! and in the crate: +//! +//! ```ignore +//! use craft_fonts_build::EmbeddedFace; +//! +//! pub static CRAFT_FONTS: &[EmbeddedFace] = include!(concat!(env!("OUT_DIR"), "/craft_fonts.rs")); +//! ``` +//! +//! The generated file is a slice expression of `EmbeddedFace { .. }` literals, so any struct with +//! the same fields and that name in scope works too. + +mod embed; +mod manifest; +#[cfg(test)] +mod tests; + +pub use embed::{Embed, Error, embed, target_is_wasm}; +pub use manifest::{ManifestEntry, ParseError, parse_manifest}; + +/// A craft-fonts face embedded by [`embed`]: one line of `fonts/manifest.txt`. +#[derive(Clone, Copy)] +pub struct EmbeddedFace { + /// Family name, e.g. `"Noto Sans Arabic"`. + pub family: &'static str, + /// Style, e.g. `"Regular"` or `"Bold"`. + pub style: &'static str, + /// Script tags: ISO 15924 codes such as `Arab`, `Jpan`, `Hans`, `Hant`, optionally with a + /// region subtag (`Hant-HK` for Hong Kong forms, which have no ISO 15924 code). + pub scripts: &'static [&'static str], + /// Face index in a font collection (`.ttc`); `0` for a single font. + pub index: u32, + pub bytes: &'static [u8], +} + +impl EmbeddedFace { + /// `"