diff --git a/crates/doc/src/locale_number.rs b/crates/doc/src/locale_number.rs index 072b50a2b1..336269f647 100644 --- a/crates/doc/src/locale_number.rs +++ b/crates/doc/src/locale_number.rs @@ -22,10 +22,29 @@ pub fn decimal_separator(lang: Option<&str>) -> char { const SPACES: &[char] = &[' ', '\u{a0}', '\u{202f}', '\u{2009}']; const CURRENCY: &[char] = &['$', '€', '£', '¥', '¢', '₽', '₴', '₺', '₹', '₩', '₪', '₫', '฿', '₱', '₸', '₼', '₾']; +/// Currency signs spelled with more than one character: the Panamanian balboa (`B/.`). +const CURRENCY_SYMBOLS: &[&str] = &["B/."]; -/// Strip a currency symbol or a short currency word (`zł`, `kr`, `PLN`) from either end. +/// Trim spaces and single-character currency signs from either end. +fn trim_currency(s: &str) -> &str { + s.trim_matches(|c: char| c.is_whitespace() || CURRENCY.contains(&c)) +} + +/// Strip a currency symbol or a short currency word (`zł`, `kr`, `PLN`, `B/.`) from either end. +/// As Word does, a multi-character sign is a word of its own (`B/. 1,250.50`, `1,250.50 B/.`); +/// `B/.1,250.50` and `B/ 1,250.50` are not numbers. fn strip_currency(s: &str) -> &str { - let mut s = s.trim_matches(|c: char| c.is_whitespace() || CURRENCY.contains(&c)); + let mut s = trim_currency(s); + if let Some(rest) = + CURRENCY_SYMBOLS.iter().find_map(|sym| s.strip_prefix(sym).filter(|rest| rest.chars().next().is_some_and(char::is_whitespace))) + { + s = trim_currency(rest); + } + if let Some(rest) = + CURRENCY_SYMBOLS.iter().find_map(|sym| s.strip_suffix(sym).filter(|rest| rest.chars().next_back().is_some_and(char::is_whitespace))) + { + s = trim_currency(rest); + } if let Some((head, word)) = s.rsplit_once(SPACES) && (1..=3).contains(&word.chars().count()) && word.chars().all(|c| c.is_alphabetic() || c == '.') @@ -137,6 +156,7 @@ mod tests { assert_eq!(parse_number("1.5", pl), Some(1.5)); assert_eq!(parse_number("-2,25", pl), Some(-2.25)); assert_eq!(parse_number("PLN 12", pl), Some(12.0)); + assert_eq!(parse_number("-B/. 1 250,50", pl), Some(-1250.5)); assert_eq!(parse_number("abc", pl), None); assert_eq!(parse_number("1,2,3", pl), None); // From review: ordinals, a leading zero, percentages and an en dash minus. @@ -174,11 +194,40 @@ mod tests { assert_eq!(format_number(5.75, Some("es-ES")), "5,75"); } + #[test] + fn balboa_numbers() { + let en = Some("en-US"); + // Word 16.0 reads `B/.` as a word of its own, before or after the number. + assert_eq!(parse_number("B/. 1,234.50", en), Some(1234.5)); + assert_eq!(parse_number("1,234.50 B/.", en), Some(1234.5)); + assert_eq!(parse_number("B/.\u{a0}1,234.50", en), Some(1234.5), "a no-break space after the sign"); + assert_eq!(parse_number("B/. 1,234.50 B/.", en), Some(1234.5), "the sign at both ends"); + assert_eq!(parse_number("-B/. 1,234.50", en), Some(-1234.5), "a minus before the sign"); + assert_eq!(parse_number("B/. 1,234.50", Some("es-PA")), Some(1234.5), "the balboa's own region"); + // The sign has no space of its own in Word: touching the number is text. + assert_eq!(parse_number("B/.1,234.50", en), None); + assert_eq!(parse_number("1,234.50B/.", en), None); + assert_eq!(parse_number("B/ 1,234.50", en), None, "the balboa is B/. in Word"); + assert_eq!(parse_number("B/.", en), None); + // The slash and the full stop make `B/.` ambiguous with a fraction: the sign is matched at + // the ends of the text only, so a fraction is never read as a balboa amount and a sign in + // the middle is not a sign. + assert_eq!(parse_number("1/.5", en), None, "a fraction is not a price"); + assert_eq!(parse_number("1/.5", Some("es-PA")), None); + assert_eq!(parse_number("1/.5 B/.", en), None, "the sign at the end does not rescue a fraction"); + assert_eq!(parse_number("B/. 1/.5", en), None); + assert_eq!(parse_number("2 B/. 1,234.50", en), None, "the sign sits before the number"); + assert_eq!(parse_number("1,234.50 B/. 1,234.50", en), None, "the sign sits between two numbers"); + assert_eq!(parse_number("1,234.50/2", en), None, "a division is not a balboa amount"); + assert_eq!(parse_number("B/. 1,234.50,50", en), None, "a second decimal separator"); + } + #[test] fn english_numbers() { let en = Some("en-US"); assert_eq!(parse_number("1,250.50", en), Some(1250.5)); assert_eq!(parse_number("$1,250", en), Some(1250.0)); + assert_eq!(parse_number("PAB 1,250.50", en), Some(1250.5), "the ISO code"); assert_eq!(parse_number("1.5", en), Some(1.5)); assert_eq!(parse_number("12", None), Some(12.0)); assert_eq!(parse_number(".5", None), Some(0.5)); diff --git a/crates/engine/src/cmd/table_formula.rs b/crates/engine/src/cmd/table_formula.rs index 6e6342d7bc..af30eadb05 100644 --- a/crates/engine/src/cmd/table_formula.rs +++ b/crates/engine/src/cmd/table_formula.rs @@ -924,6 +924,33 @@ mod tests { #[test] fn numbers_read_currency_and_separators_but_not_letters() { assert_eq!(parse_number("$1,200", None), Some(1200.0)); + assert_eq!(parse_number("B/. 1,234.50", None), Some(1234.5), "the Panamanian balboa"); + assert_eq!(parse_number("1,234.50 B/.", None), Some(1234.5)); + assert_eq!(parse_number("(B/. 1,234.50)", None), Some(-1234.5), "an accounting negative"); + assert_eq!(parse_number("B/.1,234.50", None), None, "Word needs a space after the sign"); + assert_eq!(parse_number("1/.5", None), None, "a fraction is not a balboa amount"); + // The formula reads the cell in the document's language. With the document's default text + // as es-PA and the editing language as pl-PL the choice is observable: `1,250.50` is a + // number under es-PA and not under pl-PL (whose decimal separator is `,`). + let balboa = table(&[&["B/. 1,234.50"], &["(B/. 1,234.50)"], &[""]]); + let mut doc = wordcraft_doc::Document::new(); + std::sync::Arc::make_mut(&mut doc.styles).default_chr.lang = Some("es-PA".into()); + assert_eq!( + evaluate_with(&balboa, Some(&doc), Some("pl-PL"), 2, 0, "=SUM(ABOVE)"), + Ok(0.0), + "a balboa cell sums in the document's language, and its negative in parentheses cancels it", + ); + assert_eq!(evaluate_with(&balboa, Some(&doc), Some("pl-PL"), 2, 0, "=COUNT(ABOVE)"), Ok(2.0), "both balboa cells are numbers",); + // And the other way around: a balboa cell written with Polish separators is read only when + // the document language reaches the parser, not when it falls back to English. + let mut polish = wordcraft_doc::Document::new(); + std::sync::Arc::make_mut(&mut polish.styles).default_chr.lang = Some("pl-PL".into()); + let balboa_pl = table(&[&["B/. 1 250,50"], &[""]]); + assert_eq!( + evaluate_with(&balboa_pl, Some(&polish), Some("en-US"), 1, 0, "=SUM(ABOVE)"), + Ok(1250.5), + "the cell's language selects the separators; English does not read `1 250,50`", + ); assert_eq!(parse_number(" 12% ", None), Some(12.0)); assert_eq!(parse_number("(5)", None), Some(-5.0)); assert_eq!(parse_number("x9", None), None); diff --git a/docs/localization-parity.md b/docs/localization-parity.md index a4fd5cab55..12bfea9749 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** trivial (Arabic: the dialogs of the mirrored Arabic interface read right to left: label-and-field rows, tables (list columns, the Models table), tile galleries and the Add/Remove arrows follow the reading direction, through one `rtl::Table` helper that draws exactly as before in English; no ranked gap closed, readiness estimates unchanged); previously minor (Options › Language redesign: Your languages, Add a language…, Set as default; the Dictionaries folder moved from Proofing); previously trivial (Language packs: the missing-pack bar works on a fresh install and counts the caret's language); previously trivial (Language lists: every installed and available Language pack's language can be chosen in Set Proofing Language, installed ones in Review › Language and the status bar; craft-i18n.rev at 9d6d54d: region names and the Language (Region) template in 35 locales, craft-i18n#145); previously trivial (craft-i18n.rev at cde4900: rows for Options › Language, the missing-pack bar and 28 Language pack language names in 35 locales, craft-i18n#136); previously trivial (craft-proof pin: French elision suggestions first, ca-ES-valencia reads the ca-valencia pack); previously minor (Arabic `ar` interface: craft-i18n catalog, mirrored shell, font fallback, #1195; 28 languages); previously trivial (Language packs: language-packs-2 is published and pinned: 75 packs, adding Danish, German (Germany, Austria, Switzerland), Esperanto, Estonian, Persian, Croatian, Hungarian, Norwegian (Bokmål, Nynorsk), Dutch and Swedish (Sweden, Finland)); previously trivial (Language packs: language-packs-1 is published and pinned, 61 packs; the language requests it covers); previously minor (Language packs: languages beyond English install their spelling dictionary and hyphenation as a pack from File › Options › Language or the missing-pack bar; the default build embeds English Language data only) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** trivial (table sort and formulas read the Panamanian balboa's `B/.` around a number (`B/. 1,250.50`, `1,250.50 B/.`), as Word does, matched only at the ends of the text so `1/.5` stays a fraction); previously trivial (Arabic: the dialogs of the mirrored Arabic interface read right to left: label-and-field rows, tables (list columns, the Models table), tile galleries and the Add/Remove arrows follow the reading direction, through one `rtl::Table` helper that draws exactly as before in English; no ranked gap closed, readiness estimates unchanged); previously minor (Options › Language redesign: Your languages, Add a language…, Set as default; the Dictionaries folder moved from Proofing); previously trivial (Language packs: the missing-pack bar works on a fresh install and counts the caret's language); previously trivial (Language lists: every installed and available Language pack's language can be chosen in Set Proofing Language, installed ones in Review › Language and the status bar; craft-i18n.rev at 9d6d54d: region names and the Language (Region) template in 35 locales, craft-i18n#145); previously trivial (craft-i18n.rev at cde4900: rows for Options › Language, the missing-pack bar and 28 Language pack language names in 35 locales, craft-i18n#136); previously trivial (craft-proof pin: French elision suggestions first, ca-ES-valencia reads the ca-valencia pack); previously minor (Arabic `ar` interface: craft-i18n catalog, mirrored shell, font fallback, #1195; 28 languages); previously trivial (Language packs: language-packs-2 is published and pinned: 75 packs, adding Danish, German (Germany, Austria, Switzerland), Esperanto, Estonian, Persian, Croatian, Hungarian, Norwegian (Bokmål, Nynorsk), Dutch and Swedish (Sweden, Finland)); previously trivial (Language packs: language-packs-1 is published and pinned, 61 packs; the language requests it covers); previously minor (Language packs: languages beyond English install their spelling dictionary and hyphenation as a pack from File › Options › Language or the missing-pack bar; the default build embeds English Language data only) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 Interface languages, script support and proofing per language. How translation works (adding a language, the embedded catalogs): `crates/ui-egui/src/i18n.rs`. The catalogs, their format and @@ -219,7 +219,12 @@ Polish, `1.250,50` in German, `1,250.50` in English) and write formula results w separator (`crates/doc/src/locale_number.rs`). Cuban and Peruvian Spanish (`es-CU`, `es-PE`) use decimal points (`1,234.50`), matching the existing region settings and CLDR's Latin American Spanish parent locale; `cuban_and_peruvian_numbers_use_decimal_points` covers parsing and result -formatting. Interface lists (styles, fonts) still sort by code point. +formatting. Currency signs and short currency words around the number are ignored, as Word +ignores them: a single-character sign may touch the number (`$1,250`, `1 250,00 zł`, `PLN 12`) +while the Panamanian balboa's `B/.` is a word of its own (`B/. 1,250.50`, `1,250.50 B/.`, as +observed in Word; `B/.1,250.50` and `B/ 1,250.50` are not numbers). Because `B/.` carries a slash +and a full stop, it is matched only at the ends of the text, so `1/.5` and `1,250.50/2` are not +read as balboa amounts. Interface lists (styles, fonts) still sort by code point. Russian (`ru`) sorting keeps ё with е (a secondary diaeresis difference), before ж. Composed and decomposed ё and й have the same keys and index headings; the stored text is unchanged. Tests cover Russian locale aliases, case, representative words and these two canonical pairs. This is a narrow Cyrillic correction, not full Unicode normalization or CLDR collation. Ordering reference: [Unicode CLDR Russian collation chart](https://unicode.org/cldr/charts/42/collation/ru.html). @@ -270,6 +275,7 @@ WordCraft keeps 2,54 cm). | Date | Change | Summary | |---|---|---| +| 2026-11-04 | trivial | Table sort and formulas read the Panamanian balboa's `B/.` around a number (`B/. 1,250.50`, `1,250.50 B/.`), as Word does; `B/.1,250.50` and `B/ 1,250.50` stay text, matching Word 16.0, and the sign is matched only at the ends of the text, so `1/.5` is unchanged | | 2026-11-04 | trivial | craft-i18n.rev at 9d6d54d (craft-i18n#145): 69 region names and the `{language} ({region})` template (full-width in ja, ko, zh) in 35 locales, AI-assisted without native-speaker review | | 2026-11-04 | trivial | craft-i18n.rev at cde4900 (craft-i18n#136): translated rows for Options › Language (Your languages, Add a language…, Set as default, Dictionary files), the missing-pack bar and 28 Language pack language names in 35 locales, AI-assisted without native-speaker review | | 2026-11-03 | trivial | craft-proof pin moved to craft-libs main with #589 and #590: French suggestions offer the elided form first (`Jadore` → `J'adore` before `J adore`; English keeps plural fixes first), and Valencian (`ca-ES-valencia`) text reads the `ca-valencia` pack |