diff --git a/ROADMAP.md b/ROADMAP.md index 070552d07..e093b46a1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (rows auto-fit oversized fonts on open and whole-row/column formatting) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (regional formats, `app.region`) · **Target:** Microsoft Excel (Microsoft 365) GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web. @@ -93,7 +93,8 @@ The ribbon is translated into Japanese, Simplified Chinese, Korean, Brazilian Po and German (266–268 labels each, ~12% of ~2,200 strings; merged 10-10 in #31, #104, #82, German added 10-11), the interface follows the system language, and CJK, Thai and Hebrew text falls back to system fonts -(#38). Dialogs, menus and messages are English; formulas and number formats are en-US only. +(#38). Dialogs, menus and messages are English; formulas are en-US (plus Spanish). Numbers, dates +and times follow a Regional format setting (15 regions; File › Options, `app.region`). Localized German formulas and numbers are in PR #163. Detail: [localization-parity.md](docs/localization-parity.md). | Language | Code | Status | UI strings | @@ -165,6 +166,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | 2026-10-11 | XLSX pass-through: custom XML, external links, connections, query tables, printer settings, form/ActiveX controls and OLE objects are kept on save (#330, #324) | | 2026-10-11 | Data › Forecast › Forecast Sheet: a new sheet with the history, FORECAST.ETS forecast, confidence bounds, statistics and a chart (#322) | | 2026-10-11 | Startup no longer panics on a system font egui can't parse: fonts are validated and skipped (#226) | +| 2026-10-11 | Regional formats: File › Options › Regional format (`app.region`) changes how numbers, dates and times are typed and shown, for 15 regions | | 2026-10-11 | Ctrl+drag on the fill handle swaps copying and extending a series (#264) | | 2026-10-11 | Odd-period bond functions ODDFPRICE, ODDFYIELD, ODDLPRICE, ODDLYIELD: Financial 55 / 55 (#275) | | 2026-10-11 | PERCENTOF and EUROCONVERT worksheet functions (#279) | @@ -201,6 +203,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | 2026-10-12 | minor | Ink preservation across XLSX save/reopen (ink portion of #551) | | 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. | | 2026-10-11 | minor | Password-encrypted workbooks open (#11); progress log row | +| 2026-10-11 | minor | Regional formats (`app.region`); progress log row | | 2026-10-11 | minor | Forecast Sheet (#322); progress log row | | 2026-10-11 | minor | Worksheet functions 505 / 523 (ODD* bond functions); progress log row | | 2026-10-11 | minor | Worksheet functions 503 / 523 (PERCENTOF, EUROCONVERT); progress log row | diff --git a/apps/gridcraft-web/src/web.rs b/apps/gridcraft-web/src/web.rs index c0fcb22da..c141b0938 100644 --- a/apps/gridcraft-web/src/web.rs +++ b/apps/gridcraft-web/src/web.rs @@ -58,6 +58,12 @@ pub fn start() { ) { app.ui.language = browser; } + // The browser's regional format too, with the same CJK exception: month names and AM/PM + // words of those regions need a CJK font the web build doesn't have. + let region = gridcraft_ui_egui::i18n::system_region(); + if !matches!(region.tag, "ja-JP" | "ko-KR" | "zh-CN" | "zh-TW") { + let _ = app.session.set_region(region.tag); + } SheetApp::setup_context_for_language(&cc.egui_ctx, false, app.ui.language); if let Some(bridge) = &app_bridge { if let Some(author) = query_param("author") { diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs index 7c0eb04e6..dd24d045f 100644 --- a/apps/gridcraft/src/main.rs +++ b/apps/gridcraft/src/main.rs @@ -105,6 +105,9 @@ fn load_prefs(app: &mut SheetApp) { app.ui.language = gridcraft_ui_egui::i18n::Language::system(); // ...and its light or dark appearance (#239), until a theme is chosen and saved in ui.json. app.ui.system_theme = true; + // ...and its regional format (separators, date order), until one is chosen and saved in prefs.json. + app.session.prefs.region = gridcraft_ui_egui::i18n::system_region().tag.into(); + app.session.apply_region(); let Some(dir) = config_dir() else { return }; if let Ok(b) = std::fs::read(dir.join("ui.json")) && let Ok(ui) = serde_json::from_slice::(&b) @@ -115,9 +118,16 @@ fn load_prefs(app: &mut SheetApp) { app.ui.language = language; } if let Ok(b) = std::fs::read(dir.join("prefs.json")) - && let Ok(p) = serde_json::from_slice(&b) + && let Ok(mut p) = serde_json::from_slice::(&b) { + // A prefs.json from before regions existed has no region: keep the system's. + let saved_region = + serde_json::from_slice::(&b).ok().is_some_and(|v| v.get("region").is_some_and(serde_json::Value::is_string)); + if !saved_region { + p.region = app.session.prefs.region.clone(); + } app.session.prefs = p; + app.session.apply_region(); } } diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index d5adbc07e..5e20fb473 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -1,13 +1,15 @@ //! GridCraft core types: cell addresses, values and Excel's coercion rules, error values, -//! date serials and typed-input parsing. No dependencies on the rest of the workspace. +//! date serials, regional formats and typed-input parsing. No dependencies on the rest of the workspace. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] pub mod addr; pub mod date; pub mod parse; +pub mod region; pub mod value; pub use addr::{CellRef, MAX_COLS, MAX_ROWS, RangeRef, col_to_letters, letters_to_col}; pub use date::DateSystem; +pub use region::Region; pub use value::{Array, CellError, Value, compare, compare_numbers, compare_text, number_to_text, sort_compare}; diff --git a/crates/core/src/parse.rs b/crates/core/src/parse.rs index b465beaf9..67dd1e064 100644 --- a/crates/core/src/parse.rs +++ b/crates/core/src/parse.rs @@ -2,9 +2,13 @@ //! //! Recognises numbers with thousands separators, percentages, currency, scientific notation, //! fractions (`1 1/2`), dates (`3/4/2026`, `2026-03-04`, `4-Mar-2026`, `Mar 4, 2026`), times -//! (`10:30`, `10:30:15 PM`) and date-times. Locale: en-US. +//! (`10:30`, `10:30:15 PM`) and date-times. [`parse_input`] reads them the en-US way; +//! [`parse_input_in`] reads what someone types in another [`Region`]: its decimal and group +//! separators (`1.234,56`), currency symbol, day-month-year order (`25/09/2026`), month names +//! and AM/PM words. -use crate::date::{DateSystem, MONTHS, serial_from_ymd, time_fraction}; +use crate::date::{DateSystem, serial_from_ymd, time_fraction}; +use crate::region::{DateOrder, EN_US, Grouping, Region}; use crate::value::{CellError, Value}; /// A parsed entry: its value and the number format Excel would apply automatically. @@ -14,9 +18,15 @@ pub struct Parsed { pub format: Option<&'static str>, } -/// Interprets typed cell input (not formulas). Text that isn't a number, date, boolean or error -/// stays text. A leading apostrophe forces text. +/// Interprets typed cell input (not formulas) the en-US way. Text that isn't a number, date, +/// boolean or error stays text. A leading apostrophe forces text. Files and imports use this. pub fn parse_input(s: &str, sys: DateSystem) -> Parsed { + parse_input_in(s, sys, &EN_US) +} + +/// [`parse_input`] for what a person types in `region`. The formats it returns are canonical +/// format codes (`m/d/yyyy` is the system short date, which the region's display decides). +pub fn parse_input_in(s: &str, sys: DateSystem, region: &Region) -> Parsed { if s.is_empty() { return Parsed { value: Value::Empty, format: None }; } @@ -33,76 +43,102 @@ pub fn parse_input(s: &str, sys: DateSystem) -> Parsed { if let Some(e) = CellError::parse(t) { return Parsed { value: Value::Error(e), format: None }; } - if let Some((n, f)) = parse_numeric(t, sys) { + if let Some((n, f)) = parse_numeric(t, sys, region) { return Parsed { value: Value::number(n), format: f }; } Parsed { value: Value::text(s), format: None } } -/// Numeric text as arithmetic coerces it ("1,000", "50%", "$3", "1/2/2020", "10:00"). +/// Numeric text as arithmetic coerces it ("1,000", "50%", "$3", "1/2/2020", "10:00"). en-US: +/// worksheet text coercion does not follow the region. pub fn parse_number_text(s: &str) -> Option { parse_number_text_in(s, DateSystem::D1900) } /// Like [`parse_number_text`], with date text read in the given workbook date system. pub fn parse_number_text_in(s: &str, sys: DateSystem) -> Option { - parse_numeric(s.trim(), sys).map(|(n, _)| n) + parse_numeric(s.trim(), sys, &EN_US).map(|(n, _)| n) } -fn parse_numeric(t: &str, sys: DateSystem) -> Option<(f64, Option<&'static str>)> { +fn parse_numeric(t: &str, sys: DateSystem, r: &Region) -> Option<(f64, Option<&'static str>)> { if t.is_empty() { return None; } // Parenthesised negatives: (100) = -100. if let Some(inner) = t.strip_prefix('(').and_then(|x| x.strip_suffix(')')) { - return parse_numeric(inner.trim(), sys).map(|(n, f)| (-n, f)); + return parse_numeric(inner.trim(), sys, r).map(|(n, f)| (-n, f)); } - if let Some(r) = parse_plain(t) { - return Some(r); + if let Some(res) = parse_plain(t, r) { + return Some(res); } - if let Some(r) = parse_fraction(t) { - return Some((r, Some("# ?/?"))); + if let Some(res) = parse_fraction(t) { + return Some((res, Some("# ?/?"))); } - if let Some(r) = parse_datetime(t, sys) { - return Some(r); + if let Some(res) = parse_datetime(t, sys, r) { + return Some(res); } None } -/// Signs, currency, thousands separators, percent, exponent. -fn parse_plain(t: &str) -> Option<(f64, Option<&'static str>)> { +/// Digit groups of an integer part, accepted when they follow the region's grouping. +fn groups_ok(groups: &[&str], grouping: Grouping) -> bool { + let (Some(first), Some(last)) = (groups.first(), groups.last()) else { return false }; + if first.is_empty() || first.len() > 3 { + return false; + } + let thousands = groups.iter().skip(1).all(|g| g.len() == 3); + match grouping { + Grouping::Thousands => thousands, + // Indian: 12,34,567. The thousands pattern is read too. + Grouping::Indian => { + let middle = groups.get(1..groups.len().saturating_sub(1)).unwrap_or(&[]); + thousands || (first.len() <= 2 && last.len() == 3 && middle.iter().all(|g| g.len() == 2)) + } + } +} + +/// Signs, currency, group separators, percent, exponent. +fn parse_plain(t: &str, r: &Region) -> Option<(f64, Option<&'static str>)> { let mut s = t; let mut neg = false; let mut currency = false; let mut percent = false; - if let Some(r) = s.strip_prefix('-') { + if let Some(rest) = s.strip_prefix('-') { neg = true; - s = r.trim_start(); - } else if let Some(r) = s.strip_prefix('+') { - s = r.trim_start(); - } - if let Some(r) = s.strip_prefix('$') { - currency = true; - s = r.trim_start(); - if !neg && let Some(r2) = s.strip_prefix('-') { - neg = true; - s = r2; + s = rest.trim_start(); + } else if let Some(rest) = s.strip_prefix('+') { + s = rest.trim_start(); + } + if r.currency_first() { + if let Some(rest) = s.strip_prefix(r.currency) { + currency = true; + s = rest.trim_start(); + if !neg && let Some(rest2) = s.strip_prefix('-') { + neg = true; + s = rest2; + } } + } else if let Some(rest) = s.trim_end().strip_suffix(r.currency) { + currency = true; + s = rest.trim_end(); } - if let Some(r) = s.strip_suffix('%') { + if let Some(rest) = s.strip_suffix('%') { percent = true; - s = r.trim_end(); + s = rest.trim_end(); } if s.is_empty() { return None; } - let has_sep = s.contains(','); + // Where the decimal is not a point, a point is only valid as a group separator. + if r.decimal != '.' && s.contains('.') && !r.is_group('.') { + return None; + } + let has_sep = s.chars().any(|c| r.is_group(c)); let digits: String = if has_sep { - // Thousands separators must be in groups of three before the decimal point. - let (int, frac) = s.split_once('.').map_or((s, None), |(a, b)| (a, Some(b))); - let groups: Vec<&str> = int.split(',').collect(); - let first = groups.first()?; - if first.is_empty() || first.len() > 3 || groups.iter().skip(1).any(|g| g.len() != 3) { + // Group separators must split the integer part into valid groups. + let (int, frac) = s.split_once(r.decimal).map_or((s, None), |(a, b)| (a, Some(b))); + let groups: Vec<&str> = int.split(|c| r.is_group(c)).collect(); + if !groups_ok(&groups, r.grouping) { return None; } let mut d = groups.concat(); @@ -111,8 +147,10 @@ fn parse_plain(t: &str) -> Option<(f64, Option<&'static str>)> { d.push_str(f); } d - } else { + } else if r.decimal == '.' { s.to_string() + } else { + s.replacen(r.decimal, ".", 1) }; let b = digits.as_bytes(); if !b.iter().all(|c| c.is_ascii_digit() || matches!(c, b'.' | b'e' | b'E' | b'+' | b'-')) { @@ -132,14 +170,15 @@ fn parse_plain(t: &str) -> Option<(f64, Option<&'static str>)> { if neg { n = -n; } + let decimals = digits.contains('.'); let fmt = if currency { - Some(if digits.contains('.') { "\"$\"#,##0.00" } else { "\"$\"#,##0" }) + Some(if decimals { r.currency_format } else { r.currency_format_int }) } else if percent { - Some(if digits.contains('.') { "0.00%" } else { "0%" }) + Some(if decimals { "0.00%" } else { "0%" }) } else if sci { Some("0.00E+00") } else if has_sep { - Some(if digits.contains('.') { "#,##0.00" } else { "#,##0" }) + Some(if decimals { "#,##0.00" } else { "#,##0" }) } else { None }; @@ -161,25 +200,33 @@ fn parse_fraction(t: &str) -> Option { Some(if neg { -v } else { v }) } -fn month_from_name(s: &str) -> Option { - let l = s.to_ascii_lowercase(); - if l.len() < 3 { - return None; +/// Strips an AM/PM word from a lowercase time: the region's, `am`/`pm`, or a bare `a`/`p` +/// after the time. CJK regions put the word before the time (`午後3:30`). +fn split_ampm(lower: &str, r: &Region) -> (String, Option) { + let region_am = r.am_pm[0].to_lowercase(); + let region_pm = r.am_pm[1].to_lowercase(); + let words: [(&str, bool); 4] = [(®ion_am, false), (®ion_pm, true), ("am", false), ("pm", true)]; + for (w, pm) in words { + // Only a non-ASCII word (`午後`) may come first. + let stripped = lower.strip_suffix(w).or_else(|| if w.is_ascii() { None } else { lower.strip_prefix(w) }); + if let Some(b) = stripped { + return (b.trim().to_string(), Some(pm)); + } + } + if let Some(b) = lower.strip_suffix('a') { + return (b.trim().to_string(), Some(false)); } - MONTHS.iter().position(|m| m.to_ascii_lowercase().starts_with(&l)).map(|i| i as u32 + 1) + if let Some(b) = lower.strip_suffix('p') { + return (b.trim().to_string(), Some(true)); + } + (lower.to_string(), None) } /// Time part: `h:mm`, `h:mm:ss`, `h:mm:ss.000`, `h AM`, with optional AM/PM. -fn parse_time(t: &str) -> Option<(f64, bool, bool)> { - let t = t.trim(); - let lower = t.to_ascii_lowercase(); - let (body, ampm) = if let Some(b) = lower.strip_suffix("am").or_else(|| lower.strip_suffix('a')) { - (b.trim().to_string(), Some(false)) - } else if let Some(b) = lower.strip_suffix("pm").or_else(|| lower.strip_suffix('p')) { - (b.trim().to_string(), Some(true)) - } else { - (lower.clone(), None) - }; +fn parse_time(t: &str, r: &Region) -> Option<(f64, bool, bool)> { + let lower = t.trim().to_lowercase(); + let (body, ampm) = split_ampm(&lower, r); + let body = if r.time_sep == ':' { body } else { body.replace(r.time_sep, ":") }; let parts: Vec<&str> = body.split(':').collect(); if parts.len() == 1 && ampm.is_none() { return None; @@ -189,7 +236,11 @@ fn parse_time(t: &str) -> Option<(f64, bool, bool)> { } let mut h: f64 = parts.first()?.parse().ok()?; let m: f64 = parts.get(1).map_or(Some(0.0), |p| p.parse().ok())?; - let s: f64 = parts.get(2).map_or(Some(0.0), |p| p.parse().ok())?; + let s: f64 = match parts.get(2) { + None => 0.0, + Some(p) if r.decimal == '.' => p.parse().ok()?, + Some(p) => p.replacen(r.decimal, ".", 1).parse().ok()?, + }; if m >= 60.0 || s >= 60.0 || h < 0.0 || m < 0.0 || s < 0.0 { return None; } @@ -209,62 +260,85 @@ fn parse_time(t: &str) -> Option<(f64, bool, bool)> { Some((time_fraction(h, m, s), parts.len() == 3, ampm.is_some())) } -fn parse_date(t: &str, sys: DateSystem) -> Option<(f64, &'static str)> { +/// A two-digit year is 20xx below 30, else 19xx. +fn full_year(y: i64, digits: usize) -> i64 { + if digits <= 2 { if y < 30 { 2000 + y } else { 1900 + y } } else { y } +} + +/// Words that join the parts of a long date in some regions (`25 de septiembre de 2026`). +fn is_connector(w: &str, r: &Region) -> bool { + let w = w.to_lowercase(); + (matches!(r.tag, "es-ES" | "pt-BR" | "pt-PT") && matches!(w.as_str(), "de" | "del")) || (r.tag == "ru-RU" && matches!(w.as_str(), "г" | "г.")) +} + +fn parse_date(t: &str, sys: DateSystem, r: &Region) -> Option<(f64, &'static str)> { let t = t.trim().trim_end_matches(','); let num = |s: &str| s.trim().parse::().ok(); - // ISO yyyy-mm-dd or yyyy/mm/dd + let month_name = |s: &str| r.month_from_name(s); let seps: &[char] = &['/', '-', '.']; let parts: Vec<&str> = t.split(seps).collect(); if parts.len() == 3 { - let (a, b, c) = (parts[0].trim(), parts[1].trim(), parts[2].trim()); + let (a, b, c) = (parts.first()?.trim(), parts.get(1)?.trim(), parts.get(2)?.trim()); + // Year first: ISO yyyy-mm-dd or yyyy/mm/dd. if a.len() == 4 && let (Some(y), Some(m), Some(d)) = (num(a), num(b), num(c)) { - return valid_ymd(sys, y, m, d).map(|s| (s, "yyyy-mm-dd")); + // A year-first region typing its own short date keeps the system short date. + let own = r.date_order == DateOrder::Ymd && t.contains(r.date_sep) && r.date_sep != '-'; + let fmt = if own { "m/d/yyyy" } else { "yyyy-mm-dd" }; + return valid_ymd(sys, y, m, d).map(|s| (s, fmt)); } - if let (Some(m), Some(d), Some(y)) = (num(a), num(b), num(c)) { - let y = if c.len() <= 2 { if y < 30 { 2000 + y } else { 1900 + y } } else { y }; - return valid_ymd(sys, y, m, d).map(|s| (s, "m/d/yyyy")); + if let (Some(x), Some(y), Some(z)) = (num(a), num(b), num(c)) { + let (m, d, yy, ydigits) = match r.date_order { + DateOrder::Mdy => (x, y, z, c.len()), + DateOrder::Dmy => (y, x, z, c.len()), + DateOrder::Ymd => (y, z, x, a.len()), + }; + return valid_ymd(sys, full_year(yy, ydigits), m, d).map(|s| (s, "m/d/yyyy")); } // 4-Mar-2026 - if let (Some(d), Some(m), Some(y)) = (num(a), month_from_name(b), num(c)) { - let y = if c.len() <= 2 { if y < 30 { 2000 + y } else { 1900 + y } } else { y }; - return valid_ymd(sys, y, m as i64, d).map(|s| (s, "d-mmm-yy")); + if let (Some(d), Some(m), Some(y)) = (num(a), month_name(b), num(c)) { + return valid_ymd(sys, full_year(y, c.len()), m as i64, d).map(|s| (s, "d-mmm-yy")); } } if parts.len() == 2 { - let (a, b) = (parts[0].trim(), parts[1].trim()); - // m/d in the current year is ambiguous without a clock; use month/day of 2026? Excel uses - // the current year. We accept d-mmm / mmm-d only with month names, and m/d → current year. - if let (Some(m), Some(d)) = (num(a), num(b)) { - let y = current_year(); + let (a, b) = (parts.first()?.trim(), parts.get(1)?.trim()); + // A date without a year is in the current year (what Excel does). A name is accepted + // with a day or a year. + if let (Some(x), Some(y)) = (num(a), num(b)) { + let (m, d) = match r.date_order { + DateOrder::Dmy => (y, x), + DateOrder::Mdy | DateOrder::Ymd => (x, y), + }; if (1..=12).contains(&m) && (1..=31).contains(&d) { - return valid_ymd(sys, y, m, d).map(|s| (s, "d-mmm")); + return valid_ymd(sys, current_year(), m, d).map(|s| (s, "d-mmm")); + } + // month/yyyy (and yyyy/month in year-first regions). + if b.len() == 4 && (1..=12).contains(&x) { + return valid_ymd(sys, y, x, 1).map(|s| (s, "mmm-yy")); } - // m/yyyy - if (1..=12).contains(&m) && b.len() == 4 { - return valid_ymd(sys, d, m, 1).map(|s| (s, "mmm-yy")); + if a.len() == 4 && r.date_order == DateOrder::Ymd && (1..=12).contains(&y) { + return valid_ymd(sys, x, y, 1).map(|s| (s, "mmm-yy")); } } - if let (Some(d), Some(m)) = (num(a), month_from_name(b)) { + if let (Some(d), Some(m)) = (num(a), month_name(b)) { return valid_ymd(sys, current_year(), m as i64, d).map(|s| (s, "d-mmm")); } - if let (Some(m), Some(y)) = (month_from_name(a), num(b)) { - let y = if b.len() <= 2 { if y < 30 { 2000 + y } else { 1900 + y } } else { y }; - return valid_ymd(sys, y, m as i64, 1).map(|s| (s, "mmm-yy")); + if let (Some(m), Some(y)) = (month_name(a), num(b)) { + return valid_ymd(sys, full_year(y, b.len()), m as i64, 1).map(|s| (s, "mmm-yy")); } } - // "Mar 4, 2026" / "March 4 2026" / "4 March 2026" - let words: Vec<&str> = t.split([' ', ',']).filter(|w| !w.is_empty()).collect(); - if words.len() == 3 { + // "Mar 4, 2026" / "March 4 2026" / "4 March 2026" / "25 de septiembre de 2026" / "25. září 2026" + let day_dot = r.date_sep == '.'; + let words: Vec<&str> = t.split([' ', ',']).filter(|w| !w.is_empty() && !is_connector(w, r)).collect(); + if let [w0, w1, w2] = words.as_slice() { + let num_w = |w: &str| if day_dot { num(w.trim_end_matches('.')) } else { num(w) }; // Two-digit years follow the same 2029 rule as the dashed forms above. - let expand = |w: &str, y: i64| if w.len() <= 2 { if y < 30 { 2000 + y } else { 1900 + y } } else { y }; - if let (Some(m), Some(d), Some(y)) = (month_from_name(words[0]), num(words[1]), num(words[2])) { - let y = expand(words[2], y); + let year_w = |w: &str| num_w(w).map(|y| full_year(y, w.trim_end_matches('.').len())); + if let (Some(m), Some(d), Some(y)) = (month_name(w0), num_w(w1), year_w(w2)) { return valid_ymd(sys, y, m as i64, d).map(|s| (s, "d-mmm-yy")); } - if let (Some(d), Some(m), Some(y)) = (num(words[0]), month_from_name(words[1]), num(words[2])) { - let y = expand(words[2], y); + if let (Some(d), Some(m), Some(y)) = (num_w(w0), month_name(w1), year_w(w2)) { return valid_ymd(sys, y, m as i64, d).map(|s| (s, "d-mmm-yy")); } } @@ -293,8 +367,8 @@ pub fn current_year() -> i64 { 2026 } -fn parse_datetime(t: &str, sys: DateSystem) -> Option<(f64, Option<&'static str>)> { - if let Some((tf, secs, ampm)) = parse_time(t) { +fn parse_datetime(t: &str, sys: DateSystem, r: &Region) -> Option<(f64, Option<&'static str>)> { + if let Some((tf, secs, ampm)) = parse_time(t, r) { let f = match (secs, ampm) { // 24 hours or more: elapsed hours, so 25:00 shows as 25:00:00, not 1:00. _ if tf >= 1.0 => "[h]:mm:ss", @@ -305,13 +379,13 @@ fn parse_datetime(t: &str, sys: DateSystem) -> Option<(f64, Option<&'static str> }; return Some((tf, Some(f))); } - if let Some((d, f)) = parse_date(t, sys) { + if let Some((d, f)) = parse_date(t, sys, r) { return Some((d, Some(f))); } // date + time: split at the last space(s) before a time-looking tail. for (i, _) in t.match_indices(' ') { let (a, b) = (t.get(..i)?, t.get(i + 1..)?); - if let (Some((d, _)), Some((tf, _, _))) = (parse_date(a, sys), parse_time(b)) { + if let (Some((d, _)), Some((tf, _, _))) = (parse_date(a, sys, r), parse_time(b, r)) { return Some((d + tf, Some("m/d/yyyy h:mm"))); } } @@ -398,4 +472,199 @@ mod tests { assert_eq!(parse_input("2/29/1900", DateSystem::D1904).value, Value::text("2/29/1900")); assert_eq!(parse_input("2/29/1904", DateSystem::D1904).value, Value::Number(59.0)); } + + fn pin(region: &str, s: &str) -> Parsed { + parse_input_in(s, DateSystem::D1900, crate::region::find(region).unwrap()) + } + + #[test] + fn en_us_is_unchanged_by_the_region_parameter() { + for s in [ + "1,234.5", + "$3", + "3$", + "9/25/2026", + "25/09/2026", + "Sept 25, 2026", + "25 de septiembre de 2026", + "Mar. 4, 2026", + "pm 3:30", + "3:30 pm", + "1.234,56", + "1,5", + ] { + assert_eq!(pin("en-US", s), p(s), "{s}"); + } + assert_eq!(p("25/09/2026").value, Value::text("25/09/2026")); + assert_eq!(p("3$").value, Value::text("3$")); + assert_eq!(p("pm 3:30").value, Value::text("pm 3:30")); + assert_eq!(p("Mar. 4, 2026").value, Value::text("Mar. 4, 2026")); + } + + #[test] + fn decimal_comma_regions_read_their_numbers() { + let n = pin("pt-BR", "1.234,56"); + assert_eq!(n.value, Value::Number(1234.56)); + assert_eq!(n.format, Some("#,##0.00")); + assert_eq!(pin("pt-BR", "1.234").value, Value::Number(1234.0)); + assert_eq!(pin("pt-BR", "3,5").value, Value::Number(3.5)); + assert_eq!(pin("pt-BR", "3,5").format, None); + assert_eq!(pin("pt-BR", "12,5%").value, Value::Number(0.125)); + assert_eq!(pin("pt-BR", "-0,5").value, Value::Number(-0.5)); + assert_eq!(pin("pt-BR", "1,5e3").value, Value::Number(1500.0)); + assert_eq!(pin("de-DE", "1,234.5").value, Value::text("1,234.5")); + assert_eq!(pin("pt-BR", "1,23,4").value, Value::text("1,23,4")); + assert_eq!(pin("de-DE", "12.34").value.as_f64().map(|_| ()), None, "a point is not a decimal in de-DE"); + // "1.5" is 1 May there, as in Excel, not a number. + assert_eq!(pin("fr-FR", "1.5").format, Some("d-mmm")); + } + + #[test] + fn group_separators_that_are_spaces() { + assert_eq!(pin("ru-RU", "1\u{a0}234,5").value, Value::Number(1234.5)); + assert_eq!(pin("ru-RU", "1 234,5").value, Value::Number(1234.5)); + assert_eq!(pin("fr-FR", "1\u{202f}234").value, Value::Number(1234.0)); + assert_eq!(pin("cs-CZ", "12\u{a0}345,67").format, Some("#,##0.00")); + assert_eq!(pin("ru-RU", "hello world").value, Value::text("hello world")); + assert_eq!(pin("fr-FR", "1 234 5").value, Value::text("1 234 5")); + } + + #[test] + fn indian_grouping() { + assert_eq!(pin("en-IN", "12,34,567.89").value, Value::Number(1234567.89)); + assert_eq!(pin("en-IN", "1,234,567.89").value, Value::Number(1234567.89)); + assert_eq!(pin("en-IN", "12,34,56").value, Value::text("12,34,56")); + assert_eq!(pin("en-IN", "₹1,00,000").value, Value::Number(100000.0)); + assert_eq!(pin("en-IN", "₹1,00,000").format, Some("\"₹\"#,##0")); + } + + #[test] + fn currency_follows_the_region() { + let a = pin("pt-BR", "R$ 3,50"); + assert_eq!((a.value, a.format), (Value::Number(3.5), Some("\"R$\" #,##0.00"))); + let b = pin("de-DE", "1.234,50 €"); + assert_eq!((b.value, b.format), (Value::Number(1234.5), Some("#,##0.00 \"€\""))); + assert_eq!(pin("de-DE", "3 €").format, Some("#,##0 \"€\"")); + assert_eq!(pin("de-DE", "-3 €").value, Value::Number(-3.0)); + assert_eq!(pin("pt-BR", "$3").value, Value::text("$3")); + assert_eq!(pin("en-GB", "£1,200.50").value, Value::Number(1200.5)); + assert_eq!(pin("cs-CZ", "99\u{a0}Kč").value, Value::Number(99.0)); + assert_eq!(pin("ja-JP", "¥1,000").value, Value::Number(1000.0)); + } + + #[test] + fn dates_in_the_regions_order() { + let d = |r: &str, s: &str| pin(r, s).value; + assert_eq!(d("en-GB", "25/09/2026"), Value::Number(46290.0)); + assert_eq!(d("en-US", "9/25/2026"), Value::Number(46290.0)); + assert_eq!(d("de-DE", "25.09.2026"), Value::Number(46290.0)); + assert_eq!(d("pt-BR", "25/09/2026"), Value::Number(46290.0)); + assert_eq!(d("ru-RU", "25.09.2026"), Value::Number(46290.0)); + assert_eq!(d("cs-CZ", "25. 9. 2026"), Value::Number(46290.0)); + assert_eq!(d("ja-JP", "2026/9/25"), Value::Number(46290.0)); + assert_eq!(d("ko-KR", "2026-09-25"), Value::Number(46290.0)); + assert_eq!(d("zh-TW", "2026/09/25"), Value::Number(46290.0)); + assert_eq!(d("en-IN", "25/09/2026"), Value::Number(46290.0)); + // Day first: 9/25 isn't a date, and 12/1/2026 is 12 January. + assert_eq!(d("en-GB", "09/25/2026"), Value::text("09/25/2026")); + assert_eq!(d("en-GB", "12/1/2026"), Value::Number(46034.0)); + assert_eq!(d("en-US", "12/1/2026"), Value::Number(46357.0)); + // Two-digit years, ISO, impossible dates. + assert_eq!(d("en-GB", "25/09/26"), Value::Number(46290.0)); + assert_eq!(d("en-GB", "2026-09-25"), Value::Number(46290.0)); + assert_eq!(d("de-DE", "31.04.2026"), Value::text("31.04.2026")); + // The format is the system short date (built-in 14), displayed per region. + assert_eq!(pin("en-GB", "25/09/2026").format, Some("m/d/yyyy")); + assert_eq!(pin("ja-JP", "2026/9/25").format, Some("m/d/yyyy")); + assert_eq!(pin("en-GB", "2026-09-25").format, Some("yyyy-mm-dd")); + // Day and month of the current year, month and year. + assert_eq!(pin("de-DE", "25.9").format, Some("d-mmm")); + assert_eq!(pin("en-GB", "9/2026").format, Some("mmm-yy")); + assert_eq!(pin("en-GB", "9/2026").value, Value::Number(46266.0)); + assert_eq!(pin("ja-JP", "2026/9").value, Value::Number(46266.0)); + } + + #[test] + fn month_names_in_local_words() { + let d = |r: &str, s: &str| pin(r, s).value; + assert_eq!(d("de-DE", "25. September 2026"), Value::Number(46290.0)); + assert_eq!(d("de-DE", "25-Sep-2026"), Value::Number(46290.0)); + assert_eq!(d("fr-FR", "25 septembre 2026"), Value::Number(46290.0)); + assert_eq!(d("fr-FR", "25 SEPT. 2026"), Value::Number(46290.0)); + assert_eq!(d("es-ES", "25 de septiembre de 2026"), Value::Number(46290.0)); + assert_eq!(d("pt-BR", "25 de setembro de 2026"), Value::Number(46290.0)); + assert_eq!(d("it-IT", "25 Settembre 2026"), Value::Number(46290.0)); + assert_eq!(d("ru-RU", "25 сентября 2026"), Value::Number(46290.0)); + assert_eq!(d("ru-RU", "25 сентября 2026 г."), Value::Number(46290.0)); + assert_eq!(d("cs-CZ", "25. září 2026"), Value::Number(46290.0)); + assert_eq!(d("en-GB", "25 Sept 2026"), Value::Number(46290.0)); + assert_eq!(d("en-GB", "Sep 25, 2026"), Value::Number(46290.0)); + assert_eq!(d("de-DE", "25. Sepxx 2026"), Value::text("25. Sepxx 2026")); + assert_eq!(pin("de-DE", "25-Sep-2026").format, Some("d-mmm-yy")); + } + + #[test] + fn times_in_the_regions_words() { + let d = |r: &str, s: &str| pin(r, s).value; + assert_eq!(d("de-DE", "15:30"), Value::Number(15.5 / 24.0)); + assert_eq!(d("de-DE", "3:30 PM"), Value::Number(15.5 / 24.0)); + assert_eq!(d("ja-JP", "午後3:30"), Value::Number(15.5 / 24.0)); + assert_eq!(d("ja-JP", "午後 3:30"), Value::Number(15.5 / 24.0)); + assert_eq!(d("ko-KR", "오후 3:30"), Value::Number(15.5 / 24.0)); + assert_eq!(d("zh-CN", "上午12:00"), Value::Number(0.0)); + assert_eq!(d("es-ES", "3:30 p. m."), Value::Number(15.5 / 24.0)); + assert_eq!(d("cs-CZ", "3:30 odp."), Value::Number(15.5 / 24.0)); + assert_eq!(pin("de-DE", "10:30:15,5").value, Value::Number(time_fraction(10.0, 30.0, 15.5))); + assert_eq!(d("de-DE", "25.09.2026 15:30"), Value::Number(46290.0 + 15.5 / 24.0)); + assert_eq!(d("ja-JP", "2026/9/25 午後3:30"), Value::Number(46290.0 + 15.5 / 24.0)); + assert_eq!(pin("de-DE", "25.09.2026 15:30").format, Some("m/d/yyyy h:mm")); + assert_eq!(pin("ja-JP", "午後3:30").format, Some("h:mm AM/PM")); + } + + #[test] + fn hostile_input_never_panics_in_any_region() { + let nasty = [ + "", + " ", + "-", + "()", + "(", + "€", + "R$", + "%", + "1,", + ",1", + "1,,2", + ".", + "..", + "////", + "a/b/c", + "2026//", + "9999999999999999999/1/1", + "1e999", + "\u{0}", + "٣٫٥", + "1\u{a0}", + "午後", + "午後:", + "::", + ":", + "p", + "a", + "de de de", + "г.", + "25. 25. 25.", + "1.2.3.4.5", + "🙂/🙂/🙂", + "-€", + "€-", + "1 e 3", + ]; + for r in crate::region::all() { + for s in nasty { + let _ = parse_input_in(s, DateSystem::D1900, r); + let _ = parse_input_in(s, DateSystem::D1904, r); + } + } + } } diff --git a/crates/core/src/region.rs b/crates/core/src/region.rs new file mode 100644 index 000000000..fdf89cff5 --- /dev/null +++ b/crates/core/src/region.rs @@ -0,0 +1,682 @@ +//! Regional formats: how a region writes numbers, dates, times and money. +//! +//! Excel takes these from the operating system's regional settings; they are independent of the +//! interface language and of the formula language. A [`Region`] changes what people *type* into +//! a cell (`1.234,56`, `25/09/2026`) and what cells *show* (separators, month and day names, +//! AM/PM, the system short and long dates). It never changes what is stored: values are numbers, +//! and the number format codes saved in a file stay in their canonical en-US spelling. +//! +//! [`EN_US`] is the default and reproduces GridCraft's original behaviour exactly. +//! +//! # Where the values come from +//! The separators, month and weekday names, AM/PM words, currency symbols and the order of day, +//! month and year are the Unicode CLDR conventions (`unicode-org/cldr-json`: +//! `cldr-numbers-full/main//numbers.json` and `cldr-dates-full/main//ca-gregorian.json`), +//! which is also what macOS, Android and ICU ship. They were read from those files for en-GB, +//! en-IN, cs and ru, and written from the same conventions for the other regions. +//! Deliberate choices where operating systems differ from CLDR: +//! - Short dates use a four-digit year (`dd/mm/yyyy`, where CLDR has `dd/MM/y` or `dd/MM/yy`): +//! Excel's short date is the system's, Windows writes four digits, and a two-digit year would +//! not read back to the same date. +//! - The long date is CLDR's *full* date (with the weekday), like Windows en-US. +//! - Month names for `mmm` are CLDR's abbreviated names (so en-GB has `Sept`). +//! - French and Portuguese (Portugal) group digits with U+00A0 (Windows), not CLDR's U+202F. +//! Parsing accepts every space-like grouping character in those regions. +//! +//! Regions this module doesn't list (`de-CH`, `es-MX`, `en-CA`…) resolve to the main region of +//! their language through [`lookup`]; their own conventions (a Swiss apostrophe, a Mexican +//! decimal point) are not modelled. + +/// How digits are grouped. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Grouping { + /// Groups of three (`1,234,567`). + Thousands, + /// The Indian system: three digits, then groups of two (`12,34,567`). + Indian, +} + +/// The order of the parts of a short date. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DateOrder { + Mdy, + Dmy, + Ymd, +} + +/// One region's conventions. See the module documentation. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Region { + /// BCP 47 tag, `pt-BR`. + pub tag: &'static str, + /// The region's name in its own language. + pub name: &'static str, + /// Windows locale id, used by `[$-409]`-style format tags. + pub lcid: u16, + pub decimal: char, + pub group: char, + pub grouping: Grouping, + pub date_order: DateOrder, + pub date_sep: char, + pub time_sep: char, + /// The system short date as a canonical format code (`dd/mm/yyyy`): built-in format 14. + pub short_date: &'static str, + /// The system long date (`[$-F800]`). + pub long_date: &'static str, + /// The system short time. It contains `AM/PM` in regions that use a 12-hour clock. + pub short_time: &'static str, + /// The system long time (`[$-F400]`). + pub long_time: &'static str, + /// Built-in format 22: the short date and a 24-hour time. + pub datetime: &'static str, + pub months: [&'static str; 12], + pub months_abbr: [&'static str; 12], + /// The month names a date with a day uses (Russian `1 января`, Czech `1. ledna`), where they + /// differ from `months`. + pub months_genitive: Option<[&'static str; 12]>, + /// Sunday first. + pub weekdays: [&'static str; 7], + pub weekdays_abbr: [&'static str; 7], + pub am_pm: [&'static str; 2], + pub currency: &'static str, + /// The currency format with two decimals, as a canonical code. Where it starts with a quote + /// the symbol comes before the number, otherwise after. + pub currency_format: &'static str, + /// The same with no decimals (for a typed whole amount). + pub currency_format_int: &'static str, +} + +const EN_MONTHS: [&str; 12] = + ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; +const EN_MONTHS_ABBR: [&str; 12] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; +const EN_DAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; +const EN_DAYS_ABBR: [&str; 7] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; +const AM_PM: [&str; 2] = ["AM", "PM"]; + +/// English (United States): the default. Identical to the formats before regions existed. +pub static EN_US: Region = Region { + tag: "en-US", + name: "English (United States)", + lcid: 0x0409, + decimal: '.', + group: ',', + grouping: Grouping::Thousands, + date_order: DateOrder::Mdy, + date_sep: '/', + time_sep: ':', + short_date: "m/d/yyyy", + long_date: "dddd, mmmm dd, yyyy", + short_time: "h:mm AM/PM", + long_time: "h:mm:ss AM/PM", + datetime: "m/d/yyyy h:mm", + months: EN_MONTHS, + months_abbr: EN_MONTHS_ABBR, + months_genitive: None, + weekdays: EN_DAYS, + weekdays_abbr: EN_DAYS_ABBR, + am_pm: AM_PM, + currency: "$", + currency_format: "\"$\"#,##0.00", + currency_format_int: "\"$\"#,##0", +}; + +static EN_GB: Region = Region { + tag: "en-GB", + name: "English (United Kingdom)", + lcid: 0x0809, + decimal: '.', + group: ',', + grouping: Grouping::Thousands, + date_order: DateOrder::Dmy, + date_sep: '/', + time_sep: ':', + short_date: "dd/mm/yyyy", + long_date: "dddd, d mmmm yyyy", + short_time: "hh:mm", + long_time: "hh:mm:ss", + datetime: "dd/mm/yyyy hh:mm", + months: EN_MONTHS, + months_abbr: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sept", "Oct", "Nov", "Dec"], + months_genitive: None, + weekdays: EN_DAYS, + weekdays_abbr: EN_DAYS_ABBR, + am_pm: AM_PM, + currency: "£", + currency_format: "\"£\"#,##0.00", + currency_format_int: "\"£\"#,##0", +}; + +static EN_IN: Region = Region { + tag: "en-IN", + name: "English (India)", + lcid: 0x4009, + decimal: '.', + group: ',', + grouping: Grouping::Indian, + date_order: DateOrder::Dmy, + date_sep: '/', + time_sep: ':', + short_date: "dd/mm/yyyy", + long_date: "dddd, d mmmm yyyy", + short_time: "h:mm AM/PM", + long_time: "h:mm:ss AM/PM", + datetime: "dd/mm/yyyy h:mm", + months: EN_MONTHS, + months_abbr: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sept", "Oct", "Nov", "Dec"], + months_genitive: None, + weekdays: EN_DAYS, + weekdays_abbr: EN_DAYS_ABBR, + am_pm: AM_PM, + currency: "₹", + currency_format: "\"₹\"#,##0.00", + currency_format_int: "\"₹\"#,##0", +}; + +static ES_ES: Region = Region { + tag: "es-ES", + name: "Español (España)", + lcid: 0x0C0A, + decimal: ',', + group: '.', + grouping: Grouping::Thousands, + date_order: DateOrder::Dmy, + date_sep: '/', + time_sep: ':', + short_date: "dd/mm/yyyy", + long_date: "dddd, d \"de\" mmmm \"de\" yyyy", + short_time: "h:mm", + long_time: "h:mm:ss", + datetime: "dd/mm/yyyy h:mm", + months: ["enero", "febrero", "marzo", "abril", "mayo", "junio", "julio", "agosto", "septiembre", "octubre", "noviembre", "diciembre"], + months_abbr: ["ene", "feb", "mar", "abr", "may", "jun", "jul", "ago", "sep", "oct", "nov", "dic"], + months_genitive: None, + weekdays: ["domingo", "lunes", "martes", "miércoles", "jueves", "viernes", "sábado"], + weekdays_abbr: ["dom", "lun", "mar", "mié", "jue", "vie", "sáb"], + am_pm: ["a. m.", "p. m."], + currency: "€", + currency_format: "#,##0.00 \"€\"", + currency_format_int: "#,##0 \"€\"", +}; + +static DE_DE: Region = Region { + tag: "de-DE", + name: "Deutsch (Deutschland)", + lcid: 0x0407, + decimal: ',', + group: '.', + grouping: Grouping::Thousands, + date_order: DateOrder::Dmy, + date_sep: '.', + time_sep: ':', + short_date: "dd.mm.yyyy", + long_date: "dddd, d. mmmm yyyy", + short_time: "hh:mm", + long_time: "hh:mm:ss", + datetime: "dd.mm.yyyy hh:mm", + months: ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"], + months_abbr: ["Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"], + months_genitive: None, + weekdays: ["Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag"], + weekdays_abbr: ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"], + am_pm: AM_PM, + currency: "€", + currency_format: "#,##0.00 \"€\"", + currency_format_int: "#,##0 \"€\"", +}; + +static FR_FR: Region = Region { + tag: "fr-FR", + name: "Français (France)", + lcid: 0x040C, + decimal: ',', + group: '\u{a0}', + grouping: Grouping::Thousands, + date_order: DateOrder::Dmy, + date_sep: '/', + time_sep: ':', + short_date: "dd/mm/yyyy", + long_date: "dddd d mmmm yyyy", + short_time: "hh:mm", + long_time: "hh:mm:ss", + datetime: "dd/mm/yyyy hh:mm", + months: ["janvier", "février", "mars", "avril", "mai", "juin", "juillet", "août", "septembre", "octobre", "novembre", "décembre"], + months_abbr: ["janv.", "févr.", "mars", "avr.", "mai", "juin", "juil.", "août", "sept.", "oct.", "nov.", "déc."], + months_genitive: None, + weekdays: ["dimanche", "lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi"], + weekdays_abbr: ["dim.", "lun.", "mar.", "mer.", "jeu.", "ven.", "sam."], + am_pm: AM_PM, + currency: "€", + currency_format: "#,##0.00 \"€\"", + currency_format_int: "#,##0 \"€\"", +}; + +static IT_IT: Region = Region { + tag: "it-IT", + name: "Italiano (Italia)", + lcid: 0x0410, + decimal: ',', + group: '.', + grouping: Grouping::Thousands, + date_order: DateOrder::Dmy, + date_sep: '/', + time_sep: ':', + short_date: "dd/mm/yyyy", + long_date: "dddd d mmmm yyyy", + short_time: "hh:mm", + long_time: "hh:mm:ss", + datetime: "dd/mm/yyyy hh:mm", + months: ["gennaio", "febbraio", "marzo", "aprile", "maggio", "giugno", "luglio", "agosto", "settembre", "ottobre", "novembre", "dicembre"], + months_abbr: ["gen", "feb", "mar", "apr", "mag", "giu", "lug", "ago", "set", "ott", "nov", "dic"], + months_genitive: None, + weekdays: ["domenica", "lunedì", "martedì", "mercoledì", "giovedì", "venerdì", "sabato"], + weekdays_abbr: ["dom", "lun", "mar", "mer", "gio", "ven", "sab"], + am_pm: AM_PM, + currency: "€", + currency_format: "#,##0.00 \"€\"", + currency_format_int: "#,##0 \"€\"", +}; + +static PT_BR: Region = Region { + tag: "pt-BR", + name: "Português (Brasil)", + lcid: 0x0416, + decimal: ',', + group: '.', + grouping: Grouping::Thousands, + date_order: DateOrder::Dmy, + date_sep: '/', + time_sep: ':', + short_date: "dd/mm/yyyy", + long_date: "dddd, d \"de\" mmmm \"de\" yyyy", + short_time: "hh:mm", + long_time: "hh:mm:ss", + datetime: "dd/mm/yyyy hh:mm", + months: ["janeiro", "fevereiro", "março", "abril", "maio", "junho", "julho", "agosto", "setembro", "outubro", "novembro", "dezembro"], + months_abbr: ["jan", "fev", "mar", "abr", "mai", "jun", "jul", "ago", "set", "out", "nov", "dez"], + months_genitive: None, + weekdays: ["domingo", "segunda-feira", "terça-feira", "quarta-feira", "quinta-feira", "sexta-feira", "sábado"], + weekdays_abbr: ["dom", "seg", "ter", "qua", "qui", "sex", "sáb"], + am_pm: AM_PM, + currency: "R$", + currency_format: "\"R$\" #,##0.00", + currency_format_int: "\"R$\" #,##0", +}; + +static PT_PT: Region = Region { + tag: "pt-PT", + name: "Português (Portugal)", + lcid: 0x0816, + decimal: ',', + group: '\u{a0}', + grouping: Grouping::Thousands, + date_order: DateOrder::Dmy, + date_sep: '/', + time_sep: ':', + short_date: "dd/mm/yyyy", + long_date: "dddd, d \"de\" mmmm \"de\" yyyy", + short_time: "hh:mm", + long_time: "hh:mm:ss", + datetime: "dd/mm/yyyy hh:mm", + months: ["janeiro", "fevereiro", "março", "abril", "maio", "junho", "julho", "agosto", "setembro", "outubro", "novembro", "dezembro"], + months_abbr: ["jan", "fev", "mar", "abr", "mai", "jun", "jul", "ago", "set", "out", "nov", "dez"], + months_genitive: None, + weekdays: ["domingo", "segunda-feira", "terça-feira", "quarta-feira", "quinta-feira", "sexta-feira", "sábado"], + weekdays_abbr: ["dom", "seg", "ter", "qua", "qui", "sex", "sáb"], + am_pm: AM_PM, + currency: "€", + currency_format: "#,##0.00 \"€\"", + currency_format_int: "#,##0 \"€\"", +}; + +static RU_RU: Region = Region { + tag: "ru-RU", + name: "Русский (Россия)", + lcid: 0x0419, + decimal: ',', + group: '\u{a0}', + grouping: Grouping::Thousands, + date_order: DateOrder::Dmy, + date_sep: '.', + time_sep: ':', + short_date: "dd.mm.yyyy", + long_date: "d mmmm yyyy\" г.\"", + short_time: "hh:mm", + long_time: "hh:mm:ss", + datetime: "dd.mm.yyyy hh:mm", + months: ["январь", "февраль", "март", "апрель", "май", "июнь", "июль", "август", "сентябрь", "октябрь", "ноябрь", "декабрь"], + months_abbr: ["янв.", "февр.", "март", "апр.", "май", "июнь", "июль", "авг.", "сент.", "окт.", "нояб.", "дек."], + months_genitive: Some(["января", "февраля", "марта", "апреля", "мая", "июня", "июля", "августа", "сентября", "октября", "ноября", "декабря"]), + weekdays: ["воскресенье", "понедельник", "вторник", "среда", "четверг", "пятница", "суббота"], + weekdays_abbr: ["вс", "пн", "вт", "ср", "чт", "пт", "сб"], + am_pm: AM_PM, + currency: "₽", + currency_format: "#,##0.00\u{a0}\"₽\"", + currency_format_int: "#,##0\u{a0}\"₽\"", +}; + +static CS_CZ: Region = Region { + tag: "cs-CZ", + name: "Čeština (Česko)", + lcid: 0x0405, + decimal: ',', + group: '\u{a0}', + grouping: Grouping::Thousands, + date_order: DateOrder::Dmy, + date_sep: '.', + time_sep: ':', + short_date: "dd.mm.yyyy", + long_date: "dddd d. mmmm yyyy", + short_time: "h:mm", + long_time: "h:mm:ss", + datetime: "dd.mm.yyyy h:mm", + months: ["leden", "únor", "březen", "duben", "květen", "červen", "červenec", "srpen", "září", "říjen", "listopad", "prosinec"], + months_abbr: ["led", "úno", "bře", "dub", "kvě", "čvn", "čvc", "srp", "zář", "říj", "lis", "pro"], + months_genitive: Some(["ledna", "února", "března", "dubna", "května", "června", "července", "srpna", "září", "října", "listopadu", "prosince"]), + weekdays: ["neděle", "pondělí", "úterý", "středa", "čtvrtek", "pátek", "sobota"], + weekdays_abbr: ["ne", "po", "út", "st", "čt", "pá", "so"], + am_pm: ["dop.", "odp."], + currency: "Kč", + currency_format: "#,##0.00 \"Kč\"", + currency_format_int: "#,##0 \"Kč\"", +}; + +static JA_JP: Region = Region { + tag: "ja-JP", + name: "日本語 (日本)", + lcid: 0x0411, + decimal: '.', + group: ',', + grouping: Grouping::Thousands, + date_order: DateOrder::Ymd, + date_sep: '/', + time_sep: ':', + short_date: "yyyy/m/d", + long_date: "yyyy\"年\"m\"月\"d\"日\"", + short_time: "h:mm", + long_time: "h:mm:ss", + datetime: "yyyy/m/d h:mm", + months: ["1月", "2月", "3月", "4月", "5月", "6月", "7月", "8月", "9月", "10月", "11月", "12月"], + months_abbr: ["1月", "2月", "3月", "4月", "5月", "6月", "7月", "8月", "9月", "10月", "11月", "12月"], + months_genitive: None, + weekdays: ["日曜日", "月曜日", "火曜日", "水曜日", "木曜日", "金曜日", "土曜日"], + weekdays_abbr: ["日", "月", "火", "水", "木", "金", "土"], + am_pm: ["午前", "午後"], + currency: "¥", + currency_format: "\"¥\"#,##0", + currency_format_int: "\"¥\"#,##0", +}; + +static KO_KR: Region = Region { + tag: "ko-KR", + name: "한국어 (대한민국)", + lcid: 0x0412, + decimal: '.', + group: ',', + grouping: Grouping::Thousands, + date_order: DateOrder::Ymd, + date_sep: '-', + time_sep: ':', + short_date: "yyyy-mm-dd", + long_date: "yyyy\"년\" m\"월\" d\"일\" dddd", + short_time: "AM/PM h:mm", + long_time: "AM/PM h:mm:ss", + datetime: "yyyy-mm-dd hh:mm", + months: ["1월", "2월", "3월", "4월", "5월", "6월", "7월", "8월", "9월", "10월", "11월", "12월"], + months_abbr: ["1월", "2월", "3월", "4월", "5월", "6월", "7월", "8월", "9월", "10월", "11월", "12월"], + months_genitive: None, + weekdays: ["일요일", "월요일", "화요일", "수요일", "목요일", "금요일", "토요일"], + weekdays_abbr: ["일", "월", "화", "수", "목", "금", "토"], + am_pm: ["오전", "오후"], + currency: "₩", + currency_format: "\"₩\"#,##0", + currency_format_int: "\"₩\"#,##0", +}; + +static ZH_CN: Region = Region { + tag: "zh-CN", + name: "中文 (中国)", + lcid: 0x0804, + decimal: '.', + group: ',', + grouping: Grouping::Thousands, + date_order: DateOrder::Ymd, + date_sep: '/', + time_sep: ':', + short_date: "yyyy/m/d", + long_date: "yyyy\"年\"m\"月\"d\"日\"", + short_time: "h:mm", + long_time: "h:mm:ss", + datetime: "yyyy/m/d h:mm", + months: ["一月", "二月", "三月", "四月", "五月", "六月", "七月", "八月", "九月", "十月", "十一月", "十二月"], + months_abbr: ["1月", "2月", "3月", "4月", "5月", "6月", "7月", "8月", "9月", "10月", "11月", "12月"], + months_genitive: None, + weekdays: ["星期日", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六"], + weekdays_abbr: ["周日", "周一", "周二", "周三", "周四", "周五", "周六"], + am_pm: ["上午", "下午"], + currency: "¥", + currency_format: "\"¥\"#,##0.00", + currency_format_int: "\"¥\"#,##0", +}; + +static ZH_TW: Region = Region { + tag: "zh-TW", + name: "中文 (台灣)", + lcid: 0x0404, + decimal: '.', + group: ',', + grouping: Grouping::Thousands, + date_order: DateOrder::Ymd, + date_sep: '/', + time_sep: ':', + short_date: "yyyy/m/d", + long_date: "yyyy\"年\"m\"月\"d\"日\"", + short_time: "AM/PM hh:mm", + long_time: "AM/PM hh:mm:ss", + datetime: "yyyy/m/d hh:mm", + months: ["一月", "二月", "三月", "四月", "五月", "六月", "七月", "八月", "九月", "十月", "十一月", "十二月"], + months_abbr: ["1月", "2月", "3月", "4月", "5月", "6月", "7月", "8月", "9月", "10月", "11月", "12月"], + months_genitive: None, + weekdays: ["星期日", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六"], + weekdays_abbr: ["週日", "週一", "週二", "週三", "週四", "週五", "週六"], + am_pm: ["上午", "下午"], + currency: "NT$", + currency_format: "\"NT$\"#,##0.00", + currency_format_int: "\"NT$\"#,##0", +}; + +static ALL: [&Region; 15] = [&EN_US, &EN_GB, &EN_IN, &ES_ES, &DE_DE, &FR_FR, &IT_IT, &PT_BR, &PT_PT, &RU_RU, &CS_CZ, &JA_JP, &KO_KR, &ZH_CN, &ZH_TW]; + +/// Every region, English (United States) first. +pub fn all() -> &'static [&'static Region] { + &ALL +} + +/// The default region, English (United States). +pub fn default_region() -> &'static Region { + &EN_US +} + +/// The region with exactly this tag (`pt-BR`; case and `_` don't matter). +pub fn find(tag: &str) -> Option<&'static Region> { + let t = tag.trim().replace('_', "-"); + ALL.iter().copied().find(|r| r.tag.eq_ignore_ascii_case(&t)) +} + +/// The region for a tag as operating systems and browsers write it: `pt-BR`, `pt_BR.UTF-8`, +/// `de`, `en-AU`, `zh-Hant`, `sr_RS@latin`. An exact match wins; otherwise the language's main +/// region (with the usual variants: `en-AU`→`en-GB`, `pt-AO`→`pt-PT`, `zh-Hant`→`zh-TW`). +/// An unknown language is English (United States). +pub fn lookup(tag: &str) -> &'static Region { + if let Some(r) = find(tag) { + return r; + } + let base = tag.split(['.', '@']).next().unwrap_or(""); + let norm = base.trim().replace('_', "-"); + if let Some(r) = find(&norm) { + return r; + } + let mut parts = norm.split('-').filter(|p| !p.is_empty()); + let lang = parts.next().unwrap_or("").to_ascii_lowercase(); + let mut script = String::new(); + let mut country = String::new(); + for p in parts { + if p.len() == 4 && p.chars().all(|c| c.is_ascii_alphabetic()) { + script = p.to_ascii_lowercase(); + } else if (p.len() == 2 && p.chars().all(|c| c.is_ascii_alphabetic())) || (p.len() == 3 && p.chars().all(|c| c.is_ascii_digit())) { + country = p.to_ascii_uppercase(); + } + } + let tag = match lang.as_str() { + "en" => match country.as_str() { + "GB" | "AU" | "NZ" | "IE" => "en-GB", + "IN" => "en-IN", + _ => "en-US", + }, + "pt" => match country.as_str() { + "AO" | "CH" | "CV" | "FR" | "GQ" | "GW" | "LU" | "MO" | "MZ" | "ST" | "TL" | "PT" => "pt-PT", + _ => "pt-BR", + }, + "zh" => { + if script == "hant" || matches!(country.as_str(), "TW" | "HK" | "MO") { + "zh-TW" + } else { + "zh-CN" + } + } + "es" => "es-ES", + "de" => "de-DE", + "fr" => "fr-FR", + "it" => "it-IT", + "ru" => "ru-RU", + "cs" => "cs-CZ", + "ja" => "ja-JP", + "ko" => "ko-KR", + _ => "en-US", + }; + find(tag).unwrap_or(&EN_US) +} + +impl Region { + /// True for the characters that separate groups of digits in this region. A region that + /// groups with a space also accepts the other space-like characters (NBSP, narrow NBSP, thin + /// space), as people type and paste any of them. + pub fn is_group(&self, c: char) -> bool { + c == self.group || (is_space_like(self.group) && is_space_like(c)) + } + + /// True when the currency symbol comes before the number. + pub fn currency_first(&self) -> bool { + self.currency_format.starts_with('"') + } + + /// Month number (1–12) for a month name as typed: the full name, the abbreviation (with or + /// without its period) or any prefix of the full name of three or more letters (`Sept`), + /// ignoring case. Names with a day (`1 января`) match too. + pub fn month_from_name(&self, word: &str) -> Option { + let w = word.trim().to_lowercase(); + if w.is_empty() { + return None; + } + let long_enough = w.chars().count() >= 3; + for (i, full) in self.months.iter().enumerate() { + let full_l = full.to_lowercase(); + let abbr_l = self.months_abbr.get(i).map(|a| a.to_lowercase()).unwrap_or_default(); + let gen_l = self.months_genitive.as_ref().and_then(|g| g.get(i)).map(|g| g.to_lowercase()); + let hit = w == full_l + || w == abbr_l + || w == abbr_l.trim_end_matches('.') + || gen_l.as_deref() == Some(w.as_str()) + || (long_enough && full_l.starts_with(&w)) + || (long_enough && gen_l.as_deref().is_some_and(|g| g.starts_with(&w))); + if hit { + return Some(i as u32 + 1); + } + } + None + } + + /// The name of month `month` (1–12) for `mmmm`; `with_day` selects the genitive form where + /// the region has one. + pub fn month_name(&self, month: u32, with_day: bool) -> &'static str { + let i = (month as usize).wrapping_sub(1); + if with_day && let Some(g) = &self.months_genitive { + return g.get(i).copied().unwrap_or(""); + } + self.months.get(i).copied().unwrap_or("") + } + + /// True when the short time uses a 12-hour clock. + pub fn is_12_hour(&self) -> bool { + self.short_time.contains("AM/PM") + } +} + +fn is_space_like(c: char) -> bool { + matches!(c, ' ' | '\u{a0}' | '\u{202f}' | '\u{2009}') +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lookup_accepts_system_and_browser_tags() { + assert_eq!(lookup("pt-BR").tag, "pt-BR"); + assert_eq!(lookup("pt_BR.UTF-8").tag, "pt-BR"); + assert_eq!(lookup("pt").tag, "pt-BR"); + assert_eq!(lookup("pt-PT").tag, "pt-PT"); + assert_eq!(lookup("pt-AO").tag, "pt-PT"); + assert_eq!(lookup("de").tag, "de-DE"); + assert_eq!(lookup("de-AT").tag, "de-DE"); + assert_eq!(lookup("en-AU").tag, "en-GB"); + assert_eq!(lookup("en_GB.UTF-8").tag, "en-GB"); + assert_eq!(lookup("en-IN").tag, "en-IN"); + assert_eq!(lookup("en-CA").tag, "en-US"); + assert_eq!(lookup("zh-Hant").tag, "zh-TW"); + assert_eq!(lookup("zh-HK").tag, "zh-TW"); + assert_eq!(lookup("zh").tag, "zh-CN"); + assert_eq!(lookup("cs_CZ.UTF-8").tag, "cs-CZ"); + assert_eq!(lookup("C").tag, "en-US"); + assert_eq!(lookup("").tag, "en-US"); + assert_eq!(lookup("xx-YY").tag, "en-US"); + assert_eq!(find("DE-de").map(|r| r.tag), Some("de-DE")); + assert!(find("xx").is_none()); + } + + #[test] + fn every_region_is_consistent() { + let mut tags = std::collections::HashSet::new(); + for r in all() { + assert!(tags.insert(r.tag), "duplicate {}", r.tag); + assert_ne!(r.decimal, r.group, "{}", r.tag); + assert!(r.currency_format.contains(r.currency), "{}", r.tag); + assert!(r.months.iter().all(|m| !m.is_empty()) && r.months_abbr.iter().all(|m| !m.is_empty()), "{}", r.tag); + assert!(r.weekdays.iter().all(|m| !m.is_empty()) && r.weekdays_abbr.iter().all(|m| !m.is_empty()), "{}", r.tag); + assert_eq!(find(r.tag).map(|x| x.tag), Some(r.tag)); + } + } + + #[test] + fn month_names_match_loosely() { + let de = find("de-DE").unwrap(); + assert_eq!(de.month_from_name("März"), Some(3)); + assert_eq!(de.month_from_name("mär"), Some(3)); + assert_eq!(de.month_from_name("DEZ"), Some(12)); + assert_eq!(de.month_from_name("de"), None); + let gb = find("en-GB").unwrap(); + assert_eq!(gb.month_from_name("Sept"), Some(9)); + assert_eq!(gb.month_from_name("sep"), Some(9)); + let ru = find("ru-RU").unwrap(); + assert_eq!(ru.month_from_name("сентября"), Some(9)); + assert_eq!(ru.month_from_name("сент."), Some(9)); + assert_eq!(EN_US.month_from_name("Ma"), None); + assert_eq!(EN_US.month_from_name("May"), Some(5)); + } + + #[test] + fn space_like_groups_are_interchangeable() { + let fr = find("fr-FR").unwrap(); + assert!(fr.is_group('\u{a0}') && fr.is_group(' ') && fr.is_group('\u{202f}')); + assert!(!find("de-DE").unwrap().is_group(' ')); + } +} diff --git a/crates/engine/src/cmd/data.rs b/crates/engine/src/cmd/data.rs index 383624b70..d13ca61e6 100644 --- a/crates/engine/src/cmd/data.rs +++ b/crates/engine/src/cmd/data.rs @@ -73,7 +73,7 @@ pub fn specs() -> Vec { has_doc, validation ), - cmd!(query "data.validate", "Check Value Against Validation", [], None, "{cell?, input} → {ok, message?}", has_doc, validate_cmd), + cmd!(query "data.validate", "Check Value Against Validation", [], None, "{cell?, input, region?: tag the input was typed in (default: canonical en-US text)} → {ok, message?}", has_doc, validate_cmd), cmd!(noundo "data.circleInvalid", "Circle Invalid Data", ["Data", "Data Tools", "Data Validation"], None, "{} → invalid cells", has_doc, circle_invalid), cmd!( "data.group", @@ -837,7 +837,7 @@ fn text_to_columns(s: &mut Session, p: &Json) -> Result { } } for (c, t) in out { - let cell = super::edit::input_to_cell(&t, sheet, c, &mut cx.wb).unwrap_or(None); + let cell = super::edit::input_to_cell(&t, sheet, c, &mut cx.wb, &gridcraft_core::region::EN_US).unwrap_or(None); let shm = cx.sheet_mut(sheet)?; match cell { Some(x) => shm.set_cell(c, x), @@ -996,8 +996,9 @@ pub fn list_items(wb: &Workbook, sheet: usize, dv: &Validation) -> Vec { } } -/// Checks typed input against the validation of a cell. `Ok(None)` = valid. -pub fn check_validation(wb: &Workbook, sheet: usize, at: CellRef, input: &str) -> Option<(Validation, String)> { +/// Checks typed input against the validation of a cell. `Ok(None)` = valid. `region` is how the +/// input was typed (en-US for a canonical `input_text`). +pub fn check_validation(wb: &Workbook, sheet: usize, at: CellRef, input: &str, region: &gridcraft_core::Region) -> Option<(Validation, String)> { let sh = wb.sheet(sheet)?; let dv = sh.validations.iter().find(|d| d.ranges.iter().any(|r| r.contains(at)))?; if dv.kind == ValidationKind::Any || !dv.show_error { @@ -1006,7 +1007,7 @@ pub fn check_validation(wb: &Workbook, sheet: usize, at: CellRef, input: &str) - if input.is_empty() && dv.allow_blank { return None; } - let v = gridcraft_core::parse::parse_input(input, wb.date_system).value; + let v = gridcraft_core::parse::parse_input_in(input, wb.date_system, region).value; // The formulas are written for the first cell of the range and see the value being entered, // as in Excel (a custom `=A1>10` on A1:A9 checks A5's new value at A5). let first = dv.ranges.first().map(|r| r.start).unwrap_or(at); @@ -1098,7 +1099,8 @@ fn validate_cmd(s: &mut Session, p: &Json) -> Result { let d = s.doc()?; let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active); let input = str_param(p, "input").unwrap_or(""); - match check_validation(&d.wb, d.wb.active_sheet, at, input) { + let region = typed_region(p, "data.validate")?; + match check_validation(&d.wb, d.wb.active_sheet, at, input, region) { None => Ok(json!({"ok": true})), Some((dv, msg)) => Ok(json!({"ok": false, "message": msg, "title": dv.error_title, "style": format!("{:?}", dv.error_style)})), } @@ -1114,8 +1116,9 @@ fn circle_invalid(s: &mut Session, _: &Json) -> Result { let r = sh.used_range().and_then(|u| r.intersection(&u)); let Some(r) = r else { continue }; for c in r.iter().take(100_000) { + // The stored value's canonical text: en-US, like `data.validate` without a region. let text = sh.input_text(c); - if !text.is_empty() && check_validation(&d.wb, sheet, c, &text).is_some() { + if !text.is_empty() && check_validation(&d.wb, sheet, c, &text, gridcraft_core::region::default_region()).is_some() { bad_cells.push(c.a1()); } } diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 82dbe0be7..5206ad2bb 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -10,13 +10,21 @@ use crate::{Clipboard, Session}; pub fn specs() -> Vec { vec![ - cmd!("cell.set", "Enter Cell", [], None, "{cell?: \"B2\", input: \"text, number or =formula\", sheet?, array?: bool}", has_doc, cell_set), + cmd!( + "cell.set", + "Enter Cell", + [], + None, + "{cell?: \"B2\", input: \"text, number or =formula\", sheet?, array?: bool, region?: \"de-DE\" — the region the text was typed in; without it the text is canonical en-US, so `1.234` is 1.234 (a JSON number `value` is never re-read)}", + has_doc, + cell_set + ), cmd!( "range.setValues", "Set Values", [], None, - "{range?: \"A1\", values: [[...]] (rows of numbers/strings/bools/null; strings starting with = are formulas)}", + "{range?: \"A1\", values: [[...]] (rows of numbers/strings/bools/null; strings starting with = are formulas), region?: tag the strings were typed in (default canonical en-US)}", has_doc, range_set_values ), @@ -25,7 +33,7 @@ pub fn specs() -> Vec { "Fill Range With Input", [], Some("Ctrl+Enter"), - "{range?, input}: enters the same input in every selected cell (relative formulas adjust)", + "{range?, input, region?}: enters the same input in every selected cell (relative formulas adjust); `region` is the tag the text was typed in (default canonical en-US)", has_doc, range_fill ), @@ -47,7 +55,7 @@ pub fn specs() -> Vec { "Paste", ["Home", "Clipboard"], Some("Cmd+V"), - "{at?: \"C3\", text?: \"tab-separated text from the system clipboard\", html?: \"the clipboard's HTML flavour; cells the source recorded as numbers (x:num, sdval) stay numbers\"}", + "{at?: \"C3\", text?: \"tab-separated text from the system clipboard\", html?: \"the clipboard's HTML flavour; cells the source recorded as numbers (x:num, sdval) stay numbers\", region?: tag the text was typed in (default canonical en-US)}", has_doc, paste ), @@ -118,10 +126,29 @@ pub fn specs() -> Vec { // ---------------------------------------------------------------- entry +/// Whether text typed into a cell is a formula: it starts with `=`, or with `+` or `-` followed by a +/// letter or `(` (`-A1`, `+SUM(1)`) unless `region` reads all of it as a number (`-R$ 5`). The +/// formula-language boundary of the UI uses the same test, so the two never disagree. +pub fn is_formula_input(input: &str, date_system: gridcraft_core::DateSystem, region: &gridcraft_core::Region) -> bool { + input.starts_with('=') + || (input.len() > 1 + && (input.starts_with('+') || input.starts_with('-')) + && input.chars().nth(1).is_some_and(|c| c.is_ascii_alphabetic() || c == '(') + && !matches!(gridcraft_core::parse::parse_input_in(input, date_system, region).value, Value::Number(_))) +} + /// What typing `input` into a cell produces (constant or formula, plus an automatic number /// format). Inherits the destination's cell, row or column style, including its number -/// format, before parsing. Errors when the formula can't be parsed. -pub(crate) fn input_to_cell(input: &str, sheet: usize, at: CellRef, wb: &mut gridcraft_model::Workbook) -> Result> { +/// format, before parsing. `region` is how the constants in `input` were typed: en-US for text +/// that is canonical (files, other commands), the user's region for what they typed. Errors when +/// the formula can't be parsed. +pub(crate) fn input_to_cell( + input: &str, + sheet: usize, + at: CellRef, + wb: &mut gridcraft_model::Workbook, + region: &gridcraft_core::Region, +) -> Result> { let style = wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(); if input.is_empty() { let c = Cell { value: Value::Empty, formula: None, style }; @@ -131,12 +158,7 @@ pub(crate) fn input_to_cell(input: &str, sheet: usize, at: CellRef, wb: &mut gri if fmt_is_text { return Ok(Some(Cell { value: Value::text(input), formula: None, style })); } - let is_formula = input.starts_with('=') - || (input.len() > 1 - && (input.starts_with('+') || input.starts_with('-')) - && gridcraft_core::parse::parse_number_text(input).is_none() - && input.chars().nth(1).is_some_and(|c| c.is_ascii_alphabetic() || c == '(')); - if is_formula { + if is_formula_input(input, wb.date_system, region) { let body = input.strip_prefix('=').unwrap_or(input); // Excel closes missing parentheses for you. let mut text = body.to_string(); @@ -176,7 +198,7 @@ pub(crate) fn input_to_cell(input: &str, sheet: usize, at: CellRef, wb: &mut gri } return Ok(Some(cell)); } - let parsed = gridcraft_core::parse::parse_input(input, wb.date_system); + let parsed = gridcraft_core::parse::parse_input_in(input, wb.date_system, region); let mut style = style; if let Some(code) = parsed.format { let cur = wb.styles.get(style).num_fmt.as_str().to_string(); @@ -220,7 +242,11 @@ fn cell_set(s: &mut Session, p: &Json) -> Result { None => s.doc()?.selection.active, }; // Both spellings take a string, number or boolean, as typed (#697). - let input = p.get("input").or_else(|| p.get("value")).map(json_to_input).ok_or_else(|| bad("cell.set", "missing `input` (or `value`)"))?; + let raw = p.get("input").or_else(|| p.get("value")); + let input = raw.map(json_to_input).ok_or_else(|| bad("cell.set", "missing `input` (or `value`)"))?; + // Text is typed in `region`; a JSON number or boolean is already a value and is canonical. + let typed = typed_region(p, "cell.set")?; + let region = if raw.is_some_and(Json::is_string) { typed } else { gridcraft_core::region::default_region() }; let array = bool_param(p, "array").unwrap_or(false); let d = s.doc()?; if let Some(sh) = d.wb.sheet(sheet) @@ -241,7 +267,7 @@ fn cell_set(s: &mut Session, p: &Json) -> Result { if !range.is_single() && cx.wb.sheet(sheet).is_some_and(|sh| sh.tables.iter().any(|t| t.range.intersects(&range))) { return Err(EngineError::Other("Multi-cell array formulas aren't allowed in tables.".into())); } - let mut cell = input_to_cell(&input, sheet, range.start, &mut cx.wb)?.unwrap_or_default(); + let mut cell = input_to_cell(&input, sheet, range.start, &mut cx.wb, region)?.unwrap_or_default(); if let Some(f) = cell.formula.as_mut() { std::sync::Arc::make_mut(f).array = Some(range); } @@ -257,7 +283,7 @@ fn cell_set(s: &mut Session, p: &Json) -> Result { cx.touch(sheet, range.start); return Ok(Json::Null); } - let cell = input_to_cell(&input, sheet, at, &mut cx.wb)?; + let cell = input_to_cell(&input, sheet, at, &mut cx.wb, region)?; let sh = cx.sheet_mut(sheet)?; match cell { Some(c) => sh.set_cell(at, c), @@ -295,6 +321,7 @@ fn range_set_values(s: &mut Session, p: &Json) -> Result { if rows.len() > MAX_ROWS as usize { return Err(bad("range.setValues", "too many rows")); } + let region = typed_region(p, "range.setValues")?; let mut count = 0; edit(s, |cx| { for (ri, row) in rows.iter().enumerate() { @@ -314,7 +341,7 @@ fn range_set_values(s: &mut Session, p: &Json) -> Result { Json::Bool(b) => { Some(Cell { value: Value::Bool(*b), formula: None, style: cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default() }) } - _ => input_to_cell(&input, sheet, at, &mut cx.wb)?, + _ => input_to_cell(&input, sheet, at, &mut cx.wb, region)?, }; let sh = cx.sheet_mut(sheet)?; match cell { @@ -336,9 +363,10 @@ fn range_fill(s: &mut Session, p: &Json) -> Result { let sheet = target_sheet(s, p)?; let ranges = target_ranges(s, p)?; let input = str_param(p, "input").unwrap_or("").to_string(); + let region = typed_region(p, "range.fill")?; let origin = s.doc()?.selection.active; edit(s, |cx| { - let base = input_to_cell(&input, sheet, origin, &mut cx.wb)?; + let base = input_to_cell(&input, sheet, origin, &mut cx.wb, region)?; for r in &ranges { if r.count() > 2_000_000 { return Err(bad("range.fill", "range too large")); @@ -596,6 +624,7 @@ fn coalesce(cells: &[CellRef]) -> Vec { fn undo(s: &mut Session, p: &Json) -> Result { let steps = u32_param(p, "steps").unwrap_or(1).max(1); + let region = s.region(); let d = s.doc_mut()?; let mut label = String::new(); let now = d.wb.clone(); @@ -608,6 +637,7 @@ fn undo(s: &mut Session, p: &Json) -> Result { d.selection = e.selection; } super::extra::keep_watch_list(&now, &mut d.wb); + d.apply_region(region); d.calc.rebuild(&d.wb); d.revision += 1; Ok(json!({"undone": label})) @@ -615,6 +645,7 @@ fn undo(s: &mut Session, p: &Json) -> Result { fn redo(s: &mut Session, p: &Json) -> Result { let steps = u32_param(p, "steps").unwrap_or(1).max(1); + let region = s.region(); let d = s.doc_mut()?; let mut label = String::new(); let now = d.wb.clone(); @@ -627,6 +658,7 @@ fn redo(s: &mut Session, p: &Json) -> Result { d.selection = e.selection; } super::extra::keep_watch_list(&now, &mut d.wb); + d.apply_region(region); d.calc.rebuild(&d.wb); d.revision += 1; Ok(json!({"redone": label})) @@ -792,6 +824,7 @@ fn paste(s: &mut Session, p: &Json) -> Result { fn paste_text(s: &mut Session, p: &Json, text: &str) -> Result { let sheet = target_sheet(s, p)?; let at = cell_param_on(s, p, "at", sheet)?.unwrap_or(s.doc()?.selection.current().start); + let region = typed_region(p, "edit.paste")?; let rows: Vec> = parse_tsv(text); // The HTML flavour, when the host passed it: the number behind each cell's shown text. let html = str_param(p, "html").map(crate::clip_html::table_cells).unwrap_or_default(); @@ -801,7 +834,7 @@ fn paste_text(s: &mut Session, p: &Json, text: &str) -> Result { for (ri, row) in rows.iter().enumerate() { for (ci, v) in row.iter().enumerate() { let Some(c) = at.offset(ri as i64, ci as i64) else { continue }; - let mut cell = input_to_cell(v, sheet, c, &mut cx.wb).unwrap_or_else(|_| Some(Cell::value(Value::text(v.as_str())))); + let mut cell = input_to_cell(v, sheet, c, &mut cx.wb, region).unwrap_or_else(|_| Some(Cell::value(Value::text(v.as_str())))); if let Some(cl) = cell.as_mut() { numeric_from_html(cl, v, html.get(ri).and_then(|r| r.get(ci)), &mut cx.wb); } @@ -1512,7 +1545,9 @@ fn replace(s: &mut Session, p: &Json) -> Result { } else { replace_ci(&text, &what, &with) }; - let cell = input_to_cell(&new, si, c, &mut cx.wb).unwrap_or_else(|_| Some(Cell::value(Value::text(new.as_str())))); + // `new` is built from `input_text`, which is canonical. + let cell = input_to_cell(&new, si, c, &mut cx.wb, &gridcraft_core::region::EN_US) + .unwrap_or_else(|_| Some(Cell::value(Value::text(new.as_str())))); let sh = cx.sheet_mut(si)?; match cell { Some(cell) => sh.set_cell(c, cell), diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index 01e2ac6e6..828bde7db 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -924,7 +924,8 @@ fn from_table_range(s: &mut Session, p: &Json) -> Result { let d = s.doc()?; let sh = d.wb.active().ok_or(EngineError::NoDocument)?; let r = if r.is_single() { crate::selection::current_region(sh, r.start) } else { r }; - let text = crate::display::range_text(&d.wb, d.wb.active_sheet, r); + // The invariant CSV reader parses this text, so it is written with en-US separators. + let text = crate::display::range_text_in(&d.wb, d.wb.active_sheet, r, &gridcraft_core::region::EN_US); from_csv(s, &json!({"text": text, "delimiter": "\t", "sheet": format!("{} (2)", sh.name).chars().take(31).collect::()})) } diff --git a/crates/engine/src/cmd/forecast.rs b/crates/engine/src/cmd/forecast.rs index d25b367ba..10c9a2454 100644 --- a/crates/engine/src/cmd/forecast.rs +++ b/crates/engine/src/cmd/forecast.rs @@ -42,7 +42,7 @@ pub fn specs() -> Vec { "Forecast Sheet", ["Data", "Forecast"], None, - "{timeline?: \"A2:A25\", values?: \"B2:B25\" (default: the first two columns of `range`, the selection or the current region; a text first cell is the header), end?: timeline value or date text | periods?: n (default: a third of the history, at least 1; ≤10,000 and history × periods ≤ 500,000; history ≤ 10,000 rows), forecastStart?: a timeline value or date text on the history, before its last point (hindcast: the model is fit to the points before it, the forecast columns start there and run over the actuals; default and the last point: forecast after the history), confidenceInterval?: bool (default true), confidence?: 0.95 or 95, seasonality?: \"auto\" | 0 (none) | 2–8,760, fillMissing?: interpolate|zeros, aggregate?: average|count|counta|max|median|min|sum, statistics?: bool, chart?: line|column} → {sheet, table, chart, history, periods}. Inserts a sheet before the active one: timeline, values, Forecast (FORECAST.ETS), Lower/Upper Confidence Bound (FORECAST.ETS.CONFINT), optional FORECAST.ETS.STAT table, and a chart.", + "{timeline?: \"A2:A25\", values?: \"B2:B25\" (default: the first two columns of `range`, the selection or the current region; a text first cell is the header), end?: timeline value or date text (typed in `region`, default canonical en-US), region?: tag `end` and `forecastStart` were typed in | periods?: n (default: a third of the history, at least 1; ≤10,000 and history × periods ≤ 500,000; history ≤ 10,000 rows), forecastStart?: a timeline value or date text on the history, before its last point (hindcast: the model is fit to the points before it, the forecast columns start there and run over the actuals; default and the last point: forecast after the history), confidenceInterval?: bool (default true), confidence?: 0.95 or 95, seasonality?: \"auto\" | 0 (none) | 2–8,760, fillMissing?: interpolate|zeros, aggregate?: average|count|counta|max|median|min|sum, statistics?: bool, chart?: line|column} → {sheet, table, chart, history, periods}. Inserts a sheet before the active one: timeline, values, Forecast (FORECAST.ETS), Lower/Upper Confidence Bound (FORECAST.ETS.CONFINT), optional FORECAST.ETS.STAT table, and a chart.", has_doc, forecast_sheet )] @@ -223,10 +223,11 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result { if !rows.iter().any(|r| matches!(r.1, Value::Number(_))) { return Err(EngineError::Other("There are no values to forecast.".into())); } + let region = typed_region(p, CMD)?; let time_param = |key: &str| -> Result> { match p.get(key) { Some(Json::Number(n)) => Ok(n.as_f64()), - Some(Json::String(t)) if !t.trim().is_empty() => match gridcraft_core::parse::parse_input(t.trim(), sys).value { + Some(Json::String(t)) if !t.trim().is_empty() => match gridcraft_core::parse::parse_input_in(t.trim(), sys, region).value { Value::Number(x) => Ok(Some(x)), _ => Err(bad(CMD, format!("`{key}` is not a number or a date: `{t}`"))), }, @@ -562,6 +563,25 @@ mod tests { assert!(r.is_err()); } + #[test] + fn forecast_times_are_read_in_the_region_they_were_typed_in() { + let run = |key: &str, text: &str, region: Option<&str>| { + let mut s = session_with_history(); + s.execute("app.region", json!({"region": "de-DE"})).unwrap(); + let mut p = json!({"timeline": "A2:A17", "values": "B2:B17", "seasonality": 4, "periods": 2}); + p[key] = json!(text); + if let Some(tag) = region { + p["region"] = json!(tag); + } + s.execute("data.forecastSheet", p) + }; + // Canonical text: `22,0` is no number, whatever the session's region is. + assert!(run("end", "22,0", None).unwrap_err().to_string().contains("not a number")); + assert_eq!(run("end", "22,0", Some("de-DE")).unwrap()["periods"], 6); + assert!(run("forecastStart", "13,0", None).unwrap_err().to_string().contains("not a number")); + assert_eq!(run("forecastStart", "13,0", Some("de-DE")).unwrap()["fitted"], 12); + } + #[test] fn forecast_sheet_parameters_and_errors() { let mut s = session_with_history(); diff --git a/crates/engine/src/cmd/formulas.rs b/crates/engine/src/cmd/formulas.rs index 40df72df0..47f3c48b7 100644 --- a/crates/engine/src/cmd/formulas.rs +++ b/crates/engine/src/cmd/formulas.rs @@ -94,7 +94,7 @@ fn auto_sum(s: &mut Session, p: &Json) -> Result { let sheet = d.wb.active_sheet; edit(s, |cx| { for (c, f) in &writes { - let cell = super::edit::input_to_cell(f, sheet, *c, &mut cx.wb)?.unwrap_or_default(); + let cell = super::edit::input_to_cell(f, sheet, *c, &mut cx.wb, &gridcraft_core::region::EN_US)?.unwrap_or_default(); cx.sheet_mut(sheet)?.set_cell(*c, cell); cx.touch(sheet, *c); } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 50865a2b7..5780655ec 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -201,6 +201,16 @@ pub(crate) fn f64_param(p: &Json, key: &str) -> Option { pub(crate) fn bool_param(p: &Json, key: &str) -> Option { p.get(key).and_then(Json::as_bool) } +/// The region typed text in `p` was written in: the `region` tag, or en-US without one. Command +/// text is canonical (en-US) unless the caller says it was typed in a region, so a recorded +/// command or script means the same whatever the session's regional preference is. +pub(crate) fn typed_region(p: &Json, cmd: &str) -> Result<&'static gridcraft_core::Region> { + match p.get("region") { + None | Some(Json::Null) => Ok(gridcraft_core::region::default_region()), + Some(Json::String(tag)) => crate::find_region(tag), + Some(_) => Err(bad(cmd, "`region` must be a tag such as \"pt-BR\"")), + } +} pub(crate) fn u32_param(p: &Json, key: &str) -> Option { p.get(key).and_then(Json::as_u64).map(|v| v.min(u32::MAX as u64) as u32) } diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index a10f19d46..9308461a6 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -1074,7 +1074,7 @@ fn draw_text(pg: &mut Page, b: &Block, c: CellRef, l: &Look, rect: (f32, f32, f3 { let digit = gridcraft_pdf::text_width(font, size, "0").max(0.1); let max_chars = (avail / digit).floor().max(1.0) as usize; - gridcraft_numfmt::format_general_fit(n, max_chars.min(11)).unwrap_or_else(|| "#".repeat(max_chars.min(255))) + gridcraft_numfmt::format_general_fit_in(n, max_chars.min(11), wb.region).unwrap_or_else(|| "#".repeat(max_chars.min(255))) } else { crate::display::cell_text(wb, sh, c) }; diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index edcacf57d..fd7e88d7d 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -33,6 +33,15 @@ pub fn specs() -> Vec { s.prefs.formula_bar = bool_param(p, "on").unwrap_or(!s.prefs.formula_bar); Ok(json!({"on": s.prefs.formula_bar})) }), + cmd!( + noundo "app.region", + "Region", + [], + None, + "{region?: \"en-US\"|\"en-GB\"|\"en-IN\"|\"es-ES\"|\"de-DE\"|\"fr-FR\"|\"it-IT\"|\"pt-BR\"|\"pt-PT\"|\"ru-RU\"|\"cs-CZ\"|\"ja-JP\"|\"ko-KR\"|\"zh-CN\"|\"zh-TW\"} — sets the regional format every open workbook is typed and shown in (decimal and group separators, date order, month names, system dates); display only, values never change. Without `region` it reports the current one → {region, name, decimal, group, dateOrder, shortDate, longDate, available: [tags]}", + always, + region + ), cmd!("view.zoomToSelection", "Zoom to Selection", ["View", "Zoom"], None, "{viewWidth?, viewHeight?}", has_doc, zoom_selection), cmd!("pageLayout.orientation", "Orientation", ["Page Layout", "Page Setup"], None, "{orientation: portrait|landscape}", has_doc, orientation), cmd!("pageLayout.size", "Size", ["Page Layout", "Page Setup"], None, "{paper: Letter|Legal|A4|A3|A5|Tabloid|Executive}", has_doc, paper), @@ -112,6 +121,30 @@ pub fn specs() -> Vec { ] } +/// Options › Region: sets (or reports) the regional format; never opens a dialog. +fn region(s: &mut Session, p: &Json) -> Result { + let r = match str_param(p, "region") { + Some(tag) => s.set_region(tag)?, + None if p.get("region").is_some_and(|v| !v.is_null()) => return Err(bad("app.region", "`region` must be a tag such as \"pt-BR\"")), + None => s.region(), + }; + let order = match r.date_order { + gridcraft_core::region::DateOrder::Mdy => "MDY", + gridcraft_core::region::DateOrder::Dmy => "DMY", + gridcraft_core::region::DateOrder::Ymd => "YMD", + }; + Ok(json!({ + "region": r.tag, + "name": r.name, + "decimal": r.decimal.to_string(), + "group": r.group.to_string(), + "dateOrder": order, + "shortDate": r.short_date, + "longDate": r.long_date, + "available": gridcraft_core::region::all().iter().map(|r| r.tag).collect::>(), + })) +} + fn freeze_panes(s: &mut Session, p: &Json) -> Result { let d = s.doc()?; let sh = d.wb.active().ok_or(EngineError::NoDocument)?; diff --git a/crates/engine/src/cmd/whatif.rs b/crates/engine/src/cmd/whatif.rs index 09c716cab..2d8c7be55 100644 --- a/crates/engine/src/cmd/whatif.rs +++ b/crates/engine/src/cmd/whatif.rs @@ -155,7 +155,8 @@ fn cell_list(wb: &Workbook, t: &str, cmd: &str, cap: usize) -> Result<(usize, Ve Ok((sheet.unwrap_or(wb.active_sheet), out)) } -/// What a JSON value typed into a cell is, as a constant. +/// What a JSON value typed into a cell is, as a constant. Scenario values are stored in the +/// workbook, so their text is canonical (en-US), whatever the region. fn json_value(v: &Json, wb: &Workbook) -> Value { match v { Json::Null => Value::Empty, @@ -489,7 +490,7 @@ fn scenario_show(s: &mut Session, p: &Json) -> Result { edit(s, |cx| { for (c, v) in cells.iter().zip(sc.values.iter()) { let input = super::edit::json_to_input(v); - let cell = super::edit::input_to_cell(&input, si, *c, &mut cx.wb)?; + let cell = super::edit::input_to_cell(&input, si, *c, &mut cx.wb, &gridcraft_core::region::EN_US)?; let sh = cx.sheet_mut(si)?; match cell { Some(cell) => sh.set_cell(*c, cell), diff --git a/crates/engine/src/display.rs b/crates/engine/src/display.rs index b90e42727..7c516d647 100644 --- a/crates/engine/src/display.rs +++ b/crates/engine/src/display.rs @@ -3,9 +3,9 @@ use std::collections::HashMap; use std::sync::Mutex; -use gridcraft_core::{CellRef, RangeRef, Value}; +use gridcraft_core::{CellRef, RangeRef, Region, Value}; use gridcraft_model::{Sheet, Workbook}; -use gridcraft_numfmt::{Formatted, NumberFormat, format_value}; +use gridcraft_numfmt::{Formatted, NumberFormat, format_value_in}; /// Parsed number formats are cached by code. fn parsed(code: &str) -> NumberFormat { @@ -30,23 +30,37 @@ pub fn number_format(code: &str) -> NumberFormat { /// Formats a value with a format code. pub fn format(v: &Value, code: &str, wb: &Workbook) -> Formatted { - format_value(v, &parsed(code), wb.date_system) + format_in(v, code, wb, wb.region) +} + +/// [`format`] in an explicit region instead of the workbook's. +pub fn format_in(v: &Value, code: &str, wb: &Workbook, region: &Region) -> Formatted { + format_value_in(v, &parsed(code), wb.date_system, region) } /// What the cell editor and the formula bar start from. Same as `Cell::input_text`, except that a /// constant number in a date or time format is shown as a date (as the grid shows it), not as its /// serial. When the formatted text would not read back as the same date (for example /// `dddd, mmmm d`), an ISO `yyyy-mm-dd` (with `hh:mm:ss` for a time of day; just `hh:mm:ss` -/// for a time alone) is used instead, and if even that would not read back, the serial. +/// for a time alone) is used instead, and if even that would not read back, the serial. Constant +/// numbers use the workbook region's decimal separator, and dates are read back with its date order. pub fn edit_text(wb: &Workbook, sheet: &Sheet, c: CellRef) -> String { let raw = sheet.input_text(c); let Some(cell) = sheet.cell(c) else { return raw }; let Value::Number(n) = &cell.value else { return raw }; let code = wb.styles.get(cell.style).num_fmt.as_str(); - if cell.formula.is_some() || !parsed(code).is_date() { + if cell.formula.is_some() { return raw; } - let reads_back = |s: &str| matches!(gridcraft_core::parse::parse_input(s, wb.date_system).value, Value::Number(m) if (m - n).abs() < 1e-9); + // A constant number reads back as typed in the region: with its decimal separator. Every + // fallback below returns this, never the canonical text, which the region would misread + // (de-DE reads `-1.234` as -1234). + let raw = if wb.region.decimal == '.' { raw } else { raw.replacen('.', &wb.region.decimal.to_string(), 1) }; + if !parsed(code).is_date() { + return raw; + } + let reads_back = + |s: &str| matches!(gridcraft_core::parse::parse_input_in(s, wb.date_system, wb.region).value, Value::Number(m) if (m - n).abs() < 1e-9); let shown = format(&cell.value, code, wb).text; if reads_back(&shown) { return shown; @@ -82,20 +96,31 @@ pub fn formula_hidden(wb: &Workbook, sheet: &Sheet, c: CellRef) -> bool { /// The text a cell shows (full precision General; the grid narrows General to fit the column). pub fn cell_text(wb: &Workbook, sheet: &Sheet, c: CellRef) -> String { + cell_text_in(wb, sheet, c, wb.region) +} + +/// [`cell_text`] in an explicit region. +pub fn cell_text_in(wb: &Workbook, sheet: &Sheet, c: CellRef, region: &Region) -> String { if sheet.show_formulas && let Some(f) = sheet.cell(c).and_then(|x| x.formula.as_ref()) && !formula_hidden(wb, sheet, c) { return format!("={}", f.text); } - value_text(wb, sheet, c) + value_text_in(wb, sheet, c, region) } /// A cell's value with its number format applied, independent of column width: no `#####` /// and no General narrowing (Save As CSV/Text writes this). Formulas give their result. pub fn value_text(wb: &Workbook, sheet: &Sheet, c: CellRef) -> String { + value_text_in(wb, sheet, c, wb.region) +} + +/// [`value_text`] in an explicit region. Files use the en-US one: CSV is read back with en-US +/// rules, so a region's separators must not reach it. +pub fn value_text_in(wb: &Workbook, sheet: &Sheet, c: CellRef, region: &Region) -> String { let style = wb.styles.get(sheet.style_id(c)); - let f = format(&sheet.value(c), style.num_fmt.as_str(), wb); + let f = format_in(&sheet.value(c), style.num_fmt.as_str(), wb, region); match f.fill { Some((_, pos)) => { let mut t = f.text; @@ -110,6 +135,12 @@ pub fn value_text(wb: &Workbook, sheet: &Sheet, c: CellRef) -> String { /// Tab-separated text of a range, as the system clipboard gets it. pub fn range_text(wb: &Workbook, sheet: usize, r: RangeRef) -> String { + range_text_in(wb, sheet, r, wb.region) +} + +/// [`range_text`] in an explicit region. Text that goes back through the invariant CSV reader +/// (Get Data from a range) uses en-US. +pub fn range_text_in(wb: &Workbook, sheet: usize, r: RangeRef, region: &Region) -> String { let Some(sh) = wb.sheet(sheet) else { return String::new() }; let mut out = String::new(); let r = clipboard_range(r); @@ -123,7 +154,7 @@ pub fn range_text(wb: &Workbook, sheet: usize, r: RangeRef) -> String { out.push('\t'); } first = false; - let t = cell_text(wb, sh, CellRef::new(row, col)); + let t = cell_text_in(wb, sh, CellRef::new(row, col), region); if t.contains(['\t', '\n', '"']) { out.push('"'); out.push_str(&t.replace('"', "\"\"")); diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 3e84036cb..50e9c187c 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -231,6 +231,8 @@ pub fn source_dir(path: &str) -> Option { /// Save As CSV/Text writes "the text and values as they are displayed in cells" with "all /// characters in each cell" (Microsoft Support, *Excel formatting and features that are not /// transferred to other file formats*): number formats applied, never column-width `#####`. +/// Always with en-US separators and names, whatever the workbook's region: CSV is read back with +/// en-US rules (`gridcraft_xlsx::read_csv`), so a regional `1.000` would reopen as 1. fn csv(wb: &Workbook, sh: &Sheet, delimiter: u8) -> Result> { gridcraft_xlsx::write_csv(sh, delimiter, |c| { // General's grid-width formatting rounds large identifiers to scientific notation. @@ -241,7 +243,7 @@ fn csv(wb: &Workbook, sh: &Sheet, delimiter: u8) -> Result> { { number.to_string() } else { - crate::display::value_text(wb, sh, c) + crate::display::value_text_in(wb, sh, c, &gridcraft_core::region::EN_US) } }) .map_err(|e| EngineError::Other(e.to_string())) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 258a1cb72..4a01286c1 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -142,6 +142,20 @@ impl DocState { pub fn is_dirty(&self) -> bool { !Arc::ptr_eq(&self.wb, &self.saved) } + /// Gives the workbook the session's region (`Workbook::region` is not saved, so every + /// document, and every snapshot undo brings back, gets it from the session). Only the display + /// changes, never a value; a clean workbook stays clean. + pub(crate) fn apply_region(&mut self, region: &'static gridcraft_core::Region) { + if *self.wb.region == *region { + return; + } + let clean = !self.is_dirty(); + Arc::make_mut(&mut self.wb).region = region; + if clean { + self.saved = self.wb.clone(); + } + self.revision += 1; + } pub fn sheet_index(&self) -> usize { self.wb.active_sheet } @@ -199,6 +213,10 @@ pub struct Prefs { pub user_dictionary: Vec, /// View › Show › Formula Bar (an application option in Excel, not saved in the file). pub formula_bar: bool, + /// The regional format (Options › Region), a tag from [`core::region::all`]: how numbers, + /// dates and times are typed and shown. The apps start it from the system region on first + /// run; `app.region` changes it. An unknown tag reads as en-US. + pub region: String, } impl Default for Prefs { @@ -213,6 +231,7 @@ impl Default for Prefs { r1c1: false, user_dictionary: vec![], formula_bar: true, + region: gridcraft_core::region::default_region().tag.into(), } } } @@ -258,10 +277,38 @@ pub struct Session { pub show_all_notes: bool, } +/// The region a tag names (`pt-BR`, case and `_` insensitive); an error listing the valid tags +/// when there is none. +pub fn find_region(tag: &str) -> Result<&'static gridcraft_core::Region> { + gridcraft_core::region::find(tag).ok_or_else(|| { + let tags: Vec<&str> = gridcraft_core::region::all().iter().map(|r| r.tag).collect(); + EngineError::Other(format!("unknown region {tag:?} (use one of {})", tags.join(", "))) + }) +} + impl Session { pub fn new() -> Session { Session::default() } + /// The region documents are typed and shown in: `prefs.region`, en-US when it names none. + pub fn region(&self) -> &'static gridcraft_core::Region { + gridcraft_core::region::find(&self.prefs.region).unwrap_or_else(gridcraft_core::region::default_region) + } + /// Sets the region (a tag such as `pt-BR`) and applies it to every open document. + pub fn set_region(&mut self, tag: &str) -> Result<&'static gridcraft_core::Region> { + let region = find_region(tag)?; + self.prefs.region = region.tag.into(); + self.apply_region(); + Ok(region) + } + /// Applies `prefs.region` to every open document: for after the preferences were replaced + /// (the apps load them from a file). + pub fn apply_region(&mut self) { + let region = self.region(); + for d in &mut self.docs { + d.apply_region(region); + } + } pub fn documents(&self) -> &[DocState] { &self.docs } @@ -285,7 +332,8 @@ impl Session { pub fn doc_mut(&mut self) -> Result<&mut DocState> { self.active_mut().ok_or(EngineError::NoDocument) } - pub fn add_document(&mut self, d: DocState) -> usize { + pub fn add_document(&mut self, mut d: DocState) -> usize { + d.apply_region(self.region()); self.docs.push(d); self.active = self.docs.len() - 1; self.active diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ae499a189..8ef502fa0 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3282,3 +3282,335 @@ fn paste_special_validation_moves_relative_references() { assert!(!ok(&mut s, "C3", "5"), "the pasted rule checks C3, not A1"); assert!(ok(&mut s, "C3", "20")); } + +fn text_of(s: &Session, a: &str) -> String { + let d = s.doc().unwrap(); + crate::display::cell_text(&d.wb, d.wb.active().unwrap(), CellRef::parse(a).unwrap()) +} + +#[test] +fn app_region_sets_reports_and_rejects() { + let mut s = s(); + // No params reports the current region and never changes it. + let r = s.execute("app.region", json!({})).unwrap(); + assert_eq!(r["region"], "en-US"); + assert_eq!(r["decimal"], "."); + assert_eq!(r["group"], ","); + assert_eq!(r["dateOrder"], "MDY"); + assert_eq!(r["shortDate"], "m/d/yyyy"); + assert!(r["available"].as_array().unwrap().iter().any(|t| t == "pt-BR")); + assert_eq!(s.prefs.region, "en-US"); + // Setting returns the new region and stores it in the preferences. + let r = s.execute("app.region", json!({"region": "pt-BR"})).unwrap(); + assert_eq!((r["region"].as_str(), r["decimal"].as_str(), r["dateOrder"].as_str()), (Some("pt-BR"), Some(","), Some("DMY"))); + assert_eq!(s.prefs.region, "pt-BR"); + assert_eq!(s.doc().unwrap().wb.region.tag, "pt-BR"); + assert_eq!(s.execute("app.region", json!({})).unwrap()["region"], "pt-BR"); + // Tags are matched like the system writes them; unknown regions are errors that keep the setting. + assert_eq!(s.execute("app.region", json!({"region": "de_de"})).unwrap()["region"], "de-DE"); + for bad in [json!({"region": "xx-XX"}), json!({"region": ""}), json!({"region": "de"}), json!({"region": 7}), json!({"region": ["de-DE"]})] { + let e = s.execute("app.region", bad.clone()).unwrap_err().to_string(); + assert!(e.contains("region"), "{bad}: {e}"); + assert_eq!(s.prefs.region, "de-DE", "{bad}"); + } + assert!(s.execute("app.region", json!({"region": "xx-XX"})).unwrap_err().to_string().contains("pt-BR"), "the error lists the regions"); + // Programmatic calls never ask for a dialog. + assert!(s.ui_requests.is_empty()); + // Without a workbook it only stores the preference. + let mut empty = Session::new(); + assert_eq!(empty.execute("app.region", json!({"region": "fr-FR"})).unwrap()["region"], "fr-FR"); + empty.new_workbook(); + assert_eq!(empty.doc().unwrap().wb.region.tag, "fr-FR", "new documents get the preference"); + // It is journaled (a recorded session replays with the same region) but is not an undo step. + assert!(s.journal.iter().any(|(id, p)| id == "app.region" && p["region"] == "pt-BR")); + assert!(s.doc().unwrap().undo.is_empty()); +} + +#[test] +fn region_changes_how_typed_dates_are_read_and_shown_but_not_the_value() { + let mut s = s(); + s.execute("app.region", json!({"region": "en-GB"})).unwrap(); + s.execute("cell.set", json!({"cell": "A1", "input": "25/09/2026", "region": "en-GB"})).unwrap(); + assert_eq!(v(&s, "A1"), Value::Number(46290.0)); + assert_eq!(fmt_of(&s, "A1"), "m/d/yyyy", "the stored code stays canonical"); + assert_eq!(text_of(&s, "A1"), "25/09/2026"); + // A date and time are built-in format 22. + s.execute("cell.set", json!({"cell": "A2", "input": "25/09/2026 18:00", "region": "en-GB"})).unwrap(); + assert_eq!(v(&s, "A2"), Value::Number(46290.75)); + assert_eq!(text_of(&s, "A2"), "25/09/2026 18:00"); + // The editor starts from what reads back in the region. + { + let d = s.doc().unwrap(); + assert_eq!(crate::display::edit_text(&d.wb, d.wb.active().unwrap(), CellRef::parse("A1").unwrap()), "25/09/2026"); + } + // Switching back displays the same values the en-US way. + s.execute("app.region", json!({"region": "en-US"})).unwrap(); + assert_eq!(v(&s, "A1"), Value::Number(46290.0)); + assert_eq!(text_of(&s, "A1"), "9/25/2026"); + assert_eq!(text_of(&s, "A2"), "9/25/2026 18:00"); + assert_eq!(s.doc().unwrap().undo.len(), 2, "switching the region adds no undo step"); + s.execute("cell.set", json!({"cell": "A3", "input": "25/09/2026"})).unwrap(); + assert_eq!(v(&s, "A3"), Value::from("25/09/2026"), "day first isn't a date in en-US"); + s.execute("cell.set", json!({"cell": "A3", "input": "9/25/2026"})).unwrap(); + assert_eq!(v(&s, "A3"), Value::Number(46290.0)); +} + +#[test] +fn decimal_comma_regions_type_and_show_numbers() { + let mut s = s(); + s.execute("app.region", json!({"region": "pt-BR"})).unwrap(); + s.execute("cell.set", json!({"cell": "A1", "input": "1.234,56", "region": "pt-BR"})).unwrap(); + assert_eq!(v(&s, "A1"), Value::Number(1234.56)); + assert_eq!(text_of(&s, "A1"), "1.234,56"); + s.execute("cell.set", json!({"cell": "A2", "input": "3,5", "region": "pt-BR"})).unwrap(); + assert_eq!(v(&s, "A2"), Value::Number(3.5)); + assert_eq!(text_of(&s, "A2"), "3,5", "General shows the region's decimal"); + s.execute("cell.set", json!({"cell": "A3", "input": "R$ 10,50", "region": "pt-BR"})).unwrap(); + assert_eq!(v(&s, "A3"), Value::Number(10.5)); + assert_eq!(text_of(&s, "A3"), "R$ 10,50"); + s.execute("cell.set", json!({"cell": "A4", "input": "12,5%", "region": "pt-BR"})).unwrap(); + assert_eq!(text_of(&s, "A4"), "12,50%"); + // The editor shows the number as it reads back in the region. + { + let d = s.doc().unwrap(); + let sh = d.wb.active().unwrap(); + assert_eq!(crate::display::edit_text(&d.wb, sh, CellRef::parse("A2").unwrap()), "3,5"); + assert_eq!(crate::display::edit_text(&d.wb, sh, CellRef::parse("A1").unwrap()), "1234,56"); + } + // A leading minus before a currency amount is a number, not a formula. + s.execute("cell.set", json!({"cell": "A5", "input": "-R$ 5", "region": "pt-BR"})).unwrap(); + assert_eq!(v(&s, "A5"), Value::Number(-5.0)); + // Formulas stay canonical; a constant inside one keeps the point. + s.execute("cell.set", json!({"cell": "B1", "input": "=A2*2", "region": "pt-BR"})).unwrap(); + assert_eq!(v(&s, "B1"), Value::Number(7.0)); + assert_eq!(text_of(&s, "B1"), "7"); + // The same text in en-US stays text. + s.execute("app.region", json!({"region": "en-US"})).unwrap(); + assert_eq!(text_of(&s, "A1"), "1,234.56"); + s.execute("cell.set", json!({"cell": "C1", "input": "3,5"})).unwrap(); + assert_eq!(v(&s, "C1"), Value::from("3,5")); +} + +#[test] +fn region_applies_to_every_document_new_documents_and_undo() { + let mut s = s(); + s.new_workbook(); + s.execute("cell.set", json!({"cell": "A1", "input": "9/25/2026"})).unwrap(); + s.execute("app.region", json!({"region": "de-DE"})).unwrap(); + assert!(s.documents().iter().all(|d| d.wb.region.tag == "de-DE")); + assert_eq!(text_of(&s, "A1"), "25.09.2026"); + s.new_workbook(); + assert_eq!(s.doc().unwrap().wb.region.tag, "de-DE"); + // Undo brings back an older snapshot; it shows in the current region. + s.set_active(1); + s.execute("cell.set", json!({"cell": "A2", "input": "1,5", "region": "de-DE"})).unwrap(); + s.execute("app.region", json!({"region": "ja-JP"})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.doc().unwrap().wb.region.tag, "ja-JP"); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(s.doc().unwrap().wb.region.tag, "ja-JP"); + assert_eq!(text_of(&s, "A1"), "2026/9/25"); + // A saved workbook doesn't turn dirty because the region changed. + let mut c = Session::new(); + c.new_workbook(); + assert!(!c.doc().unwrap().is_dirty()); + c.execute("app.region", json!({"region": "fr-FR"})).unwrap(); + assert!(!c.doc().unwrap().is_dirty()); + // The region isn't saved with the workbook and a file opens in the current one. + let json_bytes = serde_json::to_string(&*c.doc().unwrap().wb).unwrap(); + assert!(!json_bytes.contains("fr-FR")); + let back: gridcraft_model::Workbook = serde_json::from_str(&json_bytes).unwrap(); + assert_eq!(back.region.tag, "en-US"); + c.add_document(crate::DocState::new(back, None, "Back".into())); + assert_eq!(c.doc().unwrap().wb.region.tag, "fr-FR"); + // Preferences replaced from a file are applied explicitly. + c.prefs = serde_json::from_str(r#"{"region": "it-IT"}"#).unwrap(); + c.apply_region(); + assert!(c.documents().iter().all(|d| d.wb.region.tag == "it-IT")); + c.prefs = serde_json::from_str(r#"{"region": "nonsense"}"#).unwrap(); + c.apply_region(); + assert!(c.documents().iter().all(|d| d.wb.region.tag == "en-US"), "an unknown saved region reads as en-US"); + assert_eq!(serde_json::from_str::("{}").unwrap().region, "en-US", "prefs saved before regions existed"); +} + +#[test] +fn data_validation_and_status_bar_follow_the_region() { + let mut s = s(); + s.execute("app.region", json!({"region": "de-DE"})).unwrap(); + s.execute("cell.set", json!({"cell": "A1", "input": "1.234,5", "region": "de-DE"})).unwrap(); + s.execute("cell.set", json!({"cell": "A2", "input": "2,25", "region": "de-DE"})).unwrap(); + s.execute("selection.set", json!({"range": "A1:A2"})).unwrap(); + let d = s.doc().unwrap(); + let st = crate::display::stats(d.wb.active().unwrap(), &d.selection.ranges); + assert_eq!(st.sum, 1236.75); + // The status bar formats its sum with General's wide code through display::format. + let shown = crate::display::format(&Value::Number(st.sum), "#,##0.##########", &d.wb).text; + assert_eq!(shown.trim_end_matches(','), "1.236,75"); + // Typed input is checked as the region reads it. + s.execute("data.validation", json!({"range": "B1", "type": "decimal", "operator": "between", "formula1": "0", "formula2": "5"})).unwrap(); + assert_eq!(s.execute("data.validate", json!({"cell": "B1", "input": "4,5", "region": "de-DE"})).unwrap()["ok"], true); + assert_eq!(s.execute("data.validate", json!({"cell": "B1", "input": "5,5", "region": "de-DE"})).unwrap()["ok"], false); + s.execute("app.region", json!({"region": "en-US"})).unwrap(); + assert_eq!(s.execute("data.validate", json!({"cell": "B1", "input": "4,5"})).unwrap()["ok"], false, "4,5 isn't a number in en-US"); +} + +/// Text in command params is canonical (en-US) unless the call says it was typed in a region. +#[test] +fn commands_read_text_canonically_unless_given_the_region_it_was_typed_in() { + let mut s = s(); + s.execute("app.region", json!({"region": "de-DE"})).unwrap(); + // A JSON number is a value, not typed text: de-DE must not read its point as a group separator. + s.execute("cell.set", json!({"cell": "A1", "value": 1.234})).unwrap(); + assert_eq!(v(&s, "A1"), Value::Number(1.234)); + // Text without a region is canonical, whatever the preference is... + s.execute("cell.set", json!({"cell": "A2", "input": "1.234"})).unwrap(); + assert_eq!(v(&s, "A2"), Value::Number(1.234)); + s.execute("cell.set", json!({"cell": "A3", "input": "1,234"})).unwrap(); + assert_eq!(v(&s, "A3"), Value::Number(1234.0), "en-US reads the comma as a group separator"); + // ...and with a region it is read the way that region writes it. + s.execute("cell.set", json!({"cell": "A4", "input": "1.234", "region": "de-DE"})).unwrap(); + assert_eq!(v(&s, "A4"), Value::Number(1234.0)); + s.execute("cell.set", json!({"cell": "A5", "input": "1,234", "region": "de-DE"})).unwrap(); + assert_eq!(v(&s, "A5"), Value::Number(1.234)); + // A value (not text) stays a value even when a region is named. + s.execute("cell.set", json!({"cell": "A6", "value": 1.5, "region": "de-DE"})).unwrap(); + assert_eq!(v(&s, "A6"), Value::Number(1.5)); + // Headless: day-first text stays text after a region change unless the call names the region. + let mut h = self::s(); + h.execute("app.region", json!({"region": "en-GB"})).unwrap(); + h.execute("cell.set", json!({"cell": "A1", "input": "25/09/2026"})).unwrap(); + assert_eq!(v(&h, "A1"), Value::from("25/09/2026")); + h.execute("cell.set", json!({"cell": "A1", "input": "25/09/2026", "region": "en-GB"})).unwrap(); + assert_eq!(v(&h, "A1"), Value::Number(46290.0)); + // range.fill, range.setValues and plain-text paste take the same parameter. + h.execute("range.fill", json!({"range": "B1:B2", "input": "25/09/2026", "region": "en-GB"})).unwrap(); + assert_eq!((v(&h, "B1"), v(&h, "B2")), (Value::Number(46290.0), Value::Number(46290.0))); + h.execute("range.setValues", json!({"range": "C1", "values": [["25/09/2026"]]})).unwrap(); + assert_eq!(v(&h, "C1"), Value::from("25/09/2026")); + h.execute("range.setValues", json!({"range": "C1", "values": [["25/09/2026"]], "region": "en-GB"})).unwrap(); + assert_eq!(v(&h, "C1"), Value::Number(46290.0)); + h.execute("edit.paste", json!({"at": "D1", "text": "25/09/2026\t1,5"})).unwrap(); + assert_eq!((v(&h, "D1"), v(&h, "E1")), (Value::from("25/09/2026"), Value::from("1,5"))); + h.execute("edit.paste", json!({"at": "D2", "text": "25/09/2026\t1,5", "region": "de-DE"})).unwrap(); + assert_eq!(v(&h, "D2"), Value::Number(46290.0), "de-DE is day first too"); + assert_eq!(v(&h, "E2"), Value::Number(1.5)); + // A bad tag is an error naming the valid ones, and changes nothing. + for bad in [json!("xx"), json!(7), json!("")] { + let e = h.execute("cell.set", json!({"cell": "Z1", "input": "1", "region": bad.clone()})).unwrap_err().to_string(); + assert!(e.contains("region"), "{bad}: {e}"); + } + assert!(h.execute("cell.set", json!({"cell": "Z1", "input": "1", "region": "xx"})).unwrap_err().to_string().contains("en-GB")); + assert_eq!(v(&h, "Z1"), Value::Empty); + assert_eq!(h.execute("data.validate", json!({"cell": "Z1", "input": "1", "region": null})).unwrap()["ok"], true, "null is no region"); +} + +/// A script means the same thing whatever the session's region is when it runs. +#[test] +fn recorded_scripts_and_the_journal_replay_independently_of_the_current_region() { + let mut s = s(); + s.execute("app.region", json!({"region": "de-DE"})).unwrap(); + s.execute("automate.recordActions", json!({})).unwrap(); + s.execute("cell.set", json!({"cell": "A1", "input": "1.234", "region": "de-DE"})).unwrap(); + s.execute("cell.set", json!({"cell": "A2", "input": "1.234"})).unwrap(); + s.execute("automate.recordActions", json!({"stop": true, "name": "Typed"})).unwrap(); + assert_eq!((v(&s, "A1"), v(&s, "A2")), (Value::Number(1234.0), Value::Number(1.234))); + s.execute("app.region", json!({"region": "en-US"})).unwrap(); + s.execute("edit.clearAll", json!({"range": "A1:A2"})).unwrap(); + s.execute("automate.runScript", json!({"name": "Typed"})).unwrap(); + assert_eq!((v(&s, "A1"), v(&s, "A2")), (Value::Number(1234.0), Value::Number(1.234)), "same result in en-US"); + // Replaying the journal in a fresh session gives the same cells. + let mut r = self::s(); + for (id, p) in s.journal.iter().filter(|(id, _)| id == "cell.set") { + r.execute(id, p.clone()).unwrap(); + } + assert_eq!((v(&r, "A1"), v(&r, "A2")), (Value::Number(1234.0), Value::Number(1.234))); +} + +#[test] +fn canonical_producers_stay_canonical_in_a_decimal_comma_region() { + let mut s = s(); + s.execute("app.region", json!({"region": "de-DE"})).unwrap(); + // Replace builds its candidates from the stored (canonical) text. + s.execute("cell.set", json!({"cell": "A1", "value": 1.234})).unwrap(); + s.execute("edit.replace", json!({"what": "1", "with": "2", "matchCase": true, "all": true})).unwrap(); + assert_eq!(v(&s, "A1"), Value::Number(2.234)); + // Restoring a scenario writes the values it captured. + s.execute("data.scenarioAdd", json!({"name": "S", "changing": "A1"})).unwrap(); + s.execute("cell.set", json!({"cell": "A1", "value": 9})).unwrap(); + s.execute("data.scenarioShow", json!({"name": "S"})).unwrap(); + assert_eq!(v(&s, "A1"), Value::Number(2.234)); + // Circle Invalid Data agrees with validating the same number typed in the region. + s.execute("data.validation", json!({"range": "A1", "type": "decimal", "operator": "between", "formula1": "0", "formula2": "3"})).unwrap(); + assert_eq!(s.execute("data.validate", json!({"cell": "A1", "input": "2,234", "region": "de-DE"})).unwrap()["ok"], true); + assert_eq!(s.execute("data.circleInvalid", json!({})).unwrap()["invalid"], json!([])); + s.execute("cell.set", json!({"cell": "A1", "value": 7.5})).unwrap(); + assert_eq!(s.execute("data.validate", json!({"cell": "A1", "input": "7,5", "region": "de-DE"})).unwrap()["ok"], false); + assert_eq!(s.execute("data.circleInvalid", json!({})).unwrap()["invalid"], json!(["A1"])); +} + +#[test] +fn csv_files_stay_en_us_whatever_the_region() { + let reopen = |wb: &gridcraft_model::Workbook, name: &str| crate::io::open_bytes(name, &crate::io::save_bytes(wb, name).unwrap()).unwrap().0; + let mut s = s(); + s.execute("app.region", json!({"region": "de-DE"})).unwrap(); + s.execute("cell.set", json!({"cell": "A1", "value": 1000})).unwrap(); + s.execute("home.numberFormat", json!({"range": "A1", "code": "#,##0"})).unwrap(); + s.execute("cell.set", json!({"cell": "B1", "value": 1.5})).unwrap(); + s.execute("cell.set", json!({"cell": "C1", "value": 46290})).unwrap(); + s.execute("home.numberFormat", json!({"range": "C1", "code": "m/d/yyyy"})).unwrap(); + let wb = s.doc().unwrap().wb.clone(); + let bytes = crate::io::save_bytes(&wb, "book.csv").unwrap(); + assert_eq!(String::from_utf8(bytes).unwrap(), "\"1,000\",1.5,9/25/2026\r\n"); + let back = reopen(&wb, "book.csv"); + let sh = back.active().unwrap(); + assert_eq!(sh.value(CellRef::parse("A1").unwrap()), Value::Number(1000.0)); + assert_eq!(sh.value(CellRef::parse("B1").unwrap()), Value::Number(1.5)); + assert_eq!(sh.value(CellRef::parse("C1").unwrap()), Value::Number(46290.0)); + // The same in a day-first region; the screen keeps showing the region's way. + s.execute("app.region", json!({"region": "en-GB"})).unwrap(); + assert_eq!(text_of(&s, "C1"), "25/09/2026"); + let wb = s.doc().unwrap().wb.clone(); + assert!(String::from_utf8(crate::io::save_bytes(&wb, "book.csv").unwrap()).unwrap().ends_with("9/25/2026\r\n")); + assert_eq!(reopen(&wb, "book.csv").active().unwrap().value(CellRef::parse("C1").unwrap()), Value::Number(46290.0)); + // The clipboard text and HTML are what the screen shows (Excel copies the displayed text). + let d = s.doc().unwrap(); + assert!(crate::display::range_text(&d.wb, 0, gridcraft_core::RangeRef::parse("C1").unwrap()).starts_with("25/09/2026")); +} + +#[test] +fn get_data_from_a_range_keeps_the_numbers_in_a_decimal_comma_region() { + let mut s = s(); + s.execute("app.region", json!({"region": "de-DE"})).unwrap(); + s.execute("range.setValues", json!({"range": "A1", "values": [["n", "d"], [1234.5, 46290]]})).unwrap(); + s.execute("home.numberFormat", json!({"range": "A2", "code": "#,##0.00"})).unwrap(); + s.execute("home.numberFormat", json!({"range": "B2", "code": "m/d/yyyy"})).unwrap(); + assert_eq!(text_of(&s, "A2"), "1.234,50"); + s.execute("data.fromTableRange", json!({"range": "A1:B2"})).unwrap(); + assert_eq!(s.doc().unwrap().wb.sheets.len(), 2); + assert_eq!(v(&s, "A2"), Value::Number(1234.5)); + assert_eq!(v(&s, "B2"), Value::Number(46290.0)); +} + +/// F2 then Enter must keep the value, also where the editor falls back to the raw number. +#[test] +fn edit_text_falls_back_to_the_region_decimal_in_date_formats() { + let mut s = s(); + s.execute("app.region", json!({"region": "de-DE"})).unwrap(); + // A negative number is no date. + s.execute("cell.set", json!({"cell": "A1", "value": -1.234})).unwrap(); + s.execute("home.numberFormat", json!({"range": "A1", "code": "m/d/yyyy"})).unwrap(); + // A time with fractions of a second doesn't read back from `hh:mm:ss`. + s.execute("cell.set", json!({"cell": "A2", "value": 46290.123456789})).unwrap(); + s.execute("home.numberFormat", json!({"range": "A2", "code": "m/d/yyyy"})).unwrap(); + let edit = |s: &Session, a: &str| { + let d = s.doc().unwrap(); + crate::display::edit_text(&d.wb, d.wb.active().unwrap(), CellRef::parse(a).unwrap()) + }; + assert_eq!(edit(&s, "A1"), "-1,234"); + assert_eq!(edit(&s, "A2"), "46290,123456789"); + for (a, want) in [("A1", -1.234), ("A2", 46290.123456789)] { + let text = edit(&s, a); + s.execute("cell.set", json!({"cell": a, "input": text, "region": "de-DE"})).unwrap(); + assert_eq!(v(&s, a), Value::Number(want), "{a}"); + } +} diff --git a/crates/model/src/workbook.rs b/crates/model/src/workbook.rs index 828ca692e..5403658e9 100644 --- a/crates/model/src/workbook.rs +++ b/crates/model/src/workbook.rs @@ -120,6 +120,11 @@ pub struct Workbook { /// `preserved` are relative to. Saving into another folder rewrites them from there. Not in JSON. #[serde(skip)] pub source_dir: Option, + /// The region whose formats this workbook is typed and shown in (Options › Region). A runtime + /// setting of the application, not of the file: it is not saved, and the engine session sets + /// it on every document it holds. en-US until then. + #[serde(skip, default = "gridcraft_core::region::default_region")] + pub region: &'static gridcraft_core::Region, } impl Default for Workbook { @@ -148,6 +153,7 @@ impl Workbook { csv_delimiter: None, preserved: None, source_dir: None, + region: gridcraft_core::region::default_region(), } } pub fn sheet(&self, i: usize) -> Option<&Sheet> { diff --git a/crates/numfmt/src/lib.rs b/crates/numfmt/src/lib.rs index 6280e1d26..4ac9c5981 100644 --- a/crates/numfmt/src/lib.rs +++ b/crates/numfmt/src/lib.rs @@ -1,5 +1,10 @@ //! GridCraft number formats: Excel format codes (`#,##0.00;[Red](#,##0.00)`, dates, fractions, -//! scientific…), the General display format and the `TEXT()` worksheet function. en-US only. +//! scientific…), the General display format and the `TEXT()` worksheet function. +//! +//! Format codes are always the canonical en-US spelling. A [`Region`] changes only how a value +//! is *shown*: the decimal and group separators (with Indian grouping), month and day names, AM/PM +//! words, the system short date (built-in formats 14 and 22) and the system long date and time +//! (`[$-F800]`, `[$-F400]`). [`format_value`] is the en-US display. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] @@ -8,14 +13,16 @@ mod decimal; mod parse; mod render; -use std::sync::Arc; +use std::collections::HashMap; +use std::sync::{Arc, LazyLock, Mutex}; -use gridcraft_core::{CellError, DateSystem, Value}; +use gridcraft_core::region::EN_US; +use gridcraft_core::{CellError, DateSystem, Region, Value}; pub use builtin::{builtin_format, builtin_id}; pub use decimal::format_general_fit; -use parse::{SecKind, Section, Tok}; +use parse::{SecKind, Section, System, Tok}; use render::Out; /// Width (in characters) of General output in a cell. @@ -23,6 +30,36 @@ const GENERAL_WIDTH: usize = 11; /// What Excel paints when a date is out of range. const OVERFLOW: &str = "########"; +/// General text with the region's decimal separator. +pub(crate) fn general_fit_in(n: f64, width: usize, region: &Region) -> Option { + let s = format_general_fit(n, width)?; + Some(if region.decimal == '.' { s } else { s.replacen('.', ®ion.decimal.to_string(), 1) }) +} + +/// [`format_general_fit`] with the region's decimal separator. +pub fn format_general_fit_in(n: f64, width: usize, region: &Region) -> Option { + general_fit_in(n, width, region) +} + +/// Region-defined format codes (`dd/mm/yyyy`, the long date…), parsed once. +fn region_code(code: &'static str) -> NumberFormat { + static CACHE: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); + let mut m = CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + m.entry(code).or_insert_with(|| NumberFormat::parse(code)).clone() +} + +/// The code a region shows built-in formats 14 (`m/d/yyyy`) and 22 (`m/d/yyyy h:mm`) with: the +/// system short date, as Excel does. `None` when `code` isn't one of them or is already what the +/// region shows. +fn system_short_code(code: &str, region: &Region) -> Option<&'static str> { + let target = match code { + "m/d/yyyy" => region.short_date, + "m/d/yyyy h:mm" => region.datetime, + _ => return None, + }; + (target != code).then_some(target) +} + /// Colour from a `[Red]` or `[ColorN]` section tag. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum FormatColor { @@ -256,9 +293,12 @@ impl NumberFormat { } /// Formats a number. The flag is true when the value cannot be shown (date out of range). - fn format_number(&self, n: f64, sys: DateSystem) -> (Formatted, bool) { + fn format_number(&self, n: f64, sys: DateSystem, region: &Region) -> (Formatted, bool) { + if let Some(code) = system_short_code(&self.inner.code, region) { + return region_code(code).format_number(n, sys, region); + } let general = |n: f64| Formatted { - text: format_general_fit(n, GENERAL_WIDTH).unwrap_or_else(|| OVERFLOW.into()), + text: general_fit_in(n, GENERAL_WIDTH, region).unwrap_or_else(|| OVERFLOW.into()), color: None, fill: None, numeric: true, @@ -269,19 +309,27 @@ impl NumberFormat { let Some((sec, drop_sign)) = self.select(n) else { return (general(n), false) }; let minus = n < 0.0 && !drop_sign; let out: Option = match sec.kind { - SecKind::Text => Some(render::render_text(sec, &format_general_fit(n, GENERAL_WIDTH).unwrap_or_default())), + SecKind::Text => Some(render::render_text(sec, &general_fit_in(n, GENERAL_WIDTH, region).unwrap_or_default())), SecKind::General => { let width = if minus { GENERAL_WIDTH - 1 } else { GENERAL_WIDTH }; - Some(render::render_general(sec, n.abs(), width)) + Some(render::render_general(sec, n.abs(), width, region)) } SecKind::Date => { if minus { None + } else if let Some(system) = sec.system { + // The tokens after `[$-F800]` are only a fallback: the region decides. + let code = if system == System::LongDate { region.long_date } else { region.long_time }; + let own = region_code(code); + match own.inner.sections.first() { + Some(s) if s.kind == SecKind::Date => render::render_date(&s.toks, n.abs(), sys, region), + _ => render::render_date(&sec.toks, n.abs(), sys, region), + } } else { - render::render_date(sec, n.abs(), sys) + render::render_date(&sec.toks, n.abs(), sys, region) } } - SecKind::Number => Some(render::render_number(sec, n.abs())), + SecKind::Number => Some(render::render_number(sec, n.abs(), region)), }; let Some(mut out) = out else { return (Formatted { text: OVERFLOW.into(), color: sec.color, fill: None, numeric: true }, true); @@ -304,18 +352,24 @@ impl NumberFormat { } } -/// Formats a cell value for display. +/// Formats a cell value for display the en-US way. pub fn format_value(v: &Value, fmt: &NumberFormat, sys: DateSystem) -> Formatted { + format_value_in(v, fmt, sys, &EN_US) +} + +/// Formats a cell value for display in `region`: its separators, month and day names and AM/PM, +/// and the system dates for built-in formats 14 and 22 and `[$-F800]` / `[$-F400]`. +pub fn format_value_in(v: &Value, fmt: &NumberFormat, sys: DateSystem, region: &Region) -> Formatted { match v { Value::Empty => Formatted::default(), Value::Number(n) if !n.is_finite() => Formatted { text: CellError::Num.as_str().into(), ..Formatted::default() }, - Value::Number(n) => fmt.format_number(*n, sys).0, + Value::Number(n) => fmt.format_number(*n, sys, region).0, Value::Text(t) => fmt.format_text(t), Value::Bool(b) => Formatted { text: if *b { "TRUE" } else { "FALSE" }.into(), ..Formatted::default() }, Value::Error(e) => Formatted { text: e.as_str().into(), ..Formatted::default() }, Value::Array(a) => match a.data.first() { Some(Value::Array(_)) | None => Formatted::default(), - Some(first) => format_value(first, fmt, sys), + Some(first) => format_value_in(first, fmt, sys, region), }, } } @@ -417,7 +471,7 @@ pub fn text_function(v: &Value, code: &str, sys: DateSystem) -> Result, } /// Splits a code into sections at unquoted, unescaped, unbracketed semicolons. @@ -251,11 +260,12 @@ struct Raw { color: Option, cond: Option, currency: bool, + system: Option, } fn tokenize(src: &str) -> Raw { let cs: Vec = src.chars().collect(); - let mut raw = Raw { toks: Vec::new(), color: None, cond: None, currency: false }; + let mut raw = Raw { toks: Vec::new(), color: None, cond: None, currency: false, system: None }; let toks = &mut raw.toks; // With no `]` at or after a `[`, that `[` is a literal; checking this once keeps the scan linear. let last_close = cs.iter().rposition(|&c| c == ']'); @@ -306,7 +316,7 @@ fn tokenize(src: &str) -> Raw { let close = if last_close.is_some_and(|l| l > i) { cs.get(i + 1..).and_then(|r| r.iter().position(|&d| d == ']')) } else { None }; if let Some(p) = close { let content: String = cs.get(i + 1..i + 1 + p).unwrap_or(&[]).iter().collect(); - bracket(&mut raw_parts(toks, &mut raw.color, &mut raw.cond, &mut raw.currency), &content); + bracket(&mut raw_parts(toks, &mut raw.color, &mut raw.cond, &mut raw.currency, &mut raw.system), &content); i += p + 2; } else { push_lit(toks, "["); @@ -391,10 +401,17 @@ struct RawParts<'a> { color: &'a mut Option, cond: &'a mut Option, currency: &'a mut bool, + system: &'a mut Option, } -fn raw_parts<'a>(toks: &'a mut Vec, color: &'a mut Option, cond: &'a mut Option, currency: &'a mut bool) -> RawParts<'a> { - RawParts { toks, color, cond, currency } +fn raw_parts<'a>( + toks: &'a mut Vec, + color: &'a mut Option, + cond: &'a mut Option, + currency: &'a mut bool, + system: &'a mut Option, +) -> RawParts<'a> { + RawParts { toks, color, cond, currency, system } } /// Handles the content of a `[...]` tag. @@ -410,6 +427,18 @@ fn bracket(p: &mut RawParts<'_>, content: &str) { } return; } + // The system long date and time: `[$-F800]`, `[$-F400]`, or the newer `[$-x-sysdate]`. + match lc.as_str() { + "$-f800" | "$-x-sysdate" => { + *p.system = Some(System::LongDate); + return; + } + "$-f400" | "$-x-systime" => { + *p.system = Some(System::LongTime); + return; + } + _ => {} + } if let Some(body) = content.strip_prefix('$') { let sym = body.split('-').next().unwrap_or(""); if !sym.is_empty() { @@ -438,8 +467,8 @@ fn date_part(t: &Tok) -> Option { } fn analyze(raw: Raw) -> Section { - let Raw { mut toks, color, cond, currency } = raw; - let is_date = toks.iter().any(|t| matches!(t, Tok::Date(_))); + let Raw { mut toks, color, cond, currency, system } = raw; + let is_date = system.is_some() || toks.iter().any(|t| matches!(t, Tok::Date(_))); let has_general = toks.iter().any(|t| matches!(t, Tok::General)); let mut sec = Section { toks: Vec::new(), @@ -455,6 +484,7 @@ fn analyze(raw: Raw) -> Section { fraction: false, currency, fill: toks.iter().any(|t| matches!(t, Tok::Fill(_))), + system, }; if is_date { sec.kind = SecKind::Date; diff --git a/crates/numfmt/src/render.rs b/crates/numfmt/src/render.rs index 4408d383f..15e4be0c6 100644 --- a/crates/numfmt/src/render.rs +++ b/crates/numfmt/src/render.rs @@ -1,9 +1,10 @@ //! Rendering a value with a parsed section. use gridcraft_core::DateSystem; -use gridcraft_core::date::{MONTHS, WEEKDAYS, datetime_from_serial, max_serial}; +use gridcraft_core::date::{datetime_from_serial, max_serial}; +use gridcraft_core::region::{Grouping, Region}; -use crate::decimal::{decimal_digits, fixed, fixed_from_digits, format_general_fit}; +use crate::decimal::{decimal_digits, fixed, fixed_from_digits}; use crate::parse::{DatePart, Section, Tok}; /// Text being built plus the first repeat-fill position. @@ -46,11 +47,11 @@ pub(crate) fn render_text(sec: &Section, text: &str) -> Out { } /// Renders a number through a General section (literals around the General keyword). -pub(crate) fn render_general(sec: &Section, v: f64, width: usize) -> Out { +pub(crate) fn render_general(sec: &Section, v: f64, width: usize, region: &Region) -> Out { let mut out = Out::default(); for t in &sec.toks { match t { - Tok::General => out.push(&format_general_fit(v, width).unwrap_or_else(|| "#".repeat(width.clamp(1, 11)))), + Tok::General => out.push(&crate::general_fit_in(v, width, region).unwrap_or_else(|| "#".repeat(width.clamp(1, 11)))), Tok::Fill(c) => out.fill(*c), other => out.push(&other.literal_text()), } @@ -58,12 +59,13 @@ pub(crate) fn render_general(sec: &Section, v: f64, width: usize) -> Out { out } -/// Renders a date/time. `None` when the serial is outside the supported range. -pub(crate) fn render_date(sec: &Section, v: f64, sys: DateSystem) -> Option { +/// Renders a date/time from the section's tokens (or the region's, for `[$-F800]`). Month and day +/// names and AM/PM are the region's. `None` when the serial is outside the supported range. +pub(crate) fn render_date(toks: &[Tok], v: f64, sys: DateSystem, region: &Region) -> Option { if !v.is_finite() || !(0.0..max_serial(sys) as f64 + 1.0).contains(&v) { return None; } - let sub_digits = sec.toks.iter().filter_map(|t| if let Tok::Date(DatePart::SubSec(n)) = t { Some(*n) } else { None }).max().unwrap_or(0).min(3); + let sub_digits = toks.iter().filter_map(|t| if let Tok::Date(DatePart::SubSec(n)) = t { Some(*n) } else { None }).max().unwrap_or(0).min(3); let unit: i64 = 10i64.pow(3 - sub_digits as u32); let mut day = v.floor() as i64; let mut ms = (((v - v.floor()) * 86_400_000.0) / unit as f64).round() as i64 * unit; @@ -72,18 +74,24 @@ pub(crate) fn render_date(sec: &Section, v: f64, sys: DateSystem) -> Option day += 1; } let total_ms = day.saturating_mul(86_400_000).saturating_add(ms); - let needs_date = sec.toks.iter().any(|t| matches!(t, Tok::Date(d) if d.is_calendar())); + let needs_date = toks.iter().any(|t| matches!(t, Tok::Date(d) if d.is_calendar())); let dt = if needs_date { datetime_from_serial(sys, day as f64)? } else { datetime_from_serial(sys, 0.0).unwrap_or_default() }; - let ampm = sec.toks.iter().any(|t| matches!(t, Tok::Date(DatePart::AmPm | DatePart::AP(..)))); + let ampm = toks.iter().any(|t| matches!(t, Tok::Date(DatePart::AmPm | DatePart::AP(..)))); + // A month name next to a day is the form dates use (Russian `1 января`). + let with_day = toks.iter().any(|t| matches!(t, Tok::Date(DatePart::Day(n)) if *n <= 2)); let hour = ms / 3_600_000; let minute = (ms / 60_000) % 60; let second = (ms / 1000) % 60; let milli = ms % 1000; let pad = |n: i64, w: u8| if w >= 2 { format!("{:0w$}", n, w = w.min(20) as usize) } else { n.to_string() }; - let month_name = MONTHS.get((dt.month as usize).wrapping_sub(1)).copied().unwrap_or(""); - let day_name = WEEKDAYS.get(dt.weekday as usize).copied().unwrap_or(""); + let month_idx = (dt.month as usize).wrapping_sub(1); + let month_full = region.month_name(dt.month, with_day); + let month_abbr = region.months_abbr.get(month_idx).copied().unwrap_or(""); + let month_initial = region.months.get(month_idx).and_then(|m| m.chars().next()).map(String::from).unwrap_or_default(); + let day_full = region.weekdays.get(dt.weekday as usize).copied().unwrap_or(""); + let day_abbr = region.weekdays_abbr.get(dt.weekday as usize).copied().unwrap_or(""); let mut out = Out::default(); - for t in &sec.toks { + for t in toks { match t { Tok::Date(d) => { let s = match *d { @@ -96,14 +104,14 @@ pub(crate) fn render_date(sec: &Section, v: f64, sys: DateSystem) -> Option } DatePart::Month(n) => match n { 1 | 2 => pad(dt.month as i64, n), - 3 => month_name.chars().take(3).collect(), - 5 => month_name.chars().take(1).collect(), - _ => month_name.to_string(), + 3 => month_abbr.to_string(), + 5 => month_initial.clone(), + _ => month_full.to_string(), }, DatePart::Day(n) => match n { 1 | 2 => pad(dt.day as i64, n), - 3 => day_name.chars().take(3).collect(), - _ => day_name.to_string(), + 3 => day_abbr.to_string(), + _ => day_full.to_string(), }, DatePart::Hour(n) => { let h = if ampm { @@ -127,7 +135,7 @@ pub(crate) fn render_date(sec: &Section, v: f64, sys: DateSystem) -> Option DatePart::ElapsedH(n) => pad(total_ms / 3_600_000, n), DatePart::ElapsedM(n) => pad(total_ms / 60_000, n), DatePart::ElapsedS(n) => pad(total_ms / 1000, n), - DatePart::AmPm => (if hour >= 12 { "PM" } else { "AM" }).to_string(), + DatePart::AmPm => region.am_pm.get(usize::from(hour >= 12)).copied().unwrap_or("").to_string(), DatePart::AP(a, p) => (if hour >= 12 { p } else { a }).to_string(), }; out.push(&s); @@ -139,10 +147,19 @@ pub(crate) fn render_date(sec: &Section, v: f64, sys: DateSystem) -> Option Some(out) } +/// True when a group separator goes before a digit that has `right` digits to its right. +fn group_before(right: usize, grouping: Grouping) -> bool { + match grouping { + Grouping::Thousands => right > 0 && right.is_multiple_of(3), + // 12,34,567: three digits, then groups of two. + Grouping::Indian => right >= 3 && (right - 3).is_multiple_of(2), + } +} + /// Assigns integer digits right-to-left to the placeholders at `idxs`; the leftmost placeholder /// takes all remaining digits. `force_zero` shows `0` in the units placeholder when there are no /// digits. A blank `?` pads with a space, and so does the thousands separator it would carry. -fn fill_int(toks: &[Tok], idxs: &[usize], digits: &str, thousands: bool, force_zero: bool, pieces: &mut [String]) { +fn fill_int(toks: &[Tok], idxs: &[usize], digits: &str, thousands: bool, force_zero: bool, region: &Region, pieces: &mut [String]) { let ds: Vec = digits.chars().collect(); let mut rem = ds.len(); let mut pos = 0usize; @@ -155,8 +172,8 @@ fn fill_int(toks: &[Tok], idxs: &[usize], digits: &str, thousands: bool, force_z }; let mut rev = String::new(); let push_digit = |c: char, rev: &mut String, pos: &mut usize| { - if thousands && *pos > 0 && pos.is_multiple_of(3) { - rev.push(','); + if thousands && group_before(*pos, region.grouping) { + rev.push(region.group); } rev.push(c); *pos += 1; @@ -187,15 +204,15 @@ fn fill_int(toks: &[Tok], idxs: &[usize], digits: &str, thousands: bool, force_z } } -fn with_commas(digits: &str, thousands: bool) -> String { +fn with_commas(digits: &str, thousands: bool, region: &Region) -> String { if !thousands { return digits.to_string(); } let n = digits.chars().count(); let mut s = String::with_capacity(n + n / 3); for (i, c) in digits.chars().enumerate() { - if i > 0 && (n - i).is_multiple_of(3) { - s.push(','); + if i > 0 && group_before(n - i, region.grouping) { + s.push(region.group); } s.push(c); } @@ -231,7 +248,7 @@ fn digit_indices(toks: &[Tok], range: std::ops::Range) -> Vec { } /// Renders a non-negative number through a number section. -pub(crate) fn render_number(sec: &Section, v: f64) -> Out { +pub(crate) fn render_number(sec: &Section, v: f64, region: &Region) -> Out { let mut x = v.abs(); // Observed: `%` in a scientific section is only a literal (`#%e+#` shows 123456.789 as // `1%e+5`). @@ -254,7 +271,7 @@ pub(crate) fn render_number(sec: &Section, v: f64) -> Out { let mut zero = false; if sec.fraction { lits = vec![None; n]; - render_fraction(sec, x, &mut pieces, &mut lits); + render_fraction(sec, x, region, &mut pieces, &mut lits); } else { let point = toks.iter().position(|t| matches!(t, Tok::Point)); let exp = toks.iter().position(|t| matches!(t, Tok::Exp { .. })); @@ -309,7 +326,7 @@ pub(crate) fn render_number(sec: &Section, v: f64) -> Out { if let Some(b) = before.get_mut(first) { *b = sign.to_string(); } - fill_int(toks, &exp_idx, &digits, false, false, &mut pieces); + fill_int(toks, &exp_idx, &digits, false, false, region, &mut pieces); } None => { if let Some(p) = pieces.get_mut(e_at) { @@ -322,19 +339,19 @@ pub(crate) fn render_number(sec: &Section, v: f64) -> Out { fixed(x, frac_idx.len()) }; zero = int_s.chars().chain(frac_s.chars()).all(|c| c == '0'); - fill_int(toks, &int_idx, &int_s, sec.thousands, false, &mut pieces); + fill_int(toks, &int_idx, &int_s, sec.thousands, false, region, &mut pieces); if int_idx.is_empty() && !int_s.is_empty() && let Some(p) = point.or(exp) && let Some(b) = before.get_mut(p) { - *b = with_commas(&int_s, sec.thousands); + *b = with_commas(&int_s, sec.thousands, region); } fill_frac(toks, &frac_idx, &frac_s, &mut pieces); if let Some(p) = point && let Some(s) = pieces.get_mut(p) { - *s = ".".into(); + *s = region.decimal.to_string(); } } let mut out = Out::default(); @@ -432,7 +449,7 @@ fn blank_range(toks: &[Tok], range: std::ops::Range, spaces: bool, pieces /// becomes spaces when the whole part or the numerator has a `?`); when the fraction is zero and /// the numerator has no `0` placeholder, the separator, numerator, bar and denominator disappear /// (or become spaces when any part has a `?`) and the whole part shows at least `0`. -fn render_fraction(sec: &Section, x: f64, pieces: &mut [String], lits: &mut [Option]) { +fn render_fraction(sec: &Section, x: f64, region: &Region, pieces: &mut [String], lits: &mut [Option]) { let toks = &sec.toks; let is_digit = |i: usize| matches!(toks.get(i), Some(Tok::Digit(_))); let Some(slash) = toks.iter().position(|t| matches!(t, Tok::Slash)) else { return }; @@ -480,7 +497,7 @@ fn render_fraction(sec: &Section, x: f64, pieces: &mut [String], lits: &mut [Opt let whole_s = if whole >= 1.0 { fixed(whole, 0).0 } else { String::new() }; let num_zero_ph = num_idx.iter().any(|&i| toks.get(i) == Some(&Tok::Digit('0'))); if has_whole && num == 0 && !num_zero_ph { - fill_int(toks, &whole_idx, &whole_s, sec.thousands, true, pieces); + fill_int(toks, &whole_idx, &whole_s, sec.thousands, true, region, pieces); let spaces = has_q(&whole_idx) || has_q(&num_idx) || has_q(&den_idx); blank_range(toks, sep_start..den_end, spaces, pieces, lits); return; @@ -489,14 +506,14 @@ fn render_fraction(sec: &Section, x: f64, pieces: &mut [String], lits: &mut [Opt // A zero value shows `0` in a `?` or `0` units place, nothing in a `#` one. let units_hash = whole_idx.last().is_some_and(|&i| toks.get(i) == Some(&Tok::Digit('#'))); let force = whole_s.is_empty() && num == 0 && !units_hash; - fill_int(toks, &whole_idx, &whole_s, sec.thousands, force, pieces); + fill_int(toks, &whole_idx, &whole_s, sec.thousands, force, region, pieces); let whole_shown = whole_idx.iter().any(|&i| pieces.get(i).is_some_and(|p| p.chars().any(|c| c.is_ascii_digit()))); if !whole_shown { let spaces = has_q(&whole_idx) || has_q(&num_idx); blank_range(toks, sep_start..num_start, spaces, pieces, lits); } } - fill_int(toks, &num_idx, &num.to_string(), false, true, pieces); + fill_int(toks, &num_idx, &num.to_string(), false, true, region, pieces); if let Some(p) = pieces.get_mut(slash) { *p = "/".into(); } diff --git a/crates/numfmt/src/tests.rs b/crates/numfmt/src/tests.rs index e5687f5bd..33c3cf42a 100644 --- a/crates/numfmt/src/tests.rs +++ b/crates/numfmt/src/tests.rs @@ -637,7 +637,7 @@ fn text_uncached(v: &Value, code: &str, sys: DateSystem) -> Result String { + format_value_in(&Value::Number(n), &NumberFormat::parse(code), S, gridcraft_core::region::find(region).unwrap()).text +} + +#[test] +fn regional_separators() { + assert_eq!(fin("en-US", "#,##0.00", 1234567.891), "1,234,567.89"); + assert_eq!(fin("pt-BR", "#,##0.00", 1234567.891), "1.234.567,89"); + assert_eq!(fin("de-DE", "#,##0.00", -1234.5), "-1.234,50"); + assert_eq!(fin("fr-FR", "#,##0.00", 1234567.891), "1\u{a0}234\u{a0}567,89"); + assert_eq!(fin("ru-RU", "#,##0", 1234567.0), "1\u{a0}234\u{a0}567"); + assert_eq!(fin("de-DE", "0.00", 3.14259), "3,14"); + assert_eq!(fin("de-DE", "0.0E+00", 12345.0), "1,2E+04"); + assert_eq!(fin("de-DE", "#,##0.00\\ \"€\"", 1234.5), "1.234,50 €"); + assert_eq!(fin("de-DE", "0.00%", 0.1234), "12,34%"); + assert_eq!(fin("de-DE", "#,##0,", 1234567.0), "1.235"); + assert_eq!(fin("de-DE", "0.0,\"x\"", 5000.0), "5,0x"); +} + +#[test] +fn indian_grouping_displays_three_then_two() { + assert_eq!(fin("en-IN", "#,##0.00", 1234567.89), "12,34,567.89"); + assert_eq!(fin("en-IN", "#,##0", 123456789.0), "12,34,56,789"); + assert_eq!(fin("en-IN", "#,##0", 999.0), "999"); + assert_eq!(fin("en-IN", "#,##0", 1000.0), "1,000"); + assert_eq!(fin("en-IN", "#,##0", 100000.0), "1,00,000"); + assert_eq!(fin("en-IN", "#,##0", -12345678.0), "-1,23,45,678"); + assert_eq!(fin("en-IN", "#,###", 1234567.0), "12,34,567"); + assert_eq!(fin("en-GB", "#,##0", 1234567.0), "1,234,567"); +} + +#[test] +fn general_uses_the_regional_decimal() { + assert_eq!(fin("de-DE", "General", 1.5), "1,5"); + assert_eq!(fin("pt-BR", "General", -0.25), "-0,25"); + assert_eq!(fin("en-GB", "General", 1.5), "1.5"); + assert_eq!(fin("de-DE", "General", 1234567.0), "1234567"); + assert_eq!(fin("de-DE", "\"x\"General", 2.5), "x2,5"); + assert_eq!(format_general_fit_in(1.25, 11, gridcraft_core::region::find("fr-FR").unwrap()).as_deref(), Some("1,25")); + assert_eq!(fin("de-DE", "@", 2.5), "2,5"); +} + +#[test] +fn month_and_day_names_and_am_pm_in_the_region() { + // 25 September 2026 is a Friday. + assert_eq!(fin("en-US", "dddd mmmm", 46290.0), "Friday September"); + assert_eq!(fin("de-DE", "dddd, mmmm yyyy", 46290.0), "Freitag, September 2026"); + assert_eq!(fin("de-DE", "ddd mmm", 46290.0), "Fr Sep"); + assert_eq!(fin("fr-FR", "dddd d mmmm yyyy", 46290.0), "vendredi 25 septembre 2026"); + assert_eq!(fin("fr-FR", "mmm", 46290.0), "sept."); + assert_eq!(fin("es-ES", "dddd", 46290.0), "viernes"); + assert_eq!(fin("pt-BR", "mmmm", 46250.0), "agosto"); + assert_eq!(fin("ja-JP", "mmmm dddd", 46290.0), "9月 金曜日"); + assert_eq!(fin("zh-CN", "mmmm", 46290.0), "九月"); + assert_eq!(fin("zh-CN", "mmmmm", 46290.0), "九"); + assert_eq!(fin("en-GB", "mmm", 46290.0), "Sept"); + // The month with a day is the genitive form where there is one. + assert_eq!(fin("ru-RU", "mmmm", 46290.0), "сентябрь"); + assert_eq!(fin("ru-RU", "d mmmm yyyy", 46290.0), "25 сентября 2026"); + assert_eq!(fin("cs-CZ", "d. mmmm yyyy", 46290.0), "25. září 2026"); + assert_eq!(fin("cs-CZ", "mmmm", 46250.0), "srpen"); + assert_eq!(fin("en-US", "h:mm AM/PM", 46290.75), "6:00 PM"); + assert_eq!(fin("ja-JP", "h:mm AM/PM", 46290.75), "6:00 午後"); + assert_eq!(fin("ko-KR", "h:mm AM/PM", 46290.25), "6:00 오전"); + assert_eq!(fin("es-ES", "h:mm AM/PM", 46290.75), "6:00 p. m."); + // A/P keeps the letters of the code. + assert_eq!(fin("ja-JP", "h:mm A/P", 46290.75), "6:00 P"); +} + +#[test] +fn built_in_formats_14_and_22_show_the_regions_short_date() { + // 25 September 2026 18:00. + let v = 46290.75; + assert_eq!(fin("en-US", "m/d/yyyy", v), "9/25/2026"); + assert_eq!(fin("en-US", "m/d/yyyy h:mm", v), "9/25/2026 18:00"); + assert_eq!(fin("en-GB", "m/d/yyyy", v), "25/09/2026"); + assert_eq!(fin("en-GB", "m/d/yyyy h:mm", v), "25/09/2026 18:00"); + assert_eq!(fin("en-IN", "m/d/yyyy", v), "25/09/2026"); + assert_eq!(fin("de-DE", "m/d/yyyy", v), "25.09.2026"); + assert_eq!(fin("de-DE", "m/d/yyyy h:mm", v), "25.09.2026 18:00"); + assert_eq!(fin("pt-BR", "m/d/yyyy", v), "25/09/2026"); + assert_eq!(fin("ru-RU", "m/d/yyyy", v), "25.09.2026"); + assert_eq!(fin("cs-CZ", "m/d/yyyy", v), "25.09.2026"); + assert_eq!(fin("ja-JP", "m/d/yyyy", v), "2026/9/25"); + assert_eq!(fin("ko-KR", "m/d/yyyy", v), "2026-09-25"); + assert_eq!(fin("zh-TW", "m/d/yyyy h:mm", v), "2026/9/25 18:00"); + // Other date codes are written out as they are. + assert_eq!(fin("de-DE", "d-mmm-yy", v), "25-Sep-26"); + assert_eq!(fin("en-GB", "yyyy-mm-dd", v), "2026-09-25"); + assert_eq!(fin("de-DE", "[$-409]m/d/yyyy", v), "9/25/2026"); + assert_eq!(fin("de-DE", "m/d/yyyy", 0.5), "00.01.1900"); + assert_eq!(fin("de-DE", "m/d/yyyy", -1.0), "########"); +} + +#[test] +fn system_long_date_and_time_follow_the_region() { + let v = 46290.75; + // The code ribbon's Long Date and Time write. + let long = "[$-F800]dddd, mmmm dd, yyyy"; + let time = "[$-F400]h:mm:ss AM/PM"; + assert_eq!(fin("en-US", long, v), "Friday, September 25, 2026"); + assert_eq!(fin("en-US", time, v), "6:00:00 PM"); + assert_eq!(fin("en-GB", long, v), "Friday, 25 September 2026"); + assert_eq!(fin("en-GB", time, v), "18:00:00"); + assert_eq!(fin("de-DE", long, v), "Freitag, 25. September 2026"); + assert_eq!(fin("de-DE", time, v), "18:00:00"); + assert_eq!(fin("pt-BR", long, v), "sexta-feira, 25 de setembro de 2026"); + assert_eq!(fin("es-ES", long, v), "viernes, 25 de septiembre de 2026"); + assert_eq!(fin("fr-FR", long, v), "vendredi 25 septembre 2026"); + assert_eq!(fin("ru-RU", long, v), "25 сентября 2026 г."); + assert_eq!(fin("ja-JP", long, v), "2026年9月25日"); + assert_eq!(fin("ko-KR", long, v), "2026년 9월 25일 금요일"); + assert_eq!(fin("ko-KR", time, v), "오후 6:00:00"); + // As Excel writes it into files, and the newer tags. + assert_eq!(fin("de-DE", "[$-F800]dddd\\,\\ mmmm\\ dd\\,\\ yyyy", v), "Freitag, 25. September 2026"); + assert_eq!(fin("de-DE", "[$-x-sysdate]dddd, mmmm dd, yyyy", v), "Freitag, 25. September 2026"); + assert_eq!(fin("de-DE", "[$-x-systime]h:mm:ss AM/PM", v), "18:00:00"); + assert!(NumberFormat::parse(long).is_date()); + assert_eq!(NumberFormat::parse(long).kind(), FormatKind::Date); + // Without a region the tag shows what the code says. + assert_eq!(f(long, v), "Friday, September 25, 2026"); +} + +#[test] +fn en_us_region_matches_the_unqualified_functions() { + for (code, n) in [ + ("#,##0.00", 1234.5), + ("General", 0.1), + ("m/d/yyyy h:mm", 46290.75), + ("mmm d", 46290.0), + ("[$-F800]dddd, mmmm dd, yyyy", 46290.0), + ("h:mm AM/PM", 0.75), + ] { + assert_eq!(fin("en-US", code, n), f(code, n), "{code}"); + } +} diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index 922baabc5..0c3f1d969 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -955,6 +955,18 @@ fn options(app: &mut SheetApp, ui: &mut Ui) { } }); ui.end_row(); + // Regional format: how numbers, dates and times are typed and shown. It is independent of + // the interface language, as in Excel. + ui.label(lang.tr("Regional format")); + let region = app.session.region(); + egui::ComboBox::from_id_salt("backstage_region").width(220.0).selected_text(region.name).show_ui(ui, |ui| { + for r in gridcraft_engine::core::region::all() { + if ui.selectable_label(r.tag == region.tag, r.name).clicked() { + app.run_or_alert("app.region", json!({"region": r.tag})); + } + } + }); + ui.end_row(); }); ui.add_space(6.0); let mut formula_bar = app.session.prefs.formula_bar; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index fb899c702..185f7d610 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1257,7 +1257,13 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { confirm = false; } if confirm && let Some(cmd) = d.command { - let params = build_params(&d); + let mut params = build_params(&d); + // The forecast end is a date or number the user typed, in the workbook's region. + if cmd == "data.forecastSheet" + && let Some(map) = params.as_object_mut() + { + map.insert("region".into(), json!(app.session.region().tag)); + } match app.run(cmd, params) { Ok(r) => { open = false; @@ -2725,8 +2731,8 @@ mod tests { app.run(d.command.unwrap(), build_params(&d)).unwrap(); let doc = app.session.active().unwrap(); let at = gridcraft_engine::core::CellRef::new(0, 0); - assert!(gridcraft_engine::cmd::data::check_validation(&doc.wb, 0, at, "6").is_none()); - assert!(gridcraft_engine::cmd::data::check_validation(&doc.wb, 0, at, "4").is_some()); + assert!(gridcraft_engine::cmd::data::check_validation(&doc.wb, 0, at, "6", doc.wb.region).is_none()); + assert!(gridcraft_engine::cmd::data::check_validation(&doc.wb, 0, at, "4", doc.wb.region).is_some()); } #[test] diff --git a/crates/ui-egui/src/formula_locale.rs b/crates/ui-egui/src/formula_locale.rs index 6e833b95d..2219863c4 100644 --- a/crates/ui-egui/src/formula_locale.rs +++ b/crates/ui-egui/src/formula_locale.rs @@ -20,11 +20,10 @@ pub(crate) fn canonical( if locale == FormulaLocale::En || wb.sheet(sheet).is_some_and(|sh| wb.styles.get(sh.style_id(cell)).num_fmt.as_str() == "@") { return Ok(text.to_string()); } - // Same formula detection as the engine's `input_to_cell` (no trimming: " =1" is text). + // The engine's own formula detection (no trimming: " =1" is text), which also lets a number + // the region reads, such as `-R$ 5` in pt-BR, through as a constant. let body = text; - if body.starts_with('=') - || ((body.starts_with('+') || body.starts_with('-')) && body.chars().nth(1).is_some_and(|c| c.is_ascii_alphabetic() || c == '(')) - { + if gridcraft_engine::cmd::edit::is_formula_input(body, wb.date_system, wb.region) { let known = |name: &str| wb.name(name, sheet).is_some() || wb.table(name).is_some(); // Match the engine's existing convenience for missing closing parentheses. let mut body = body.to_string(); @@ -38,12 +37,8 @@ pub(crate) fn canonical( } return Ok(format!("={}", formula::print(&expr?))); } - // Decimal-comma number entry follows the same preference, without rewriting text or dates. - let normalized = body.trim().replace(locale.decimal_separator(), "."); - let number = normalized.strip_suffix('%').unwrap_or(&normalized); - if number.parse::().is_ok_and(f64::is_finite) { - return Ok(normalized); - } + // Typed constants (numbers, dates, text) pass through: the engine reads them in the workbook's + // region (Options › Regional format), which is independent of the formula language. Ok(text.to_string()) } diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 97b787ef3..b03f99fd1 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -543,7 +543,7 @@ fn display_text( && let Value::Number(n) = v { let max_chars = ((width_px - 4.0) / char_w).floor().max(1.0) as usize; - return match gridcraft_engine::numfmt::format_general_fit(*n, max_chars.min(11)) { + return match gridcraft_engine::numfmt::format_general_fit_in(*n, max_chars.min(11), wb.region) { Some(t) => (t, None, true, None), None => ("#".repeat(max_chars.max(1)), None, true, None), }; diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 061698df9..8989ca06e 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -134,6 +134,16 @@ impl Language { } } +/// The region the operating system (or the browser) is set to, best effort; en-US when it is +/// unknown. Used as the first-run default of the regional format while nothing is saved. +pub fn system_region() -> &'static gridcraft_engine::core::Region { + #[cfg(not(target_arch = "wasm32"))] + let tag = sys_locale::get_locale(); + #[cfg(target_arch = "wasm32")] + let tag = web_sys::window().and_then(|window| window.navigator().language()); + tag.map_or_else(gridcraft_engine::core::region::default_region, |tag| gridcraft_engine::core::region::lookup(&tag)) +} + /// Deserializes a saved language leniently: an unknown or malformed value (a language removed in a /// later version, a hand-edited `ui.json`) reads as English instead of failing the whole `UiState`, /// which would reset every other preference with it. @@ -404,6 +414,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Light", "ライト"), ("Dark", "ダーク"), ("Interface language", "表示言語"), + ("Regional format", "地域の形式"), ("Interface size", "表示サイズ"), ("Auto", "自動"), ("Drag to freeze columns", "ドラッグして列を固定"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 7ed8372c7..cdfe1cfc6 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -337,6 +337,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Light" => "Světlý", "Dark" => "Tmavý", "Interface language" => "Jazyk rozhraní", + "Regional format" => "Regionální formát", "Command\nPalette" => "Paleta\npříkazů", "Agent\nControl" => "Řízení\nagenty", "Action\nJournal" => "Deník\nakcí", diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index c20c7d1b3..6ac770cc3 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -335,6 +335,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Unfreeze Panes" => "Fixierung aufheben", "Dark Mode" => "Dunkler Modus", "Interface language" => "Anzeigesprache", + "Regional format" => "Regionales Format", "Command\nPalette" => "Befehls-\npalette", "Agent\nControl" => "Agenten-\nsteuerung", "Action\nJournal" => "Aktions-\nprotokoll", diff --git a/crates/ui-egui/src/i18n/es.rs b/crates/ui-egui/src/i18n/es.rs index 4ce231bca..e315c735c 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -334,6 +334,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Unfreeze Panes" => "Movilizar paneles", "Dark Mode" => "Modo oscuro", "Interface language" => "Idioma de la interfaz", + "Regional format" => "Formato regional", "Command\nPalette" => "Paleta de\ncomandos", "Agent\nControl" => "Control\ndel agente", "Action\nJournal" => "Registro\nde acciones", diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index 5d20ab0ca..6671d5c67 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -231,6 +231,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Light" => "Clair", "Dark" => "Sombre", "Interface language" => "Langue de l'interface", + "Regional format" => "Format régional", "Interface size" => "Taille de l'interface", "Auto" => "Automatique", "Drag to freeze columns" => "Faites glisser pour figer les colonnes", diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index 1902ad301..6778ab4ae 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -220,6 +220,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Unfreeze Panes" => "틀 고정 취소", "Dark Mode" => "다크 모드", "Interface language" => "인터페이스 언어", + "Regional format" => "지역 형식", "Interface size" => "인터페이스 크기", "Auto" => "자동", "Drag to freeze columns" => "끌어서 열 고정", diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index fb9236cdb..5cdd24b41 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -221,6 +221,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Unfreeze Panes" => "Descongelar Painéis", "Dark Mode" => "Modo Escuro", "Interface language" => "Idioma da interface", + "Regional format" => "Formato regional", "Interface size" => "Tamanho da interface", "Auto" => "Automático", "Drag to freeze columns" => "Arraste para congelar colunas", diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 765f6bb32..60f0ed085 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -220,6 +220,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Unfreeze Panes" => "Снять закрепление областей", "Dark Mode" => "Тёмный режим", "Interface language" => "Язык интерфейса", + "Regional format" => "Региональный формат", "Interface size" => "Размер интерфейса", "Auto" => "Авто", "Drag to freeze columns" => "Перетащите, чтобы закрепить столбцы", diff --git a/crates/ui-egui/src/i18n/sr.rs b/crates/ui-egui/src/i18n/sr.rs index cbf18d4c4..af1e33e59 100644 --- a/crates/ui-egui/src/i18n/sr.rs +++ b/crates/ui-egui/src/i18n/sr.rs @@ -223,6 +223,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Light" => "Svetla", "Dark" => "Tamna", "Interface language" => "Jezik interfejsa", + "Regional format" => "Regionalni format", "Interface size" => "Veličina interfejsa", "Auto" => "Automatski", "Drag to freeze columns" => "Prevuci da zamrzneš kolone", diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index 00175925e..0a6487e4c 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -220,6 +220,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Unfreeze Panes" => "取消冻结窗格", "Dark Mode" => "深色模式", "Interface language" => "界面语言", + "Regional format" => "区域格式", "Interface size" => "界面大小", "Auto" => "自动", "Drag to freeze columns" => "拖动以冻结列", diff --git a/crates/ui-egui/src/keymap.rs b/crates/ui-egui/src/keymap.rs index 032db1a2d..5dc2249a8 100644 --- a/crates/ui-egui/src/keymap.rs +++ b/crates/ui-egui/src/keymap.rs @@ -179,12 +179,14 @@ pub(crate) fn dispatch(app: &mut SheetApp, ctx: &egui::Context, key: Key, physic } Shortcut::Dialog(name, params) => app.open_dialog(name, params), Shortcut::Date | Shortcut::Time => { + // The date and time are entered as the region writes them (its short date, and its short time: + // a 12-hour clock with AM/PM or a 24-hour one), so they read back as typed. let text = app .session .active() .map(|d| { let now = gridcraft_engine::core::date::serial_in_system(d.wb.date_system, gridcraft_engine::calc::now_serial()); - let (value, format) = if shortcut == Shortcut::Date { (now.floor(), "m/d/yyyy") } else { (now.fract(), "h:mm AM/PM") }; + let (value, format) = if shortcut == Shortcut::Date { (now.floor(), "m/d/yyyy") } else { (now.fract(), d.wb.region.short_time) }; gridcraft_engine::display::format(&gridcraft_engine::core::Value::Number(value), format, &d.wb).text }) .unwrap_or_default(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f96cdac3c..96bf5bd3d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -436,9 +436,11 @@ impl SheetApp { self.cut_text = None; } let html = self.services.paste_html.as_mut().and_then(|read| read()); + // Text from outside is read as typed in the workbook's region, as Excel does. + let region = self.session.region().tag; let params = match html { - Some(html) => json!({"text": text, "html": html}), - None => json!({"text": text}), + Some(html) => json!({"text": text, "html": html, "region": region}), + None => json!({"text": text, "region": region}), }; self.run_or_alert("edit.paste", params); } @@ -918,7 +920,7 @@ impl SheetApp { }; // Data validation. if let Some(d) = self.session.active() - && let Some((dv, msg)) = gridcraft_engine::cmd::data::check_validation(&d.wb, ed.sheet, ed.cell, &text) + && let Some((dv, msg)) = gridcraft_engine::cmd::data::check_validation(&d.wb, ed.sheet, ed.cell, &text, d.wb.region) { if dv.error_style == gridcraft_engine::model::ErrorStyle::Stop { let title = if dv.error_title.is_empty() { "GridCraft".to_string() } else { dv.error_title.clone() }; @@ -929,10 +931,12 @@ impl SheetApp { } self.toast = Some((msg, now_ms())); } + // What was typed is read in the workbook's region; commands take canonical text otherwise. + let region = self.session.region().tag; let r = if fill_selection { - self.session.run("range.fill", json!({"input": text})) + self.session.run("range.fill", json!({"input": text, "region": region})) } else { - self.session.run("cell.set", json!({"cell": ed.cell.a1(), "input": text, "array": array})) + self.session.run("cell.set", json!({"cell": ed.cell.a1(), "input": text, "array": array, "region": region})) }; match r { Ok(_) => { diff --git a/crates/ui-egui/tests/file_menu.rs b/crates/ui-egui/tests/file_menu.rs index 691eef367..c0feaa026 100644 --- a/crates/ui-egui/tests/file_menu.rs +++ b/crates/ui-egui/tests/file_menu.rs @@ -217,3 +217,23 @@ fn a_cancelled_export_pdf_picker_writes_nothing() { assert!(h.state().message.is_none()); assert!(h.state_mut().session.take_ui_requests().is_empty()); } + +#[test] +fn options_page_picks_the_regional_format_and_typed_dates_follow_it() { + let mut h = harness(Services::default()); + h.state_mut().run("file.options", serde_json::json!({})).unwrap(); + h.run_steps(3); + assert_eq!(page(&h), Some("options")); + h.get_all_by_role(egui::accesskit::Role::ComboBox).nth(2).expect("regional format combo").click(); + h.run_steps(3); + h.get_by_label("English (United Kingdom)").click(); + h.run_steps(3); + assert_eq!(h.state().session.region().tag, "en-GB"); + h.state_mut().run("ui.backstage", serde_json::json!({"page": "close"})).unwrap(); + // Typing in the editor passes the workbook's region on; the command alone reads canonical text. + h.state_mut().begin_edit(Some("25/09/2026".into()), false); + assert!(h.state_mut().commit_edit(0, 0, false, false)); + let doc = h.state().session.active().unwrap(); + assert_eq!(doc.wb.active().unwrap().value(CellRef::new(0, 0)), Value::Number(46290.0)); + assert_eq!(gridcraft_engine::display::format(&Value::Number(46290.0), "m/d/yyyy", &doc.wb).text, "25/09/2026"); +} diff --git a/crates/ui-egui/tests/formula_locale.rs b/crates/ui-egui/tests/formula_locale.rs index 855869377..59e6402b6 100644 --- a/crates/ui-egui/tests/formula_locale.rs +++ b/crates/ui-egui/tests/formula_locale.rs @@ -130,6 +130,10 @@ fn locale_preserves_text_cells_and_existing_entry_conveniences() { app.run("app.language.set", json!({"language":"es"})).unwrap(); assert!(enter(&mut app, "=SUMA(1,5;2")); assert_eq!(formula(&app), "SUM(1.5,2)"); + // The interface language no longer decides how a constant is read: the region does. + assert!(enter(&mut app, "-1,5")); + assert_eq!(app.session.active().unwrap().wb.active().unwrap().value(CellRef::new(0, 0)), Value::text("-1,5")); + app.run("app.region", json!({"region":"es-ES"})).unwrap(); assert!(enter(&mut app, "-1,5")); assert_eq!(app.session.active().unwrap().wb.active().unwrap().value(CellRef::new(0, 0)), Value::Number(-1.5)); app.run("home.numberFormat", json!({"format":"@"})).unwrap(); @@ -219,3 +223,21 @@ fn user_defined_prefix_is_hidden_while_editing_and_kept_on_commit() { assert!(app.commit_edit(0, 0, false, false)); assert_eq!(formula(&app), "_xludf.EOMONTH()&_xludf.CONCAT(\"_xludf.x\")"); } + +#[test] +fn a_number_the_region_reads_is_a_constant_in_the_formula_language_too() { + // Spanish formulas and a pt-BR region: `-R$ 5` starts like a formula (`-` and a letter) but is + // a currency amount the region reads, so the editor must hand it to the engine untouched. + let mut app = app(); + app.run("app.language.set", json!({"language":"es"})).unwrap(); + app.run("app.region", json!({"region":"pt-BR"})).unwrap(); + assert!(enter(&mut app, "-R$ 5")); + let cell = |app: &SheetApp| app.session.active().unwrap().wb.active().unwrap().value(CellRef::new(0, 0)); + assert_eq!(cell(&app), Value::Number(-5.0)); + // A real formula still goes through the Spanish parser. + assert!(enter(&mut app, "=SUMA(1,5;2)")); + assert_eq!(formula(&app), "SUM(1.5,2)"); + // The same text in en-US is not a number, so it is a (Spanish) formula and is refused. + app.run("app.region", json!({"region":"en-US"})).unwrap(); + assert!(!enter(&mut app, "-R$ 5")); +} diff --git a/docs/architecture.md b/docs/architecture.md index 5695036b2..8bf38f1b9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # Architecture -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created from the code on main; supersedes the local-only plan/architecture.md for committed docs) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (`numfmt` follows a region) · **Target:** Microsoft Excel (Microsoft 365) How GridCraft is built today. Rules for contributors are in [`AGENTS.md`](../AGENTS.md). @@ -13,7 +13,7 @@ including tests, 2026-10-10. | Layer | Crate | Lines | Job | |---|---|---|---| | L0 | `core` | ~1.3k | Cell and range addresses (A1, R1C1, sheet-qualified; 1,048,576 × 16,384), `Value`, `CellError`, Excel coercion, 1900/1904 date serials, typed-input parsing | -| L0 | `numfmt` | ~2.1k | Excel number-format codes (sections, conditions, colours, dates, elapsed time, fractions, scientific, fill), General, `TEXT()`; en-US only | +| L0 | `numfmt` | ~2.1k | Excel number-format codes (sections, conditions, colours, dates, elapsed time, fractions, scientific, fill), General, `TEXT()`; separators, month and day names, AM/PM and the system date and time formats follow a `core::Region` | | L1 | `formula` | ~1.7k | Tokenizer, parser, AST, printer (A1 and R1C1), reference adjustment for copy, fill and structural edits | | L1 | `functions` | ~13.4k | The function library: `FnSpec` registry (name, arity, category, scalar args, signature, description) and pure implementations; automatic array lifting | | L2 | `model` | ~2.2k | Workbook, sheets, copy-on-write cell store (64-row bands behind `Arc`), interned styles, names, tables, CF, validation, comments, charts, shapes, images, sparklines, print settings, protection | @@ -68,4 +68,5 @@ rpm, tarball), FreeBSD, and web (WASM; WebGPU or WebGL2). Release workflows in | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | `numfmt` follows a `core::Region` (no longer en-US only) | | 2026-10-10 | major | Created from the code on main: crates, layers, data flow, agent control, known gaps | diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 7cc6ec230..dcca24d8c 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -62,6 +62,33 @@ workbook (Agile or Standard encryption). Without `password` it returns an error password returns "The password is incorrect." The password is left out of the journal and never quoted in an error. The opened workbook has no save path, so Save asks where to save it, without the password, and never replaces the encrypted original. +`{command: "app.region", params: {region: "en-GB"}}` sets the regional format: the decimal and +group separators, the date order and the month and day names that typed cell input is read with +and cells are shown with. It never changes a stored value, formula or number format code, only +how they are typed and displayed, so switching back shows the same dates in the old layout. +Formula syntax (function names, argument separators) is not part of the region. `region` is a tag +from the list below, matched strictly (an unknown tag is an error that lists the valid ones): +`en-US` (default), `en-GB`, `en-IN`, `es-ES`, `de-DE`, `fr-FR`, `it-IT`, `pt-BR`, `pt-PT`, +`ru-RU`, `cs-CZ`, `ja-JP`, `ko-KR`, `zh-CN`, `zh-TW`. Without `region` the command only reports +the current one: `{region, name, decimal, group, dateOrder, shortDate, longDate, available}`. The +region is a session preference, applied to every open and new workbook and saved in `prefs.json`; +the desktop app starts from the system region on first run, the web app from +`navigator.language`, and headless sessions (CLI, MCP, tests) from `en-US`. The command is +journaled but not undoable. + +**Command text is canonical (en-US), whatever the region preference is.** A script, a recorded +action or an agent that sends `cell.set {input: "25/09/2026"}` always gets the same result: text +in an en-US session and in a de-DE one. To say that text was typed in a region, add +`region: ""` to the call: `cell.set {cell: "A1", input: "25/09/2026", region: "en-GB"}` +stores the date 46290. The parameter is accepted by the commands that take typed constants: +`cell.set` (`input`, or a string `value`; a JSON number or boolean is a value and is never +re-read), `range.fill`, `range.setValues` (string cells), `edit.paste` (`text`), `data.validate` +(`input`) and `data.forecastSheet` (`end`). The tag is part of the journal and of recorded +scripts, so a replay does not depend on the preference. The interface passes the workbook's +region on every edit it commits, which is why typing `25/09/2026` in the grid works in en-GB. Files +are canonical too: CSV and TSV are written and read with en-US separators and dates (a de-DE +`#,##0` cell holding 1000 saves as `"1,000"`), and Get Data from a range copies values, not the +region's text. Only the system clipboard and HTML copy carry the displayed (regional) text. For a headless equivalent (no window), use `gridcraft-cli mcp` or `gridcraft-cli run`; see [`mcp.md`](mcp.md) and [`cli.md`](cli.md). diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 09197ff6c..029505cf9 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Format Cells dialog looks its labels up in the catalog) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (regional formats added) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Per-language status against Excel, which ships its UI in dozens of languages (Windows language packs cover 100+), with localized function names, argument separators, number and date formats, @@ -21,10 +21,11 @@ right-to-left sheets and full complex-script support. Part of - **About 2,200 user-visible strings** in all (heuristic source scan: ~1,700 UI and command strings plus ~500 function descriptions), so each shipped language covers **~12%** (measured count / estimated total). -- **en-US only formulas and formats.** Function names, `,` argument separator, `.` decimal - separator, en-US number and date formats (`crates/numfmt` says "en-US only"). Users ask for - localized function names and `;` separators (#45). German with localized numbers and formulas - is in PR #163 (open). +- **en-US function names and separators; regional number and date formats.** Function names and + the `,` argument separator are en-US (Spanish formulas aside, via `FormulaLocale`). Users ask + for localized function names and `;` separators (#45). German with localized numbers and + formulas is in PR #163 (open). Number, date and time formats follow a separate Regional format + setting (see below). - **Script support.** System-font fallback for CJK, Thai and Hebrew landed in #38 (closes #32; #142 is still open pending confirmation). There is no complex-text shaping: Arabic and Devanagari don't join or reorder, and there is no bidirectional layout. **Sheet Right-to-Left** @@ -35,6 +36,34 @@ right-to-left sheets and full complex-script support. Part of through egui/winit and is unverified for Chinese, Japanese and Korean. - **PDF export** uses only the 14 standard PDF fonts: non-Latin text cannot export. +## Regional formats + +File › Options › Regional format (command `app.region`, [control-protocol.md](control-protocol.md)) +selects the region, independently of the interface language, as Excel's Region setting does. It +is a session preference saved with the others; the desktop app starts from the system region, the +web app from `navigator.language`, and headless sessions from en-US. 15 regions are built in +(`crates/core/src/region.rs`, data from CLDR): en-US, en-GB, en-IN, es-ES, de-DE, fr-FR, it-IT, +pt-BR, pt-PT, ru-RU, cs-CZ, ja-JP, ko-KR, zh-CN, zh-TW. Other system regions fall back to their +language's main region. + +- **Typing:** decimal and group separators, Indian grouping, currency symbols, percent, dates in + the region's day/month/year order (two-digit years, local month names, ISO), and times with the + region's AM/PM words. Typing `25/09/2026` in en-GB stores 46290 with the canonical date format. +- **Showing:** separators and the General decimal, month and day names (genitive forms in + Russian and Czech), AM/PM words, and Excel's built-in date formats 14 and 22, which show the + region's short date; `[$-F800]` and `[$-F400]` (Long Date, Time) show the region's long date and + time. Stored values, formulas and format codes are never rewritten, so changing region only + changes how they read. +- **Commands stay canonical.** Text sent to a command (`cell.set`, scripts, MCP) is read as en-US + unless the call carries `region`, so a recorded script never depends on the preference; the + interface passes the workbook's region when it commits typed input. Files stay en-US: CSV is + written and read with en-US separators and dates, whatever the region. +- **Not covered yet:** worksheet-function text coercion (`VALUE`, `DATEVALUE`, `TEXT`, `*IF` + criteria) stays en-US; chart axis labels; goal seek and solver inputs; the CSV delimiter and + CSV number format (en-US, as above); scenario values; a custom format that is exactly + `m/d/yyyy` is shown as the region's short date like built-in 14; the web app keeps en-US for + the CJK regions until a CJK font is loaded. + ## Languages UI strings translated are measured counts against the ~2,200 estimate. Estimates to `full` @@ -80,6 +109,7 @@ language (none so far) and of localized function names. |---|---|---| | 2026-10-12 | minor | Format Cells looks up its tabs, labels, options and note through `Language::tr` (Fill tab keyed `Format Cells\|Fill`); dialog titles and OK/Cancel too. Labels already in a table (Number, Currency, Fill…) show translated; the rest fall back to English until the tables add them | | 2026-10-12 | minor | Brazilian Portuguese catalog (`i18n/pt.rs`) grows from 377 to 923 entries: every key the other catalogs translate, including the dialog strings #669 starts looking up (~42%) | +| 2026-10-11 | minor | Regional formats: typing and showing numbers, dates and times by region (`app.region`, 15 regions, Backstage Options combo) | | 2026-10-11 | minor | German ribbon catalog (`i18n/de.rs`, 266 labels, ~12%); `de` locale tags (de-DE, de-AT, de-CH…) resolve to German. PR #163 (localized German formulas/numbers) still open | | 2026-10-11 | minor | Sheet Right-to-Left mirrors the grid (#146) | | 2026-10-11 | minor | Czech (cs) ribbon table: 266 labels (~12%), menus only |