From 4f69f6785962fa8a628e80b564bd705f0cf15bca Mon Sep 17 00:00:00 2001 From: reeeji <63110560+reeeji@users.noreply.github.com> Date: Thu, 29 Oct 2026 11:58:56 +0300 Subject: [PATCH 01/97] i18n: share locale data and adapt Russian formulas and errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build on #676 and #1850 with current parser/editor/name handling and the English-keyed function-help architecture. Keep language data in separate modules and use shared constant/error display, contextual tab lookup and diagnostic templates with the pinned craft-i18n formatter. Preserve canonical engine/API/XLSX syntax, literal text and Text formatting. Validate the full alias table, Unicode editing, namespace/case rules and UI/XLSX round trips. Document the scope and language extension points. Co-authored-by: Магда М.Г. Co-authored-by: dresskywork --- Cargo.lock | 1 + Cargo.toml | 2 + README.md | 2 +- contributors/people.toml | 3 + crates/formula/src/input.rs | 201 +++-- crates/formula/src/input/en.rs | 9 + crates/formula/src/input/es.rs | 28 + crates/formula/src/input/ru.rs | 251 ++++++ crates/formula/src/lexer.rs | 50 +- crates/formula/src/parser.rs | 8 +- crates/formula/tests/it/main.rs | 2 + crates/formula/tests/it/russian_input.rs | 138 +++ crates/ui-egui/Cargo.toml | 1 + crates/ui-egui/src/app/editing.rs | 2 +- crates/ui-egui/src/dialogs/insert_function.rs | 6 +- crates/ui-egui/src/dialogs/show.rs | 16 +- crates/ui-egui/src/editor.rs | 16 +- crates/ui-egui/src/formula_bar.rs | 2 +- crates/ui-egui/src/formula_locale.rs | 69 +- crates/ui-egui/src/grid/quadrant.rs | 5 +- crates/ui-egui/src/i18n.rs | 62 +- crates/ui-egui/src/i18n/error_templates.rs | 119 +++ crates/ui-egui/src/i18n/ru.rs | 837 +++++++++++++++++- crates/ui-egui/src/i18n/ru_functions.rs | 66 ++ crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/ribbon.rs | 2 +- crates/ui-egui/tests/it/formula_locale.rs | 72 ++ docs/formula-input.md | 49 +- docs/localization-parity.md | 39 +- docs/russian-localization.md | 57 ++ 30 files changed, 1987 insertions(+), 131 deletions(-) create mode 100644 crates/formula/src/input/en.rs create mode 100644 crates/formula/src/input/es.rs create mode 100644 crates/formula/src/input/ru.rs create mode 100644 crates/formula/tests/it/russian_input.rs create mode 100644 crates/ui-egui/src/i18n/error_templates.rs create mode 100644 crates/ui-egui/src/i18n/ru_functions.rs create mode 100644 docs/russian-localization.md diff --git a/Cargo.lock b/Cargo.lock index efa4b90065..b8ae477580 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2081,6 +2081,7 @@ version = "0.13.0" name = "gridcraft-ui-egui" version = "0.13.0" dependencies = [ + "craft-i18n", "craft-ui", "craft-update", "craft-window", diff --git a/Cargo.toml b/Cargo.toml index af96a3f8b9..166643f598 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,8 @@ gridcraft-pdf = { path = "crates/pdf" } # the app) comes from one storytold/craft-libs revision. craft-update = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", features = ["egui"] } +craft-i18n = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0" } + # foundation thiserror = "2" serde = { version = "1", features = ["derive", "rc"] } diff --git a/README.md b/README.md index d9dc5eb363..fe7bf7564a 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,7 @@ cargo run --release -p gridcraft -- --sample sales --control 7979 # + JSON con cargo xtask ci # fmt, clippy, tests, assets, layering, wasm ``` -Choose Español under View → Interface language for [Spanish formula names, decimal commas, and semicolon arguments](docs/formula-input.md). +Choose Español or Русский under View → Interface language for [localized formula names, decimal commas, and semicolon arguments](docs/formula-input.md). Workbooks, clipboard exchange, and programmatic APIs retain canonical English syntax. ### Web diff --git a/contributors/people.toml b/contributors/people.toml index 5ce80fe59e..a1fbdabf20 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -103,3 +103,6 @@ real_name = "Helen Kwok" [people.dotCooCoo] real_name = "Robert Lee" sync_github_name = true + +[people.reeeji] +sync_github_name = true diff --git a/crates/formula/src/input.rs b/crates/formula/src/input.rs index 1c89297390..4490c9efdc 100644 --- a/crates/formula/src/input.rs +++ b/crates/formula/src/input.rs @@ -1,5 +1,13 @@ //! Localized aliases accepted at user-entry boundaries; file syntax stays English. +use gridcraft_core::CellError; +use std::collections::HashMap; +use std::sync::OnceLock; + +mod en; +mod es; +mod ru; + use crate::{Expr, ParseError, parser}; /// Formula-entry syntax selected by the interface language. Other interface languages @@ -9,50 +17,104 @@ pub enum FormulaLocale { #[default] En, Es, + Ru, +} + +/// Per-locale data consumed by one parser/printer and one UI boundary. +struct LocaleData { + functions: &'static [(&'static str, &'static str)], + errors: &'static [(CellError, &'static str)], + separators: Separators, + fold_alias: fn(&str) -> String, + aliases: OnceLock>, } -const SPANISH: &[(&str, &str)] = &[ - ("SUM", "SUMA"), - ("AVERAGE", "PROMEDIO"), - ("IF", "SI"), - ("COUNT", "CONTAR"), - ("SUMIF", "SUMAR.SI"), - ("COUNTIF", "CONTAR.SI"), - ("VLOOKUP", "BUSCARV"), - ("TODAY", "HOY"), - // Financial block (#1986): the loan-schedule and investment staples. - ("NPV", "VNA"), - ("IRR", "TIR"), - ("PMT", "PAGO"), - ("PV", "VA"), - ("FV", "VF"), - ("TRUE", "VERDADERO"), - ("FALSE", "FALSO"), -]; +#[derive(Clone, Copy)] +struct Separators { + decimal: char, + list: char, + array_column: char, + array_row: char, +} + +const ENGLISH_SEPARATORS: Separators = Separators { decimal: '.', list: ',', array_column: ',', array_row: ';' }; +const COMMA_DECIMAL_SEPARATORS: Separators = Separators { decimal: ',', list: ';', array_column: '\\', array_row: ';' }; + +/// Preserve a token until alias lookup if canonical ASCII casing changes its locale identity. +fn normalize_function_case(name: &str, fold: fn(&str) -> String) -> String { + let upper = name.to_ascii_uppercase(); + if upper == name || fold(name) == fold(&upper) { parser::normalize_function_name(name) } else { name.to_string() } +} impl FormulaLocale { + fn data(self) -> &'static LocaleData { + match self { + Self::En => &en::DATA, + Self::Es => &es::DATA, + Self::Ru => &ru::DATA, + } + } + + /// Separates function arguments and reference unions. pub fn list_separator(self) -> char { - if self == Self::Es { ';' } else { ',' } + self.data().separators.list } + /// Separates the integer and fractional parts of numeric literals. pub fn decimal_separator(self) -> char { - if self == Self::Es { ',' } else { '.' } + self.data().separators.decimal } + /// Separates columns inside array constants. pub fn array_column_separator(self) -> char { - if self == Self::Es { '\\' } else { ',' } + self.data().separators.array_column + } + /// Separates rows inside array constants. + pub fn array_row_separator(self) -> char { + self.data().separators.array_row + } + + pub(crate) fn is_array_separator(self, character: char) -> bool { + character == self.array_column_separator() || character == self.array_row_separator() + } + + /// Case folding for formula aliases only; workbook names keep their own identity. + pub fn fold_alias(self, name: &str) -> String { + (self.data().fold_alias)(name) } + + /// Normalizes a function token without erasing locale-specific case distinctions. + pub fn normalize_function_name(self, name: &str) -> String { + normalize_function_case(name, self.data().fold_alias) + } + + /// Preferred local spelling of a built-in; unmapped names keep their canonical spelling. pub fn function_name(self, canonical: &str) -> &str { - if self == Self::Es { - SPANISH.iter().find(|(en, _)| en.eq_ignore_ascii_case(canonical)).map(|(_, es)| *es).unwrap_or(canonical) - } else { - canonical - } + self.data().functions.iter().find(|(en, _)| en.eq_ignore_ascii_case(canonical)).map_or(canonical, |(_, local)| *local) } + + /// Resolves a local alias; unmapped names remain unchanged. pub fn canonical_name(self, localized: &str) -> &str { - if self == Self::Es { - SPANISH.iter().find(|(_, es)| es.eq_ignore_ascii_case(localized)).map(|(en, _)| *en).unwrap_or(localized) - } else { - localized + let data = self.data(); + if data.functions.is_empty() { + return localized; } + data.aliases + .get_or_init(|| data.functions.iter().map(|(en, local)| (self.fold_alias(local), *en)).collect()) + .get(&self.fold_alias(localized)) + .copied() + .unwrap_or(localized) + } + + /// Localized error spelling at the editor boundary. Files keep CellError's canonical text. + pub fn error_name(self, error: CellError) -> &'static str { + self.data().errors.iter().find(|(value, _)| *value == error).map_or(error.as_str(), |(_, text)| *text) + } + + /// Both a locale's spelling and the canonical spelling identify the same error value. + pub fn parse_error(self, text: &str) -> Option { + CellError::parse(text).or_else(|| { + let folded = self.fold_alias(text); + self.data().errors.iter().find(|(_, spelling)| self.fold_alias(spelling) == folded).map(|(error, _)| *error) + }) } } @@ -60,8 +122,8 @@ impl FormulaLocale { /// LET/LAMBDA bindings take precedence over aliases. File and API syntax uses [`crate::parse`]. pub fn parse_input(src: &str, locale: FormulaLocale, known_name: impl Fn(&str) -> bool) -> Result { let mut expr = parser::parse_syntax(src, locale)?; - if locale == FormulaLocale::Es { - normalize(&mut expr, &known_name, &mut Vec::new(), 0, false)?; + if locale != FormulaLocale::En { + normalize(&mut expr, locale, &known_name, &mut Vec::new(), 0, false)?; } Ok(expr) } @@ -96,7 +158,7 @@ pub fn print_input(expr: &Expr, locale: FormulaLocale, known_name: impl Fn(&str) return Ok(crate::print(expr)); } let mut expr = expr.clone(); - normalize(&mut expr, &known_name, &mut Vec::new(), 0, true)?; + normalize(&mut expr, locale, &known_name, &mut Vec::new(), 0, true)?; let canonical = crate::print(&expr); let tokens = crate::lexer::tokenize(&canonical).map_err(|e| ParseError { msg: e.msg, pos: e.pos })?; let mut out = String::new(); @@ -113,6 +175,8 @@ pub fn print_input(expr: &Expr, locale: FormulaLocale, known_name: impl Fn(&str) array_depth = array_depth.saturating_sub(1); out.push_str(text); } + Tok::Error(error) => out.push_str(locale.error_name(error)), + Tok::Semicolon if array_depth > 0 => out.push(locale.array_row_separator()), Tok::Number(_) => out.push_str(&text.replace('.', &locale.decimal_separator().to_string())), Tok::Comma => out.push(if array_depth > 0 { locale.array_column_separator() } else { locale.list_separator() }), _ => out.push_str(text), @@ -121,30 +185,38 @@ pub fn print_input(expr: &Expr, locale: FormulaLocale, known_name: impl Fn(&str) Ok(out) } -pub(crate) fn boolean_alias(name: &str) -> Option { - if name.eq_ignore_ascii_case("VERDADERO") { - Some(true) - } else if name.eq_ignore_ascii_case("FALSO") { - Some(false) - } else { - None +pub(crate) fn boolean_alias(name: &str, locale: FormulaLocale) -> Option { + if locale == FormulaLocale::En { + return None; + } + match locale.canonical_name(name) { + "TRUE" => Some(true), + "FALSE" => Some(false), + _ => None, } } -fn normalize(expr: &mut Expr, known_name: &impl Fn(&str) -> bool, bindings: &mut Vec, depth: usize, display: bool) -> Result<(), ParseError> { +fn normalize( + expr: &mut Expr, + locale: FormulaLocale, + known_name: &impl Fn(&str) -> bool, + bindings: &mut Vec, + depth: usize, + display: bool, +) -> Result<(), ParseError> { if depth > parser::MAX_DEPTH { return Err(ParseError { msg: "formula is nested too deeply".into(), pos: 0 }); } let bound = |name: &str, bindings: &[String]| bindings.iter().rev().any(|n| crate::same_name(n, name)) || known_name(name); match expr { Expr::Bool(value) if display => { - let alias = if *value { "VERDADERO" } else { "FALSO" }; + let alias = locale.function_name(if *value { "TRUE" } else { "FALSE" }); if !bound(alias, bindings) { *expr = Expr::Name(alias.into()); } } Expr::Name(name) if !display && !bound(name, bindings) => { - if let Some(value) = boolean_alias(name) { + if let Some(value) = boolean_alias(name, locale) { *expr = Expr::Bool(value); } } @@ -154,13 +226,13 @@ fn normalize(expr: &mut Expr, known_name: &impl Fn(&str) -> bool, bindings: &mut for pair in pairs.chunks_mut(2) { if let [declaration, value] = pair { // The new name is not in scope in its own value expression. - normalize(value, known_name, bindings, depth + 1, display)?; + normalize(value, locale, known_name, bindings, depth + 1, display)?; if let Expr::Name(name) = declaration { bindings.push(name.clone()); } } } - normalize(body, known_name, bindings, depth + 1, display)?; + normalize(body, locale, known_name, bindings, depth + 1, display)?; } bindings.truncate(saved); } @@ -172,40 +244,40 @@ fn normalize(expr: &mut Expr, known_name: &impl Fn(&str) -> bool, bindings: &mut bindings.push(name.clone()); } } - normalize(body, known_name, bindings, depth + 1, display)?; + normalize(body, locale, known_name, bindings, depth + 1, display)?; } bindings.truncate(saved); } Expr::Call(name, args) => { - let alias = if display { FormulaLocale::Es.function_name(name) } else { FormulaLocale::Es.canonical_name(name) }; + let alias = if display { locale.function_name(name) } else { locale.canonical_name(name) }; if !bound(name, bindings) && (!display || !bound(alias, bindings)) { - *name = alias.to_string(); + *name = if display { alias.to_string() } else { parser::normalize_function_name(alias) }; } for arg in args { - normalize(arg, known_name, bindings, depth + 1, display)?; + normalize(arg, locale, known_name, bindings, depth + 1, display)?; } } Expr::Invoke(callee, args) => { - normalize(callee, known_name, bindings, depth + 1, display)?; + normalize(callee, locale, known_name, bindings, depth + 1, display)?; for arg in args { - normalize(arg, known_name, bindings, depth + 1, display)?; + normalize(arg, locale, known_name, bindings, depth + 1, display)?; } } - Expr::Unary(_, child) | Expr::Paren(child) => normalize(child, known_name, bindings, depth + 1, display)?, + Expr::Unary(_, child) | Expr::Paren(child) => normalize(child, locale, known_name, bindings, depth + 1, display)?, Expr::Binary(..) => { // Follow the left spine of a flat chain (A1+A2+…) in a loop: it is not nesting. // Each call leaves `bindings` as it found them, so the order does not matter. let mut node = expr; while let Expr::Binary(_, left, right) = node { - normalize(right, known_name, bindings, depth + 1, display)?; + normalize(right, locale, known_name, bindings, depth + 1, display)?; node = left; } - normalize(node, known_name, bindings, depth, display)?; + normalize(node, locale, known_name, bindings, depth, display)?; } Expr::Array(rows) if display => { for row in rows { for child in row { - normalize(child, known_name, bindings, depth + 1, display)?; + normalize(child, locale, known_name, bindings, depth + 1, display)?; } } } @@ -215,3 +287,22 @@ fn normalize(expr: &mut Expr, known_name: &impl Fn(&str) -> bool, bindings: &mut } Ok(()) } + +#[cfg(test)] +mod case_rule_tests { + use super::normalize_function_case; + + #[test] + fn locale_case_rules_survive_token_normalization() { + // A data-only example of a case rule where ASCII upper-casing loses information. + fn dotted_i_fold(name: &str) -> String { + name.chars().flat_map(|c| if c == 'i' { 'İ'.to_uppercase() } else { c.to_uppercase() }).collect() + } + assert_eq!(normalize_function_case("indis", dotted_i_fold), "indis"); + assert_eq!(normalize_function_case("sum", dotted_i_fold), "SUM"); + assert_eq!(normalize_function_case("sum", str::to_lowercase), "SUM"); + assert_eq!(normalize_function_case("_xlfn.sum", str::to_lowercase), "SUM"); + assert_eq!(normalize_function_case("_XLUDF.sum", str::to_lowercase), "_xludf.SUM"); + assert_eq!(normalize_function_case("İNDİS", dotted_i_fold), "İNDİS"); + } +} diff --git a/crates/formula/src/input/en.rs b/crates/formula/src/input/en.rs new file mode 100644 index 0000000000..a08f80152f --- /dev/null +++ b/crates/formula/src/input/en.rs @@ -0,0 +1,9 @@ +//! Canonical English syntax, used by files/APIs and by untranslated formula locales. + +pub(super) static DATA: super::LocaleData = super::LocaleData { + functions: &[], + errors: &[], + separators: super::ENGLISH_SEPARATORS, + fold_alias: str::to_lowercase, + aliases: std::sync::OnceLock::new(), +}; diff --git a/crates/formula/src/input/es.rs b/crates/formula/src/input/es.rs new file mode 100644 index 0000000000..d9960ad920 --- /dev/null +++ b/crates/formula/src/input/es.rs @@ -0,0 +1,28 @@ +//! Spanish formula-entry data, moved from the original shared input module. + +pub(super) static DATA: super::LocaleData = super::LocaleData { + functions: FUNCTIONS, + errors: &[], + separators: super::COMMA_DECIMAL_SEPARATORS, + fold_alias: str::to_lowercase, + aliases: std::sync::OnceLock::new(), +}; + +const FUNCTIONS: &[(&str, &str)] = &[ + ("SUM", "SUMA"), + ("AVERAGE", "PROMEDIO"), + ("IF", "SI"), + ("COUNT", "CONTAR"), + ("SUMIF", "SUMAR.SI"), + ("COUNTIF", "CONTAR.SI"), + ("VLOOKUP", "BUSCARV"), + ("TODAY", "HOY"), + // Financial block (#1986): the loan-schedule and investment staples. + ("NPV", "VNA"), + ("IRR", "TIR"), + ("PMT", "PAGO"), + ("PV", "VA"), + ("FV", "VF"), + ("TRUE", "VERDADERO"), + ("FALSE", "FALSO"), +]; diff --git a/crates/formula/src/input/ru.rs b/crates/formula/src/input/ru.rs new file mode 100644 index 0000000000..4b9d8516f1 --- /dev/null +++ b/crates/formula/src/input/ru.rs @@ -0,0 +1,251 @@ +//! Russian formula names adapted from PR #1850; error spellings are locale data. + +pub(super) static DATA: super::LocaleData = super::LocaleData { + functions: FUNCTIONS, + errors: ERRORS, + separators: super::COMMA_DECIMAL_SEPARATORS, + fold_alias, + aliases: std::sync::OnceLock::new(), +}; + +pub(super) const FUNCTIONS: &[(&str, &str)] = &[ + ("SUM", "СУММ"), + ("SUMIF", "СУММЕСЛИ"), + ("SUMIFS", "СУММЕСЛИМН"), + ("SUMPRODUCT", "СУММПРОИЗВ"), + ("SUMSQ", "СУММКВ"), + ("SUMX2MY2", "СУММРАЗНКВ"), + ("SUMX2PY2", "СУММСУММКВ"), + ("SUMXMY2", "СУММКВРАЗН"), + ("SUBTOTAL", "ПРОМЕЖУТОЧНЫЕ.ИТОГИ"), + ("AGGREGATE", "АГРЕГАТ"), + ("PRODUCT", "ПРОИЗВЕД"), + ("ROUND", "ОКРУГЛ"), + ("ROUNDUP", "ОКРУГЛВВЕРХ"), + ("ROUNDDOWN", "ОКРУГЛВНИЗ"), + ("MROUND", "ОКРУГЛТ"), + ("CEILING", "ОКРВВЕРХ"), + ("FLOOR", "ОКРВНИЗ"), + ("INT", "ЦЕЛОЕ"), + ("TRUNC", "ОТБР"), + ("MOD", "ОСТАТ"), + ("QUOTIENT", "ЧАСТНОЕ"), + ("POWER", "СТЕПЕНЬ"), + ("SQRT", "КОРЕНЬ"), + ("PI", "ПИ"), + ("RAND", "СЛЧИС"), + ("RANDBETWEEN", "СЛУЧМЕЖДУ"), + ("FACT", "ФАКТР"), + ("COMBIN", "ЧИСЛКОМБ"), + ("PERMUT", "ПЕРЕСТ"), + ("GCD", "НОД"), + ("LCM", "НОК"), + ("SIGN", "ЗНАК"), + ("EVEN", "ЧЁТН"), + ("ODD", "НЕЧЁТ"), + ("DEGREES", "ГРАДУСЫ"), + ("RADIANS", "РАДИАНЫ"), + ("SEQUENCE", "ПОСЛЕДОВ"), + ("MMULT", "МУМНОЖ"), + ("MDETERM", "МОПРЕД"), + ("MINVERSE", "МОБР"), + ("TRANSPOSE", "ТРАНСП"), + ("AVERAGE", "СРЗНАЧ"), + ("AVERAGEA", "СРЗНАЧА"), + ("AVERAGEIF", "СРЗНАЧЕСЛИ"), + ("AVERAGEIFS", "СРЗНАЧЕСЛИМН"), + ("COUNT", "СЧЁТ"), + ("COUNTA", "СЧЁТЗ"), + ("COUNTBLANK", "СЧИТАТЬПУСТОТЫ"), + ("COUNTIF", "СЧЁТЕСЛИ"), + ("COUNTIFS", "СЧЁТЕСЛИМН"), + ("MAX", "МАКС"), + ("MAXA", "МАКСА"), + ("MAXIFS", "МАКСЕСЛИ"), + ("MIN", "МИН"), + ("MINA", "МИНА"), + ("MINIFS", "МИНЕСЛИ"), + ("MEDIAN", "МЕДИАНА"), + ("MODE", "МОДА"), + ("MODE.SNGL", "МОДА.ОДН"), + ("LARGE", "НАИБОЛЬШИЙ"), + ("SMALL", "НАИМЕНЬШИЙ"), + ("RANK", "РАНГ"), + ("RANK.EQ", "РАНГ.РВ"), + ("RANK.AVG", "РАНГ.СР"), + ("STDEV", "СТАНДОТКЛОН"), + ("STDEV.S", "СТАНДОТКЛОН.В"), + ("STDEV.P", "СТАНДОТКЛОН.Г"), + ("STDEVP", "СТАНДОТКЛОНП"), + ("VAR", "ДИСП"), + ("VAR.S", "ДИСП.В"), + ("VAR.P", "ДИСП.Г"), + ("VARP", "ДИСПР"), + ("CORREL", "КОРРЕЛ"), + ("SLOPE", "НАКЛОН"), + ("INTERCEPT", "ОТРЕЗОК"), + ("FORECAST", "ПРЕДСКАЗ"), + ("PERCENTILE", "ПЕРСЕНТИЛЬ"), + ("QUARTILE", "КВАРТИЛЬ"), + ("FREQUENCY", "ЧАСТОТА"), + ("GEOMEAN", "СРГЕОМ"), + ("HARMEAN", "СРГАРМ"), + ("AVEDEV", "СРОТКЛ"), + ("IF", "ЕСЛИ"), + ("IFS", "ЕСЛИМН"), + ("IFERROR", "ЕСЛИОШИБКА"), + ("IFNA", "ЕСНД"), + ("AND", "И"), + ("OR", "ИЛИ"), + ("NOT", "НЕ"), + ("XOR", "ИСКЛИЛИ"), + ("SWITCH", "ПЕРЕКЛЮЧ"), + ("TRUE", "ИСТИНА"), + ("FALSE", "ЛОЖЬ"), + ("CONCATENATE", "СЦЕПИТЬ"), + ("CONCAT", "СЦЕП"), + ("TEXTJOIN", "ОБЪЕДИНИТЬ"), + ("LEFT", "ЛЕВСИМВ"), + ("RIGHT", "ПРАВСИМВ"), + ("MID", "ПСТР"), + ("LEN", "ДЛСТР"), + ("FIND", "НАЙТИ"), + ("SEARCH", "ПОИСК"), + ("SUBSTITUTE", "ПОДСТАВИТЬ"), + ("REPLACE", "ЗАМЕНИТЬ"), + ("UPPER", "ПРОПИСН"), + ("LOWER", "СТРОЧН"), + ("PROPER", "ПРОПНАЧ"), + ("TRIM", "СЖПРОБЕЛЫ"), + ("CLEAN", "ПЕЧСИМВ"), + ("TEXT", "ТЕКСТ"), + ("VALUE", "ЗНАЧЕН"), + ("REPT", "ПОВТОР"), + ("EXACT", "СОВПАД"), + ("CHAR", "СИМВОЛ"), + ("CODE", "КОДСИМВ"), + ("T", "Т"), + ("N", "Ч"), + ("FIXED", "ФИКСИРОВАННЫЙ"), + ("DOLLAR", "РУБЛЬ"), + ("NUMBERVALUE", "ЧЗНАЧ"), + ("TODAY", "СЕГОДНЯ"), + ("NOW", "ТДАТА"), + ("DATE", "ДАТА"), + ("TIME", "ВРЕМЯ"), + ("YEAR", "ГОД"), + ("MONTH", "МЕСЯЦ"), + ("DAY", "ДЕНЬ"), + ("HOUR", "ЧАС"), + ("MINUTE", "МИНУТЫ"), + ("SECOND", "СЕКУНДЫ"), + ("WEEKDAY", "ДЕНЬНЕД"), + ("WEEKNUM", "НОМНЕДЕЛИ"), + ("ISOWEEKNUM", "НОМНЕДЕЛИ.ISO"), + ("DATEVALUE", "ДАТАЗНАЧ"), + ("TIMEVALUE", "ВРЕМЗНАЧ"), + ("EDATE", "ДАТАМЕС"), + ("EOMONTH", "КОНМЕСЯЦА"), + ("DATEDIF", "РАЗНДАТ"), + ("DAYS", "ДНИ"), + ("DAYS360", "ДНЕЙ360"), + ("NETWORKDAYS", "ЧИСТРАБДНИ"), + ("WORKDAY", "РАБДЕНЬ"), + ("YEARFRAC", "ДОЛЯГОДА"), + ("VLOOKUP", "ВПР"), + ("HLOOKUP", "ГПР"), + ("XLOOKUP", "ПРОСМОТРX"), + ("XMATCH", "ПОИСКПОЗX"), + ("LOOKUP", "ПРОСМОТР"), + ("MATCH", "ПОИСКПОЗ"), + ("INDEX", "ИНДЕКС"), + ("OFFSET", "СМЕЩ"), + ("INDIRECT", "ДВССЫЛ"), + ("ROW", "СТРОКА"), + ("COLUMN", "СТОЛБЕЦ"), + ("ROWS", "ЧСТРОК"), + ("COLUMNS", "ЧИСЛСТОЛБ"), + ("ADDRESS", "АДРЕС"), + ("CHOOSE", "ВЫБОР"), + ("AREAS", "ОБЛАСТИ"), + ("FILTER", "ФИЛЬТР"), + ("SORT", "СОРТ"), + ("SORTBY", "СОРТПО"), + ("UNIQUE", "УНИК"), + ("HYPERLINK", "ГИПЕРССЫЛКА"), + ("FORMULATEXT", "Ф.ТЕКСТ"), + ("ISBLANK", "ЕПУСТО"), + ("ISERROR", "ЕОШИБКА"), + ("ISERR", "ЕОШ"), + ("ISNA", "ЕНД"), + ("ISNUMBER", "ЕЧИСЛО"), + ("ISTEXT", "ЕТЕКСТ"), + ("ISNONTEXT", "ЕНЕТЕКСТ"), + ("ISLOGICAL", "ЕЛОГИЧ"), + ("ISREF", "ЕССЫЛКА"), + ("ISEVEN", "ЕЧЁТН"), + ("ISODD", "ЕНЕЧЁТ"), + ("NA", "НД"), + ("TYPE", "ТИП"), + ("ERROR.TYPE", "ТИП.ОШИБКИ"), + ("CELL", "ЯЧЕЙКА"), + ("INFO", "ИНФОРМ"), + ("SHEET", "ЛИСТ"), + ("SHEETS", "ЛИСТЫ"), + ("PMT", "ПЛТ"), + ("PV", "ПС"), + ("FV", "БС"), + ("NPV", "ЧПС"), + ("IRR", "ВСД"), + ("RATE", "СТАВКА"), + ("NPER", "КПЕР"), + ("IPMT", "ПРПЛТ"), + ("PPMT", "ОСПЛТ"), + ("SLN", "АПЛ"), + ("DB", "ФУО"), + ("DDB", "ДДОБ"), + ("XNPV", "ЧИСТНЗ"), + ("XIRR", "ЧИСТВНДОХ"), + ("CONVERT", "ПРЕОБР"), + ("DELTA", "ДЕЛЬТА"), +]; + +/// Russian function aliases accept е for ё without changing workbook-name identity. +pub(super) fn fold_alias(name: &str) -> String { + name.to_lowercase().replace('ё', "е") +} + +/// Russian error literals; the parser and UI share the locale-independent lookup. +pub(super) const ERRORS: &[(gridcraft_core::CellError, &str)] = &[ + (gridcraft_core::CellError::Null, "#ПУСТО!"), + (gridcraft_core::CellError::Div0, "#ДЕЛ/0!"), + (gridcraft_core::CellError::Value, "#ЗНАЧ!"), + (gridcraft_core::CellError::Ref, "#ССЫЛКА!"), + (gridcraft_core::CellError::Name, "#ИМЯ?"), + (gridcraft_core::CellError::Num, "#ЧИСЛО!"), + (gridcraft_core::CellError::NA, "#Н/Д"), + (gridcraft_core::CellError::GettingData, "#ПОЛУЧЕНИЕ_ДАННЫХ"), + (gridcraft_core::CellError::Spill, "#ПЕРЕНОС!"), + (gridcraft_core::CellError::Calc, "#ВЫЧИС!"), +]; + +#[cfg(test)] +mod tests { + use super::*; + use crate::FormulaLocale; + #[test] + fn every_russian_alias_is_unique_and_round_trips() { + let mut canonical = std::collections::HashSet::new(); + let mut aliases = std::collections::HashSet::new(); + for (en, local) in FUNCTIONS { + assert!(canonical.insert(*en), "duplicate canonical name: {en}"); + assert!(aliases.insert(FormulaLocale::Ru.fold_alias(local)), "ambiguous alias: {local}"); + assert_eq!(FormulaLocale::Ru.function_name(en), *local); + assert_eq!(FormulaLocale::Ru.canonical_name(&local.to_lowercase()), *en); + assert_eq!(FormulaLocale::Ru.canonical_name(&local.replace('Ё', "Е")), *en); + let input = format!("{local}()"); + let expr = crate::parse_input(&input, FormulaLocale::Ru, |_| false).unwrap(); + assert_eq!(crate::print(&expr), format!("{en}()")); + } + } +} diff --git a/crates/formula/src/lexer.rs b/crates/formula/src/lexer.rs index 2799b5b6f0..a724088bf7 100644 --- a/crates/formula/src/lexer.rs +++ b/crates/formula/src/lexer.rs @@ -118,14 +118,31 @@ fn quoted(src: &str, chars: &[(usize, char)], i: usize, start: usize, out: &mut Ok(j + 1) } -fn error_literal(src: &str, chars: &[(usize, char)], i: usize, start: usize, out: &mut Vec) -> Result { +/// Case variants can have different UTF-8 byte lengths, so compare character-aligned prefixes. +fn spelling_prefix<'a>(source: &'a str, spelling: &str) -> &'a str { + let end = source.char_indices().nth(spelling.chars().count()).map_or(source.len(), |(byte, _)| byte); + source.get(..end).unwrap_or("") +} + +fn error_literal( + src: &str, + chars: &[(usize, char)], + locale: crate::FormulaLocale, + i: usize, + start: usize, + out: &mut Vec, +) -> Result { let rest = src.get(start..).unwrap_or(""); - let error = CellError::ALL + let (error, end) = CellError::ALL .iter() - .copied() - .find(|error| rest.get(..error.as_str().len()).is_some_and(|text| text.eq_ignore_ascii_case(error.as_str()))) + .chain([CellError::Circ].iter()) + .find_map(|error| { + [locale.error_name(*error), error.as_str()].into_iter().find_map(|spelling| { + let prefix = spelling_prefix(rest, spelling); + (locale.fold_alias(prefix) == locale.fold_alias(spelling)).then_some((*error, i + prefix.chars().count())) + }) + }) .ok_or_else(|| LexError { msg: "unknown error literal".into(), pos: start })?; - let end = i + error.as_str().chars().count(); emit(src, chars, out, Tok::Error(error), start, end); Ok(end) } @@ -214,7 +231,7 @@ fn number(src: &str, chars: &[(usize, char)], locale: crate::FormulaLocale, i: u fn word(src: &str, chars: &[(usize, char)], locale: crate::FormulaLocale, array_depth: usize, i: usize, start: usize, out: &mut Vec) -> usize { let ch = |index| char_at(chars, index); let mut j = i; - while ch(j).is_some_and(|c| is_word_char(c) && !(array_depth > 0 && c == locale.array_column_separator())) { + while ch(j).is_some_and(|c| is_word_char(c) && !(array_depth > 0 && locale.is_array_separator(c))) { j += 1; } let value: String = chars.get(i..j).map(|s| s.iter().map(|c| c.1).collect()).unwrap_or_default(); @@ -223,7 +240,7 @@ fn word(src: &str, chars: &[(usize, char)], locale: crate::FormulaLocale, array_ j + 1 } else if ch(j) == Some(':') && sheet_range_end(chars, locale, array_depth, &value, j, out) { let mut end = j + 1; - while ch(end).is_some_and(|c| is_word_char(c) && !(array_depth > 0 && c == locale.array_column_separator())) { + while ch(end).is_some_and(|c| is_word_char(c) && !(array_depth > 0 && locale.is_array_separator(c))) { end += 1; } let last: String = chars.get(j + 1..end).map(|s| s.iter().map(|c| c.1).collect()).unwrap_or_default(); @@ -250,7 +267,7 @@ fn word(src: &str, chars: &[(usize, char)], locale: crate::FormulaLocale, array_ fn sheet_range_end(chars: &[(usize, char)], locale: crate::FormulaLocale, array_depth: usize, value: &str, colon: usize, out: &[Token]) -> bool { let ch = |index| char_at(chars, index); let mut end = colon + 1; - while ch(end).is_some_and(|c| is_word_char(c) && !(array_depth > 0 && c == locale.array_column_separator())) { + while ch(end).is_some_and(|c| is_word_char(c) && !(array_depth > 0 && locale.is_array_separator(c))) { end += 1; } let repeats_sheet = is_range_end(value) @@ -293,7 +310,7 @@ pub fn tokenize_locale(src: &str, locale: crate::FormulaLocale) -> Result { - i = error_literal(src, &chars, i, start, &mut out)?; + i = error_literal(src, &chars, locale, i, start, &mut out)?; } '[' => { i = structured_ref(src, &chars, i, start, &mut out)?; @@ -333,7 +350,11 @@ pub fn tokenize_locale(src: &str, locale: crate::FormulaLocale) -> Result { + c if array_depth > 0 && c == locale.array_row_separator() => { + push(&mut out, Tok::Semicolon, i + 1); + i += 1; + } + ';' if array_depth == 0 => { push(&mut out, Tok::Semicolon, i + 1); i += 1; } @@ -380,3 +401,12 @@ pub fn tokenize_locale(src: &str, locale: crate::FormulaLocale) -> Result Resu } let toks = tokenize_locale(body, locale).map_err(|e| ParseError { msg: e.msg, pos: e.pos })?; let toks = significant_spaces(toks); - let mut p = Parser { toks, i: 0, depth: 0, input: locale == crate::FormulaLocale::Es }; + let mut p = Parser { toks, i: 0, depth: 0, locale }; let e = p.expr(0, allow_union)?; match p.peek() { Tok::Eof => Ok(e), @@ -106,7 +106,7 @@ struct Parser { toks: Vec, i: usize, depth: usize, - input: bool, + locale: crate::FormulaLocale, } fn word_is_cols(w: &str) -> Option<(u32, bool)> { @@ -293,7 +293,7 @@ impl Parser { } Tok::LBrace => self.array(), Tok::Func(name) => { - let name = normalize_function_name(&name); + let name = self.locale.normalize_function_name(&name); let mut args = self.args()?; // Files write `A1#` as `_xlfn.ANCHORARRAY(A1)`. if name == "ANCHORARRAY" @@ -434,7 +434,7 @@ impl Parser { }, Tok::Text(s) => Expr::Text(Arc::from(s)), Tok::Bool(b) => Expr::Bool(b), - Tok::Word(w) if self.input => crate::input::boolean_alias(&w) + Tok::Word(w) if self.locale != crate::FormulaLocale::En => crate::input::boolean_alias(&w, self.locale) .map(Expr::Bool) .ok_or_else(|| self.err(format!("array constants may only contain constants, found {}", describe(&Tok::Word(w)))))?, Tok::Error(e) => Expr::Error(e), diff --git a/crates/formula/tests/it/main.rs b/crates/formula/tests/it/main.rs index 01bff98c13..db85b956c5 100644 --- a/crates/formula/tests/it/main.rs +++ b/crates/formula/tests/it/main.rs @@ -2,3 +2,5 @@ mod printer_roundtrip; mod spanish_input; + +mod russian_input; diff --git a/crates/formula/tests/it/russian_input.rs b/crates/formula/tests/it/russian_input.rs new file mode 100644 index 0000000000..3925ca9df3 --- /dev/null +++ b/crates/formula/tests/it/russian_input.rs @@ -0,0 +1,138 @@ +use gridcraft_formula::{FormulaLocale, parse, parse_input, print, print_input}; + +fn input(src: &str) -> String { + print(&parse_input(src, FormulaLocale::Ru, |_| false).unwrap()) +} + +#[test] +fn russian_aliases_and_argument_separators() { + for (src, expected) in [ + ("=сумм(A1:A10)", "SUM(A1:A10)"), + ("СРЗНАЧ(A1:A10)", "AVERAGE(A1:A10)"), + ("ЕСЛИ(A1>0;\"Да\";\"Нет\")", "IF(A1>0,\"Да\",\"Нет\")"), + ("СЧЁТ(A1:A10)", "COUNT(A1:A10)"), + ("счет(A1:A10)", "COUNT(A1:A10)"), + ("СУММЕСЛИ(A1:A10;\">0\")", "SUMIF(A1:A10,\">0\")"), + ("СЧЁТЕСЛИ(A1:A10;\">0\")", "COUNTIF(A1:A10,\">0\")"), + ("ВПР(A2;D:E;2;ЛОЖЬ)", "VLOOKUP(A2,D:E,2,FALSE)"), + ("СЕГОДНЯ()", "TODAY()"), + ("ПРОМЕЖУТОЧНЫЕ.ИТОГИ(9;A1:A10)", "SUBTOTAL(9,A1:A10)"), + ("=истина", "TRUE"), + ("=ложь", "FALSE"), + ("ИСТИНА()", "TRUE()"), + ("ЛОЖЬ()", "FALSE()"), + ("И(ИСТИНА;ЛОЖЬ)", "AND(TRUE,FALSE)"), + ("СУММ(1;SUM(2;3);СРЗНАЧ(4;6))", "SUM(1,SUM(2,3),AVERAGE(4,6))"), + ("ЕСЛИ(ИСТИНА;;)", "IF(TRUE,,)"), + ("LAMBDA(x;x+1)(2)", "LAMBDA(x,x+1)(2)"), + ] { + assert_eq!(input(src), expected, "{src}"); + } +} + +#[test] +fn russian_input_preserves_reference_and_array_syntax() { + for (src, expected) in [ + ("СУММ({1\\2;3\\4};5)", "SUM({1,2;3,4},5)"), + ("{ИСТИНА\\ЛОЖЬ;TRUE\\FALSE}", "{TRUE,FALSE;TRUE,FALSE}"), + ("СУММ((A1:A3;C1:C3))", "SUM((A1:A3,C1:C3))"), + ("\"СУММ;ЛОЖЬ\"&\"a\"\"b\"", "\"СУММ;ЛОЖЬ\"&\"a\"\"b\""), + ("'СУММ;ЛОЖЬ'!A1+СУММ!B2", "'СУММ;ЛОЖЬ'!A1+СУММ!B2"), + ] { + assert_eq!(input(src), expected, "{src}"); + } + for bad in [r"СУММ({1;2\3})", "{СУММ(A1)}", "{ЛОЖЬ;}"] { + assert!(parse_input(bad, FormulaLocale::Ru, |_| false).is_err(), "{bad}"); + } + assert!(parse("СУММ(1;2)").is_err()); + assert!(parse("{ИСТИНА}").is_err()); +} + +#[test] +fn russian_input_preserves_defined_and_local_names() { + let known = |name: &str| ["СУММ", "ЛОЖЬ"].iter().any(|n| n.to_lowercase() == name.to_lowercase()); + let expr = parse_input("СУММ(2;3)+ЛОЖЬ+ЕСЛИ(ИСТИНА;4;5)", FormulaLocale::Ru, known).unwrap(); + assert_eq!(print(&expr), "СУММ(2,3)+ЛОЖЬ+IF(TRUE,4,5)"); + for (src, expected) in [ + ("LET(ЛОЖЬ;7;ЛОЖЬ)", "LET(ЛОЖЬ,7,ЛОЖЬ)"), + ("LET(ложь;ЛОЖЬ;ЕСЛИ(ложь;1;2))", "LET(ложь,FALSE,IF(ложь,1,2))"), + ("LET(СУММ;LAMBDA(x;x+1);СУММ(2))", "LET(СУММ,LAMBDA(x,x+1),СУММ(2))"), + ("LAMBDA(ЛОЖЬ;ЛОЖЬ)(7)", "LAMBDA(ЛОЖЬ,ЛОЖЬ)(7)"), + ] { + assert_eq!(input(src), expected, "{src}"); + } +} + +#[test] +fn russian_locale_controls_decimal_list_array_separators_and_display() { + let ru = FormulaLocale::Ru; + let en = FormulaLocale::En; + let expr = parse_input("ЕСЛИ(ИСТИНА;СУММ(1,5;2,25);0)", ru, |_| false).unwrap(); + assert_eq!(print(&expr), "IF(TRUE,SUM(1.5,2.25),0)"); + assert_eq!(print_input(&expr, ru, |_| false).unwrap(), "ЕСЛИ(ИСТИНА;СУММ(1,5;2,25);0)"); + assert_eq!(print_input(&expr, en, |_| false).unwrap(), print(&expr)); + assert!(parse_input("SUM(1;2)", en, |_| false).is_err()); + assert!(parse_input("СУММ(1.5;2)", ru, |_| false).is_err()); + let expr = parse("SUM({1.5,2.25;3,4})+SUM((A1:A3,C1:C3))").unwrap(); + let shown = print_input(&expr, ru, |_| false).unwrap(); + assert_eq!(print(&parse_input(&shown, ru, |_| false).unwrap()), print(&expr)); + let expr = parse("COUNT(A1:A3)+COUNTIF(B1:B3,\">0\")+MAX(1,2)").unwrap(); + let shown = print_input(&expr, ru, |_| false).unwrap(); + assert_eq!(shown, "СЧЁТ(A1:A3)+СЧЁТЕСЛИ(B1:B3;\">0\")+МАКС(1;2)"); + assert_eq!(print(&parse_input(&shown, ru, |_| false).unwrap()), print(&expr)); +} + +#[test] +fn russian_display_round_trips_text_containing_separators() { + let ru = FormulaLocale::Ru; + let expr = parse(r#"IF(A1="a,b;c",CONCAT("1.5",",",";"),SUM(0.25,A1:A3 B2))"#).unwrap(); + let shown = print_input(&expr, ru, |_| false).unwrap(); + assert_eq!(shown, r#"ЕСЛИ(A1="a,b;c";СЦЕП("1.5";",";";");СУММ(0,25;A1:A3 B2))"#); + assert_eq!(print(&parse_input(&shown, ru, |_| false).unwrap()), print(&expr)); +} + +#[test] +fn russian_table_has_unique_names() { + let mut seen = std::collections::HashSet::new(); + for name in ["СУММ", "СЧЁТ", "ЕСЛИ", "ВПР", "ЛОЖЬ"] { + assert!(seen.insert(FormulaLocale::Ru.canonical_name(name).to_string()), "{name}"); + } + assert_eq!(FormulaLocale::Ru.function_name("sum"), "СУММ"); + assert_eq!(FormulaLocale::Ru.canonical_name("счёт"), "COUNT"); + assert_eq!(FormulaLocale::Ru.canonical_name("СЧЕТ"), "COUNT"); + assert_eq!(FormulaLocale::Ru.canonical_name("UNKNOWN"), "UNKNOWN"); +} + +#[test] +fn russian_bound_names_keep_unicode_case_identity() { + assert_eq!(input("LET(ЛОЖЬ;7;ложь)"), "LET(ЛОЖЬ,7,ложь)"); + assert_eq!(input("LAMBDA(СУММ;сумм(2))(LAMBDA(x;x+1))"), "LAMBDA(СУММ,сумм(2))(LAMBDA(x,x+1))"); + assert_eq!(FormulaLocale::Ru.function_name("XLOOKUP"), "ПРОСМОТРX"); + assert_eq!(FormulaLocale::Ru.canonical_name("поискпозx"), "XMATCH"); +} + +#[test] +fn russian_errors_parse_print_and_preserve_canonical_file_syntax() { + use gridcraft_core::CellError; + for error in CellError::ALL { + let local = FormulaLocale::Ru.error_name(error); + let expr = parse_input(&format!("IFERROR({local};0)"), FormulaLocale::Ru, |_| false).unwrap(); + assert_eq!(print(&expr), format!("IFERROR({},0)", error.as_str())); + let shown = print_input(&expr, FormulaLocale::Ru, |_| false).unwrap(); + assert_eq!(shown, format!("ЕСЛИОШИБКА({local};0)")); + assert_eq!(print(&parse_input(&shown.to_lowercase(), FormulaLocale::Ru, |_| false).unwrap()), print(&expr)); + if local != error.as_str() { + assert!(parse(local).is_err()); + } + assert_eq!(input(&format!("{{{local}\\{}}}", error.as_str())), format!("{{{},{}}}", error.as_str(), error.as_str())); + } + assert_eq!(input("'СУММ'!#ССЫЛКА!"), print(&parse("'СУММ'!#REF!").unwrap())); + assert_eq!(input("\"#ССЫЛКА!\""), "\"#ССЫЛКА!\""); +} + +#[test] +fn russian_locale_keeps_flat_chains_iterative() { + let source = std::iter::repeat_n("СУММ(1)", 400).collect::>().join("+"); + let parsed = parse_input(&source, FormulaLocale::Ru, |_| false).unwrap(); + assert_eq!(print_input(&parsed, FormulaLocale::Ru, |_| false).unwrap(), source); +} diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index b9cddcb90a..98a2cebeb2 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -9,6 +9,7 @@ description = "GridCraft egui frontend (Excel-style UI); swappable — nothing b workspace = true [dependencies] +craft-i18n = { workspace = true } # The shared capture slot (`craft_ui::capture`) comes from the one craft-libs revision every # craft-libs dependency uses (see the root Cargo.toml). craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } diff --git a/crates/ui-egui/src/app/editing.rs b/crates/ui-egui/src/app/editing.rs index 6890fb9440..933a860f77 100644 --- a/crates/ui-egui/src/app/editing.rs +++ b/crates/ui-egui/src/app/editing.rs @@ -27,7 +27,7 @@ impl SheetApp { } else { match sh.cell(at) { Some(c) if c.formula.is_some() => formula_locale::display(&c.input_text(), locale, &d.wb, d.wb.active_sheet()), - _ => gridcraft_engine::display::edit_text(&d.wb, sh, at), + _ => formula_locale::edit_text(&d.wb, sh, at, locale), } }; let (text, replace) = match text { diff --git a/crates/ui-egui/src/dialogs/insert_function.rs b/crates/ui-egui/src/dialogs/insert_function.rs index b662d71975..3bf41049cf 100644 --- a/crates/ui-egui/src/dialogs/insert_function.rs +++ b/crates/ui-egui/src/dialogs/insert_function.rs @@ -13,13 +13,13 @@ pub(super) fn insert_function(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dia let locale = language.formula_locale(); let list = gridcraft_engine::cmd::formulas::function_list(); // The names and descriptions searched are the ones shown, so in the interface language. - let q = d.search.to_lowercase(); + let q = locale.fold_alias(&d.search); let hits: Vec<&Json> = list .iter() .filter(|f| { q.is_empty() - || f["name"].as_str().is_some_and(|n| locale.function_name(n).to_lowercase().contains(&q)) - || f["description"].as_str().is_some_and(|n| language.function_description(n).unwrap_or(n).to_lowercase().contains(&q)) + || f["name"].as_str().is_some_and(|n| locale.fold_alias(locale.function_name(n)).contains(&q) || locale.fold_alias(n).contains(&q)) + || f["description"].as_str().is_some_and(|n| locale.fold_alias(language.function_description(n).unwrap_or(n)).contains(&q)) }) .take(300) .collect(); diff --git a/crates/ui-egui/src/dialogs/show.rs b/crates/ui-egui/src/dialogs/show.rs index 07debbca69..83c6419f2e 100644 --- a/crates/ui-egui/src/dialogs/show.rs +++ b/crates/ui-egui/src/dialogs/show.rs @@ -307,41 +307,43 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { } } "saveChanges" => { - ui.label(format!("Do you want to save the changes you made to {}?", d.values.get("title").and_then(Json::as_str).unwrap_or("this workbook"))); + let language = app.ui.language; + let title = d.values.get("title").and_then(Json::as_str).unwrap_or(language.tr("this workbook")); + ui.label(craft_i18n::fmt(language.tr("Do you want to save the changes you made to {title}?"), &[("title", title)])); let quitting = d.values.get("quit").and_then(Json::as_bool) == Some(true); ui.horizontal(|ui| { for (label, answer) in [("Don't Save", crate::SaveAnswer::DontSave), ("Cancel", crate::SaveAnswer::Cancel), ("Save", crate::SaveAnswer::Save)] { - if ui.button(label).clicked() && app.answer_save_changes(answer, quitting, ui.ctx()) { + if ui.button(app.ui.language.tr(label)).clicked() && app.answer_save_changes(answer, quitting, ui.ctx()) { open = false; } } }); } "start" => { - ui.heading("Start a new workbook"); + ui.heading(app.ui.language.tr("Start a new workbook")); ui.horizontal_wrapped(|ui| { - if ui.button("Blank workbook").clicked() { + if ui.button(app.ui.language.tr("Blank workbook")).clicked() { app.run_or_alert("file.new", json!({})); open = false; } for (n, t) in gridcraft_engine::sample::SAMPLES { - if ui.button(*t).clicked() { + if ui.button(app.ui.language.tr(t)).clicked() { app.run_or_alert("file.new", json!({"sample": n})); open = false; } } }); ui.separator(); - ui.label(egui::RichText::new("Recent").strong()); + ui.label(egui::RichText::new(app.ui.language.tr("Recent")).strong()); for path in app.ui.recent.clone() { if ui.link(&path).clicked() { app.open_path(&path); open = false; } } - if ui.button("Open…").clicked() { + if ui.button(app.ui.language.tr("Open…")).clicked() { app.open_dialog("open", json!({})); open = false; } diff --git a/crates/ui-egui/src/editor.rs b/crates/ui-egui/src/editor.rs index 847afe3aed..b7c7dec04c 100644 --- a/crates/ui-egui/src/editor.rs +++ b/crates/ui-egui/src/editor.rs @@ -162,14 +162,14 @@ impl EditState { } let before = self.text.get(..self.byte_caret()).unwrap_or(""); let word: String = - before.chars().rev().take_while(|c| c.is_ascii_alphanumeric() || *c == '.' || *c == '_').collect::().chars().rev().collect(); + before.chars().rev().take_while(|c| c.is_alphanumeric() || *c == '.' || *c == '_').collect::().chars().rev().collect(); if word.is_empty() || word.chars().next().is_some_and(|c| c.is_ascii_digit()) { return; } - let u = word.to_ascii_uppercase(); + let u = self.locale.fold_alias(&word); // Not after a column letter of a reference like A1 (digits follow) – fine either way. - self.autocomplete = names.iter().filter(|n| n.starts_with(&u)).take(12).cloned().collect(); - if self.autocomplete.len() == 1 && self.autocomplete[0] == u { + self.autocomplete = names.iter().filter(|n| self.locale.fold_alias(n).starts_with(&u)).take(12).cloned().collect(); + if self.autocomplete.len() == 1 && self.locale.fold_alias(&self.autocomplete[0]) == u { self.autocomplete.clear(); } self.ac_index = self.ac_index.min(self.autocomplete.len().saturating_sub(1)); @@ -180,7 +180,7 @@ impl EditState { let Some(name) = self.autocomplete.get(self.ac_index).cloned() else { return false }; let caret_b = self.byte_caret(); let before = self.text.get(..caret_b).unwrap_or(""); - let wlen: usize = before.chars().rev().take_while(|c| c.is_ascii_alphanumeric() || *c == '.' || *c == '_').map(char::len_utf8).sum(); + let wlen: usize = before.chars().rev().take_while(|c| c.is_alphanumeric() || *c == '.' || *c == '_').map(char::len_utf8).sum(); let start = caret_b - wlen; let (Some(a), Some(b)) = (self.text.get(..start), self.text.get(caret_b..)) else { return false }; self.text = format!("{a}{name}({b}"); @@ -210,7 +210,7 @@ impl EditState { } let head = before.strip_suffix('(').unwrap_or(before); let name: String = - head.chars().rev().take_while(|c| c.is_ascii_alphanumeric() || *c == '.' || *c == '_').collect::().chars().rev().collect(); + head.chars().rev().take_while(|c| c.is_alphanumeric() || *c == '.' || *c == '_').collect::().chars().rev().collect(); if name.is_empty() || name.starts_with(|c: char| c.is_ascii_digit()) { return false; } @@ -258,7 +258,7 @@ impl EditState { let name: String = chars[..i] .iter() .rev() - .take_while(|c| c.is_ascii_alphanumeric() || **c == '.' || **c == '_') + .take_while(|c| c.is_alphanumeric() || **c == '.' || **c == '_') .collect::() .chars() .rev() @@ -266,7 +266,7 @@ impl EditState { if name.is_empty() { return None; } - return Some((name.to_ascii_uppercase(), arg)); + return Some((self.locale.normalize_function_name(&name), arg)); } depth -= 1; } diff --git a/crates/ui-egui/src/formula_bar.rs b/crates/ui-egui/src/formula_bar.rs index 574f766de9..64c0f769fd 100644 --- a/crates/ui-egui/src/formula_bar.rs +++ b/crates/ui-egui/src/formula_bar.rs @@ -181,7 +181,7 @@ fn active_input(app: &SheetApp) -> String { // A legacy array formula shows in braces (`{=A1:A2*2}`), as `Cell::bar_text` does. if f.array.is_some() { format!("{{{shown}}}") } else { shown } } else { - gridcraft_engine::display::edit_text(&d.wb, sh, a) + crate::formula_locale::edit_text(&d.wb, sh, a, app.ui.language.formula_locale()) }; } String::new() diff --git a/crates/ui-egui/src/formula_locale.rs b/crates/ui-egui/src/formula_locale.rs index d53c5fa341..fb5b550dfd 100644 --- a/crates/ui-egui/src/formula_locale.rs +++ b/crates/ui-egui/src/formula_locale.rs @@ -21,16 +21,22 @@ 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 leading =/+/- convention, with localized identifiers (no trimming). 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 is_formula_entry(body) { 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 expr = formula::parse_closing_parens(body, |text| formula::parse_input(text, locale, known)); return Ok(format!("={}", formula::print(&expr?))); } + if let Some(error) = locale.parse_error(text) { + return Ok(error.as_str().to_string()); + } + for value in ["TRUE", "FALSE"] { + if locale.fold_alias(text) == locale.fold_alias(locale.function_name(value)) { + return Ok(value.to_string()); + } + } // 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); @@ -40,6 +46,10 @@ pub(crate) fn canonical( Ok(text.to_string()) } +fn is_formula_entry(text: &str) -> bool { + text.starts_with('=') || ((text.starts_with('+') || text.starts_with('-')) && text.chars().nth(1).is_some_and(|c| c.is_alphabetic() || c == '(')) +} + pub(crate) fn display(text: &str, locale: FormulaLocale, wb: &Workbook, sheet: usize) -> String { // English (and every language without a formula table) shows the stored text, `_xludf.` // prefixes aside, and skips a parse per frame. @@ -54,6 +64,41 @@ pub(crate) fn display(text: &str, locale: FormulaLocale, wb: &Workbook, sheet: u hide_udf_prefix(&shown, locale) } +/// Display spelling of Boolean/error constants, independent of the grid's layout and formatting. +pub(crate) fn localized_constant(value: &gridcraft_engine::core::Value, locale: FormulaLocale) -> Option<&'static str> { + use gridcraft_engine::core::Value; + match value { + Value::Error(error) => Some(locale.error_name(*error)), + Value::Bool(value) => Some(locale.function_name(if *value { "TRUE" } else { "FALSE" })), + _ => None, + } +} + +/// Localizes a typed value without reinterpreting literal cell text on an unchanged edit. +pub(crate) fn edit_text( + wb: &Workbook, + sheet: &gridcraft_engine::model::Sheet, + cell: gridcraft_engine::core::CellRef, + locale: FormulaLocale, +) -> String { + use gridcraft_engine::core::Value; + let raw = gridcraft_engine::display::edit_text(wb, sheet, cell); + if locale == FormulaLocale::En || wb.styles.get(sheet.style_id(cell)).num_fmt.as_str() == "@" { + return raw; + } + match sheet.value(cell) { + Value::Text(text) + if !raw.starts_with('\'') + && (is_formula_entry(&text) + || ["TRUE", "FALSE"].iter().any(|v| locale.fold_alias(&text) == locale.fold_alias(locale.function_name(v))) + || locale.parse_error(&text).is_some()) => + { + format!("'{raw}") + } + value => localized_constant(&value, locale).map_or(raw, str::to_string), + } +} + const UDF_PREFIX: &str = "_xludf."; fn has_udf_prefix(text: &str) -> bool { @@ -171,3 +216,19 @@ mod tests { assert_eq!(sig("IF", En, Language::En), "IF(logical_test, [value_if_true], [value_if_false])"); } } + +#[cfg(test)] +mod russian_help_tests { + use super::*; + #[test] + fn russian_help_uses_catalog_signature_and_selected_syntax() { + let translated_number = Language::Ru.function_argument("number").unwrap(); + let expected = format!("СУММ({translated_number}1; [{translated_number}2]; ...)"); + assert_eq!(signature("сумм", FormulaLocale::Ru, Language::Ru), Some(expected)); + assert_eq!(signature("SUM", FormulaLocale::En, Language::Ru), Some(format!("SUM({translated_number}1, [{translated_number}2], ...)"))); + let english = crate::formula_bar::function_description("IF").unwrap(); + let expected = Language::Ru.function_description(&english); + assert!(expected.is_some()); + assert_eq!(description("ЕСЛИ", FormulaLocale::Ru, Language::Ru).as_deref(), expected); + } +} diff --git a/crates/ui-egui/src/grid/quadrant.rs b/crates/ui-egui/src/grid/quadrant.rs index e5fdd9c924..34240854a0 100644 --- a/crates/ui-egui/src/grid/quadrant.rs +++ b/crates/ui-egui/src/grid/quadrant.rs @@ -328,8 +328,11 @@ fn paint_cell(q: &QuadrantPaint<'_>, painted: &(CellRef, Rect, Look, Option gridcraft_engine::formula::FormulaLocale { - if self == Self::Es { gridcraft_engine::formula::FormulaLocale::Es } else { gridcraft_engine::formula::FormulaLocale::En } + match self { + Self::Es => gridcraft_engine::formula::FormulaLocale::Es, + Self::Ru => gridcraft_engine::formula::FormulaLocale::Ru, + _ => gridcraft_engine::formula::FormulaLocale::En, + } } /// Translates an interface label; untranslated labels fall back to English. @@ -194,7 +200,12 @@ impl Language { /// A label that needs two translations (Home's text "Orientation" vs Page Layout's print /// "Orientation") is keyed `context|label`: English shows only the part after the `|`. pub fn tr(self, text: &str) -> &str { - let translated = match self { + self.translation(text).unwrap_or_else(|| text.rsplit_once('|').map_or(text, |(_, label)| label)) + } + + /// Raw catalog lookup for contextual labels and shared message templates. + pub fn translation(self, text: &str) -> Option<&'static str> { + match self { Self::En => None, Self::Es => es::translate(text), Self::El => el::translate(text), @@ -214,17 +225,28 @@ impl Language { Self::Vi => vi::translate(text), Self::Nl => nl::translate(text), Self::Ro => ro::translate(text), - }; - translated.unwrap_or_else(|| text.rsplit_once('|').map_or(text, |(_, label)| label)) + } + } + + /// Ribbon tabs can be nouns while their corresponding commands are verbs. + pub fn tr_tab(self, text: &str) -> &str { + self.translation(&format!("Tab|{text}")).unwrap_or_else(|| self.tr(text)) } pub(crate) fn tr_error(self, message: &str) -> String { - let message = if message.starts_with("command `") { crate::clean_error(message) } else { message.to_owned() }; + let message = if message.starts_with("command `") || message.starts_with("invalid parameters for `") { + crate::clean_error(message) + } else { + message.to_owned() + }; let translated = self.tr(&message); let fallback = message.rsplit_once('|').map_or(message.as_str(), |(_, label)| label); - if translated == fallback { message } else { translated.to_owned() } + if translated != fallback { + return translated.to_owned(); + } + error_templates::format_message(&message, self).unwrap_or(message) } /// A worksheet function's description in this language, looked up by the English text of the @@ -243,6 +265,7 @@ impl Language { fn function_help(self) -> Option<(HelpTable, HelpTable)> { match self { Self::Es => Some((es_functions::DESCRIPTIONS, es_functions::ARGUMENTS)), + Self::Ru => Some((ru_functions::DESCRIPTIONS, ru_functions::ARGUMENTS)), _ => None, } } @@ -517,3 +540,30 @@ mod tests { assert_eq!(name_sort_key("Čeština"), "cestina"); } } + +#[cfg(test)] +mod russian_tests { + use super::*; + #[test] + fn russian_error_messages_preserve_data_and_clean_command_wrappers() { + let ru = Language::Ru; + assert_eq!(ru.tr_error("command `chart` is not available right now: Select a chart first."), ru.tr("Select a chart first.")); + assert_eq!( + ru.tr_error("invalid parameters for `cell.set`: invalid cell reference `Лист{key}` for `cell`"), + "Неверная ссылка на ячейку `Лист{key}` в параметре `cell`" + ); + assert_eq!(ru.tr_error("unexpected end of formula"), ru.tr("unexpected end of formula")); + assert_eq!(ru.tr_tab("Insert"), ru.tr("Tab|Insert")); + assert_eq!(ru.tr_error("Sheet|Unknown failure"), "Sheet|Unknown failure"); + let mut session = gridcraft_engine::Session::new(); + session.new_workbook(); + let error = session.execute("cell.set", serde_json::json!({"cell":"A1","input":"=SUM()"})).unwrap_err().to_string(); + let translated = ru.tr_error(&error); + assert!(translated.contains(ru.formula_locale().function_name("SUM")), "{translated}"); + assert!(translated.contains(ru.function_argument("number").unwrap()), "{translated}"); + let error = session.execute("cell.set", serde_json::json!({"cell":"Лист{key}","input":"1"})).unwrap_err().to_string(); + let translated = ru.tr_error(&error); + assert!(translated.contains("Лист{key}")); + assert_ne!(translated, error); + } +} diff --git a/crates/ui-egui/src/i18n/error_templates.rs b/crates/ui-egui/src/i18n/error_templates.rs new file mode 100644 index 0000000000..e050b13e5e --- /dev/null +++ b/crates/ui-egui/src/i18n/error_templates.rs @@ -0,0 +1,119 @@ +//! Shared dynamic UI error messages, translated by any language catalog. Captured workbook data is not translated or reinterpreted as a template. + +const TEMPLATES: &[&str] = &[ + "{function} has too few arguments. Use {signature}", + "{function} has too many arguments. Use {signature}", + "no sheet named `{sheet}`", + "not a range: `{range}`", + "missing `{parameter}`", + "`{cell}` for `{key}` names a sheet other than the one this command works on", + "unknown command `{command}`", + "formula is longer than {limit} characters", + "bad number `{number}`", + "number too large `{number}`", + "unexpected character `{character}`", + "invalid cell reference `{cell}` for `{key}`", + "expected `,` or `)`, found {token}", + "expected a reference after the sheet name, found {token}", + "expected a number in array, found {token}", + "array constants may only contain constants, found {token}", + "expected {expected}, found {actual}", + "unexpected {token}", +]; + +/// Matches an entire known message. Unknown messages remain untouched by the caller. +pub(super) fn format_message(message: &str, language: super::Language) -> Option { + if let Some(detail) = message.strip_prefix("There's a problem with this formula: ") { + let detail = language.translation(detail).map(str::to_owned).or_else(|| translate_template(detail, language))?; + return Some(format!("{} {detail}", language.translation("There's a problem with this formula:")?)); + } + translate_template(message, language) +} + +fn translate_template(message: &str, language: super::Language) -> Option { + TEMPLATES.iter().find_map(|source| fill(message, source, language.translation(source)?, language)) +} + +/// Resolves named slots once so braces inside a filename or sheet name stay literal. +fn fill(message: &str, source: &str, translated: &str, language: super::Language) -> Option { + let mut rest = message; + let mut pattern = source; + let mut values = Vec::new(); + while let Some((prefix, slot)) = pattern.split_once('{') { + rest = rest.strip_prefix(prefix)?; + let (name, tail) = slot.split_once('}')?; + let delimiter = tail.split('{').next()?; + let end = if delimiter.is_empty() { rest.len() } else { rest.find(delimiter)? }; + values.push((name, rest.get(..end)?)); + rest = rest.get(end..)?; + pattern = tail; + } + if rest != pattern { + return None; + } + let locale = language.formula_locale(); + let mut rendered = Vec::with_capacity(values.len()); + for (name, value) in &values { + let value = match *name { + "function" => locale.function_name(value).to_string(), + "signature" => { + let function = values.iter().find(|(key, _)| *key == "function")?.1; + crate::formula_locale::signature(function, locale, language).unwrap_or_else(|| value.to_string()) + } + _ => value.to_string(), + }; + rendered.push((*name, value)); + } + let args: Vec<(&str, &str)> = rendered.iter().map(|(key, value)| (*key, value.as_str())).collect(); + Some(craft_i18n::fmt(translated, &args)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parameters_are_literal_and_messages_are_anchored() { + assert_eq!(format_message("bad number `abc`", super::super::Language::En), None); + assert_eq!( + fill("bad number `abc`", "bad number `{number}`", "Nombre incorrect : {number}", super::super::Language::Fr), + Some("Nombre incorrect : abc".into()) + ); + assert_eq!( + format_message("invalid cell reference `Лист{key}` for `cell`", super::super::Language::Ru), + Some("Неверная ссылка на ячейку `Лист{key}` в параметре `cell`".into()) + ); + assert_eq!(format_message("prefix: bad number `abc`", super::super::Language::Ru), None); + assert_eq!(format_message("bad number `abc` suffix", super::super::Language::Ru), None); + assert_eq!(format_message("Printer Office: permission denied", super::super::Language::Ru), None); + } +} + +#[cfg(test)] +mod contract_tests { + use super::*; + #[test] + fn translated_templates_preserve_slots_for_every_catalog() { + let names = |text: &str| { + let mut names: Vec<&str> = text.split('{').skip(1).filter_map(|s| s.split_once('}').map(|(name, _)| name)).collect(); + names.sort(); + names.into_iter().map(str::to_owned).collect::>() + }; + for language in crate::i18n::Language::ALL { + for source in TEMPLATES { + if let Some(translated) = language.translation(source) { + assert_eq!(names(source), names(translated), "{language:?}: {source}"); + } + } + } + } + + #[test] + fn actual_parser_diagnostics_are_translated_without_changing_the_parser() { + for input in ["=1+", "=#UNKNOWN!", "=1e999", "=SUM(1;2)", "=SUM({1;})"] { + let error = gridcraft_engine::formula::parse(input).unwrap_err().to_string(); + let translated = crate::i18n::Language::Ru.tr_error(&error); + assert_ne!(translated, error, "{input}: {error}"); + } + } +} diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index b0e894095b..e878af8b56 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -1,6 +1,5 @@ //! Russian translations for the interface strings in i18n.rs. -/// Returns the Russian translation for `text`, or `None` so callers fall back to English. pub(super) fn translate(text: &str) -> Option<&'static str> { Some(match text { "Paste" => "Вставить", @@ -209,9 +208,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Page Break\nPreview" => "Предварительный просмотр\nразрывов страниц", "Page\nLayout" => "Разметка\nстраницы", "Formula Bar" => "Строка формул", - "Name Box" => "Поле имени", - "Cell Editor" => "Редактор ячейки", - "Font Size" => "Размер шрифта", "Gridlines" => "Сетка", "Headings" => "Заголовки", "Zoom" => "Масштаб", @@ -311,7 +307,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Dark" => "Тёмная", "Sheet Options|View" => "На экране", "Print" => "Печать", - "Print What" => "Что печатать", // Page Layout view and the Header & Footer tab. "Click to add data" => "Щелкните, чтобы добавить данные", "Add header" => "Добавить верхний колонтитул", @@ -425,26 +420,606 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Save .xlsx workbooks automatically after every change" => "Автоматически сохранять книги .xlsx после каждого изменения", "Workbook calculation" => "Вычисления в книге", "Enable iterative calculation" => "Включить итеративные вычисления", - "Maximum Iterations:" => "Предельное число итераций:", - "Maximum Change:" => "Максимальное изменение:", - "Maximum Iterations must be a whole number from 1 to 32767." => "Предельное число итераций должно быть целым числом от 1 до 32767.", - "Maximum Change must be a number of 0 or more." => "Максимальное изменение должно быть числом не меньше 0.", "Calculation settings are saved with the workbook." => "Параметры вычислений сохраняются вместе с книгой.", + " (password)" => " (пароль)", + " (password, unlocked)" => " (пароль, разблокировано)", + "$ English (United States)" => "$ английский (США)", + "'{file}' is protected." => "Файл «{file}» защищён.", + "(No suggestions)" => "(Нет вариантов)", + "(unnamed)" => "(без имени)", + "+ Add Level" => "+ Добавить уровень", + "100% Stacked Bar" => "Нормированная линейчатая", + "100% Stacked Column" => "Нормированная гистограмма", + "A clean-room, open-source, Rust-native spreadsheet. Part of ArtCraft." => { + "Электронные таблицы с открытым исходным кодом, написанные с нуля на Rust. Часть ArtCraft." + } + "A to Z" => "От А до Я", + "About" => "О программе", + "Accessibility" => "Доступность", + "Action" => "Действие", + "Action Journal" => "Журнал действий", + "Add" => "Добавить", + "Add Chart Element" => "Добавить элемент диаграммы", + "Add subtotal to (columns, e.g. C,D):" => "Добавить итоги по (столбцы, например C,D):", + "Add to Dictionary" => "Добавить в словарь", + "Add-in and Automation" => "Надстройки и автоматизация", + "Agent Control" => "Управление агентами", + "Aggregate duplicates using" => "Объединять повторы с помощью", + "Align Left" => "Выровнять по левому краю", + "Align Right" => "Выровнять по правому краю", + "Alignment" => "Выравнивание", + "All" => "Все", + "All Charts" => "Все диаграммы", + "All done. We made {count} replacements." => "Готово. Выполнено замен: {count}.", + "All except borders" => "Всё, кроме границ", + "Allow" => "Разрешить", + "Allow all users of this sheet to:" => "Разрешить всем пользователям этого листа:", + "Alpha:" => "Альфа:", + "Analysis Tools" => "Инструменты анализа", + "Anova: Single Factor" => "Дисперсионный анализ: один фактор", + "Any value" => "Любое значение", + "Area" => "С областями", + "At each change in (column letter):" => "При каждом изменении в (буква столбца):", + "Automation in this workbook. GridCraft runs its own scripts; a file's VBA project is preserved but not executed." => { + "Автоматизация в этой книге. GridCraft выполняет собственные сценарии; проект VBA из файла сохраняется, но не выполняется." + } + "Average" => "Среднее", + "Background Color:" => "Цвет фона:", + "Before sheet (position, 0 = first):" => "Перед листом (позиция, 0 = первый):", + "Bin Range (blank for automatic bins):" => "Диапазон интервалов (пусто — автоматически):", + "Blanks" => "Пустые ячейки", + "Border" => "Граница", + "Bottom" => "Нижняя", + "Bottom Align" => "Выровнять по нижнему краю", + "Bottom margin (in):" => "Нижнее поле (дюймы):", + "By Changing Variable Cells:" => "Изменять ячейки переменных:", + "By changing cell:" => "Изменять ячейку:", + "Calculate Now (F9)" => "Пересчитать сейчас (F9)", + "Calculate Sheet (⇧F9)" => "Пересчитать лист (⇧F9)", + "Calculation Options" => "Параметры вычислений", + "Cancel" => "Отмена", + "Category:" => "Категория:", + "Cell Color" => "Цвет ячейки", + "Cell Styles" => "Стили ячеек", + "Cell Values" => "Значения ячеек", + "Center Across Selection" => "По центру выделения", + "Change" => "Заменить", + "Change All" => "Заменить все", + "Change Chart Type" => "Изменить тип диаграммы", + "Chart" => "Диаграмма", + "Chart Title" => "Название диаграммы", + "Chart data range:" => "Диапазон данных для диаграммы:", + "Check Accessibility" => "Проверить доступность", + "Choose a PNG or JPEG no larger than 16 MB." => "Выберите файл PNG или JPEG размером не более 16 МБ.", + "Clear Filter" => "Очистить фильтр", + "Clustered Bar" => "Линейчатая с группировкой", + "Clustered Column" => "Гистограмма с группировкой", + "Color:" => "Цвет:", + "Column Width" => "Ширина столбца", + "Column chart" => "Гистограмма", + "Column or Bar" => "Гистограмма или линейчатая", + "Column width (characters):" => "Ширина столбца (символы):", + "Column widths" => "Ширины столбцов", + "Columns" => "Столбцы", + "Columns (blank = all, e.g. A,C):" => "Столбцы (пусто = все, например A,C):", + "Combo" => "Комбинированная", + "Comma" => "Запятая", + "Comment:" => "Комментарий:", + "Comments and notes (C)" => "Комментарии и заметки (C)", + "Comments pane" => "Область комментариев", + "Compatibility" => "Совместимость", + "Conditional Formatting Rules Manager" => "Диспетчер правил условного форматирования", + "Conditional formats" => "Условные форматы", + "Confidence Level (%):" => "Уровень доверия (%):", + "Confidence Level for Mean (%), blank for none:" => "Уровень доверия для среднего (%), пусто — нет:", + "Confidence interval" => "Доверительный интервал", + "Confidence level (%):" => "Уровень доверия (%):", + "Constant is Zero" => "Константа равна нулю", + "Contributors" => "Участники", + "Convert Text to Columns" => "Преобразовать текст в столбцы", + "Convert the last ink stroke to a shape" => "Преобразовать последний рукописный штрих в фигуру", + "Convert to Range" => "Преобразовать в диапазон", + "Correlation" => "Корреляция", + "Count" => "Количество", + "CountA" => "Количество значений", + "Covariance" => "Ковариация", + "Create Forecast Worksheet" => "Создать лист прогноза", + "Create Sparklines" => "Создать спарклайны", + "Create a copy" => "Создать копию", + "Cube" => "Куб", + "Cumulative Percentage" => "Накопленный процент", + "Current region" => "Текущая область", + "Custom (%):" => "Свой (%):", + "Custom Sort" => "Настраиваемая сортировка", + "Damping factor:" => "Коэффициент затухания:", + "Data Range:" => "Диапазон данных:", + "Data validation" => "Проверка данных", + "Database" => "Базы данных", + "Date & Time" => "Дата и время", + "Date unit" => "Единица даты", + "Day" => "День", + "Decimal" => "Десятичное число", + "Define Name…" => "Задать имя…", + "Delete Comment" => "Удалить комментарий", + "Delete Sheet" => "Удалить лист", + "Delete chart" => "Удалить диаграмму", + "Delete rows" => "Удалять строки", + "Delete…" => "Удалить…", + "Description (optional):" => "Описание (необязательно):", + "Descriptive Statistics" => "Описательная статистика", + "Destination:" => "Место назначения:", + "Diagonal ↗" => "Диагональ ↗", + "Diagonal ↘" => "Диагональ ↘", + "Dismiss" => "Закрыть", + "Display:" => "Отображать:", + "Distributed" => "Распределённый", + "Divide" => "Разделить", + "Do you want to save the changes you made to {title}?" => "Сохранить изменения в «{title}»?", + "Don't Save" => "Не сохранять", + "Double" => "Двойное", + "Double Accounting" => "Двойное бухгалтерское", + "Doughnut" => "Кольцевая", + "Down" => "Вниз", + "Edit Name" => "Изменить имя", + "Edit chart title" => "Изменить название диаграммы", + "Edit…" => "Изменить…", + "Engineering" => "Инженерные", + "Entire column" => "Весь столбец", + "Entire row" => "Вся строка", + "Erase ink strokes" => "Стереть рукописные штрихи", + "Error message:" => "Сообщение об ошибке:", + "Error style" => "Тип сообщения", + "Errors" => "Ошибки", + "Every command run in this session (replayable)" => "Все команды, выполненные в этом сеансе (можно повторить)", + "Expand formula bar" => "Развернуть строку формул", + "Exponential Smoothing" => "Экспоненциальное сглаживание", + "F-Test Two-Sample for Variances" => "F-тест для дисперсий двух выборок", + "Fill missing points using" => "Заполнить отсутствующие точки с помощью", + "Filter (⌘⇧F)" => "Фильтр (⌘⇧F)", + "Filter by Selected Cell's Value" => "Фильтр по значению выделенной ячейки", + "Find" => "Найти", + "Find & Select" => "Найти и выделить", + "Find All" => "Найти все", + "Find Next" => "Найти следующее", + "Find and Replace" => "Найти и заменить", + "Find what:" => "Что искать:", + "Fits inside the selected cell and moves with its content." => "Помещается в выделенную ячейку и перемещается вместе с её содержимым.", + "Flash Fill (⌘E)" => "Мгновенное заполнение (⌘E)", + "Font" => "Шрифт", + "Footer (in):" => "Нижний колонтитул (дюймы):", + "Footer:" => "Нижний колонтитул:", + "Forecast Sheet" => "Лист прогноза", + "Forecast end (date or number; blank: a third of the history):" => "Завершение прогноза (дата или число; пусто — треть истории):", + "Format Cells" => "Формат ячеек", + "Format Cells|Fill" => "Заливка", + "Format Chart pane" => "Область «Формат диаграммы»", + "Format as Table" => "Форматировать как таблицу", + "Format cells" => "Форматировать ячейки", + "Format cells that are (value):" => "Форматировать ячейки, которые (значение):", + "Format cells that contain a date occurring:" => "Форматировать ячейки, содержащие дату:", + "Format cells that contain the text:" => "Форматировать ячейки, содержащие текст:", + "Format columns" => "Форматировать столбцы", + "Format rows" => "Форматировать строки", + "Format values where this formula is true:" => "Форматировать значения, для которых эта формула истинна:", + "Formats (T)" => "Форматы (T)", + "Formula Builder" => "Построитель формул", + "Formula:" => "Формула:", + "Formulas (F)" => "Формулы (F)", + "Formulas and number formats" => "Формулы и форматы чисел", + "Funnel" => "Воронка", + "GRG Nonlinear" => "Нелинейный метод ОПГ", + "GRG Nonlinear: a local optimum of a smooth model, from the current values." => { + "Нелинейный метод ОПГ: локальный оптимум гладкой модели, начиная с текущих значений." + } + "Go To" => "Перейти", + "Go To Special" => "Переход к особым ячейкам", + "Goal Seek" => "Подбор параметра", + "Goal Seeking found a solution: {value}" => "Подбор параметра нашёл решение: {value}", + "Gradebook" => "Журнал оценок", + "Green Fill with Dark Green Text" => "Зелёная заливка и тёмно-зелёный текст", + "GridCraft is fully drivable by agents. Every menu item, button and gesture is a command:" => { + "GridCraft полностью управляется агентами. Каждый пункт меню, кнопка и жест — это команда:" + } + "Grouped By:" => "Группировать по:", + "Growth" => "Рост", + "Header (&L left, &C center, &R right; &P page, &N pages, &D date, &A sheet):" => { + "Верхний колонтитул (&L слева, &C по центру, &R справа; &P страница, &N страниц, &D дата, &A лист):" + } + "Header (in):" => "Верхний колонтитул (дюймы):", + "Hidden" => "Скрытая", + "Hidden: {sheets}" => "Скрытые: {sheets}", + "Hide" => "Скрыть", + "Hide Sheet" => "Скрыть лист", + "Hierarchy & more" => "Иерархия и другие", + "Highlighter" => "Маркер", + "Histogram" => "Гистограмма распределения", + "Horizontal (Category) Axis Labels:" => "Подписи горизонтальной оси (категории):", + "Horizontal axis title:" => "Название горизонтальной оси:", + "Household Budget" => "Семейный бюджет", + "How agents drive GridCraft (MCP / control channel)" => "Как агенты управляют GridCraft (MCP / канал управления)", + "Hypothesized Mean Difference:" => "Предполагаемая разность средних:", + "Ignore Once" => "Пропустить один раз", + "Ignore blank" => "Игнорировать пустые ячейки", + "Import a text or CSV file" => "Импорт текстового файла или CSV", + "In the last 7 days" => "За последние 7 дней", + "In-cell dropdown" => "Раскрывающийся список в ячейке", + "Include forecast statistics" => "Включить статистику прогноза", + "Indent" => "Отступ", + "Information" => "Сведения", + "Input Range:" => "Входной диапазон:", + "Input X Range:" => "Входной диапазон X:", + "Input Y Range:" => "Входной диапазон Y:", + "Input message:" => "Сообщение для ввода:", + "Insert Calculated Field" => "Вставить вычисляемое поле", + "Insert Chart" => "Вставить диаграмму", + "Insert Function" => "Вставить функцию", + "Insert Function (⇧F3)" => "Вставить функцию (⇧F3)", + "Insert Hyperlink" => "Вставить гиперссылку", + "Insert Link (⌘K)" => "Вставить ссылку (⌘K)", "Insert Sheet" => "Вставить лист", + "Insert Slicer" => "Вставить срез", + "Insert a picture" => "Вставить рисунок", + "Insert a slicer to filter a table" => "Вставить срез для фильтрации таблицы", + "Insert an icon" => "Вставить значок", + "Insert rows" => "Вставлять строки", + "Insert…" => "Вставить…", + "Interpolation" => "Интерполяция", + "Interval:" => "Интервал:", + "Join the ArtCraft Discord" => "Присоединиться к Discord ArtCraft", + "Justify" => "По ширине", + "Keep" => "Оставить", + "Keep Solver Solution" => "Оставить найденное решение", + "Kth Largest (blank for none):" => "K-й наибольший (пусто — нет):", + "Kth Smallest (blank for none):" => "K-й наименьший (пусто — нет):", + "Labels in first row" => "Метки в первой строке", + "Last cell" => "Последняя ячейка", + "Last month" => "В прошлом месяце", + "Last week" => "На прошлой неделе", + "Left margin (in):" => "Левое поле (дюймы):", + "Legend Entries (Series)" => "Элементы легенды (ряды)", + "Light Red Fill" => "Светло-красная заливка", + "Light Red Fill with Dark Red Text" => "Светло-красная заливка и тёмно-красный текст", + "Line" => "График", + "Line chart" => "График", + "Line or Area" => "График или диаграмма с областями", + "Line with Markers" => "График с маркерами", + "Linear" => "Линейная", + "Link to (web address, e-mail or Sheet!A1):" => "Ссылка на (веб-адрес, электронная почта или Лист!A1):", + "Link…" => "Ссылка…", + "List" => "Список", + "Location Range:" => "Диапазон расположения:", + "Locked" => "Заблокированная", + "Locking cells or hiding formulas has no effect until you protect the worksheet (Review › Protect Sheet)." => { + "Защита ячеек и скрытие формул действуют только после защиты листа (Рецензирование › Защитить лист)." + } + "Lookup & Reference" => "Поиск и ссылки", + "Macros" => "Макросы", + "Macros and scripts in this workbook (Alt+F8)" => "Макросы и сценарии этой книги (Alt+F8)", + "Make Unconstrained Variables Non-Negative" => "Сделать переменные без ограничений неотрицательными", + "Markers" => "Маркеры", + "Match case" => "Учитывать регистр", + "Match entire cell contents" => "Совпадение со всем содержимым ячейки", + "Math & Trig" => "Математика и тригонометрия", + "Max" => "Максимум", + "Maximum:" => "Максимум:", + "Median" => "Медиана", + "Middle Align" => "Выровнять по середине", + "Min" => "Минимум", + "Minimum / Source / Formula:" => "Минимум / источник / формула:", + "Models" => "Модели", + "Modify" => "Изменить", + "Month" => "Месяц", + "More commands" => "Другие команды", + "Move or Copy" => "Переместить или скопировать", + "Move or Copy Sheet…" => "Переместить или скопировать лист…", + "Moving Average" => "Скользящее среднее", + "Multiply" => "Умножить", + "My data has headers" => "Мои данные содержат заголовки", + "My list has headers" => "Мои данные содержат заголовки", + "Name" => "Имя", + "Name Manager" => "Диспетчер имён", + "Name:" => "Имя:", + "New Comment" => "Новый комментарий", + "New Name" => "Новое имя", + "New Worksheet Ply:" => "Новый лист:", + "New sheet (⇧F11)" => "Новый лист (⇧F11)", + "New title (Modify):" => "Новое название (изменить):", + "New…" => "Создать…", + "Next month" => "В следующем месяце", + "Next sheet" => "Следующий лист", + "Next week" => "На следующей неделе", + "No names defined" => "Имена не определены", + "No picture selected" => "Рисунок не выбран", + "No ranges yet." => "Диапазонов пока нет.", + "None" => "Нет", + "Normal Probability" => "Нормальная вероятность", + "Not in Dictionary ({cell}):" => "Нет в словаре ({cell}):", + "Note:" => "Заметка:", + "Numbers" => "Числа", + "OK" => "ОК", + "Open…" => "Открыть…", + "Operation" => "Операция", + "Orientation (degrees)" => "Ориентация (градусы)", + "Other:" => "Другой:", + "Outline" => "Контур", + "Output Range:" => "Выходной диапазон:", + "Output options" => "Параметры вывода", + "Page Breaks" => "Разрывы страниц", + "Page Setup" => "Параметры страницы", + "Paper Size" => "Размер бумаги", + "Pareto (sorted histogram)" => "Парето (отсортированная гистограмма)", + "Password" => "Пароль", + "Password (optional):" => "Пароль (необязательно):", + "Password:" => "Пароль:", + "Paste (⌘V)" => "Вставить (⌘V)", + "Paste Special" => "Специальная вставка", + "Pick From Drop-down List…" => "Выбрать из раскрывающегося списка…", + "Pick From List" => "Выбрать из списка", + "Picture file path (PNG or JPEG):" => "Путь к файлу рисунка (PNG или JPEG):", + "Pie" => "Круговая", + "Pie or Doughnut" => "Круговая или кольцевая", + "Place Picture in Cell" => "Поместить рисунок в ячейку", + "Place Picture over Cells" => "Поместить рисунок поверх ячеек", + "Previous sheet" => "Предыдущий лист", + "Print Area" => "Область печати", + "Print Titles" => "Печатать заголовки", + "Product" => "Произведение", + "Protect Sheet" => "Защитить лист", + "Protect Structure and Windows" => "Защитить структуру и окна", + "Protect Workbook structure" => "Защитить структуру книги", + "Protect workbook for: Structure (sheets can't be added, moved, renamed, hidden or deleted)." => { + "Защитить книгу: структуру (листы нельзя добавлять, перемещать, переименовывать, скрывать и удалять)." + } + "Protection" => "Защита", + "Quarterly Sales Dashboard" => "Сводка продаж по кварталам", + "Radar" => "Лепестковая", + "Range password:" => "Пароль диапазона:", + "Ranges that stay editable when the sheet is protected:" => "Диапазоны, которые можно изменять на защищённом листе:", + "Rank and Percentile" => "Ранг и персентиль", + "Recommended Charts" => "Рекомендуемые диаграммы", + "Red Border" => "Красная граница", + "Red Text" => "Красный текст", + "Redo (⌘Y)" => "Повторить (⌘Y)", + "Reference:" => "Ссылка:", + "Refers To" => "Ссылается на", + "Refers to cells:" => "Ссылается на ячейки:", + "Refers to:" => "Ссылается на:", + "Regression" => "Регрессия", + "Remove" => "Удалить", + "Remove Duplicates" => "Удалить дубликаты", + "Remove the password (Modify)" => "Снять пароль (изменить)", + "Rename Sheet" => "Переименовать лист", + "Replace" => "Заменить", + "Replace All" => "Заменить все", + "Replace with:" => "Заменить на:", + "Residuals" => "Остатки", + "Restore Original Values" => "Восстановить исходные значения", + "Return to the original worksheet to insert this picture, or cancel and choose another cell." => { + "Вернитесь на исходный лист, чтобы вставить рисунок, или отмените и выберите другую ячейку." + } + "Right margin (in):" => "Правое поле (дюймы):", + "Row Height" => "Высота строки", + "Row height (pixels):" => "Высота строки (пиксели):", + "Rows" => "Строки", + "Sample" => "Выборка", + "Save (⌘S)" => "Сохранить (⌘S)", + "Save Changes?" => "Сохранить изменения?", + "Save a Copy" => "Сохранить копию", + "Save a copy as .{format}" => "Сохранить копию как .{format}", + "Scatter" => "Точечная", + "Scatter or Bubble" => "Точечная или пузырьковая", + "Scatter with Lines" => "Точечная с прямыми отрезками", + "Scope" => "Область", + "Scope (Workbook or sheet name):" => "Область (книга или имя листа):", + "ScreenTip:" => "Подсказка:", + "Search Commands" => "Поиск команд", + "Search and run any command" => "Найти и выполнить любую команду", + "Search commands (⌘⇧Space)" => "Поиск команд (⌘⇧Пробел)", + "Search:" => "Поиск:", + "Seasonality (auto, 0 for none, or a season length):" => "Сезонность (авто, 0 — нет, или длина сезона):", + "Select" => "Выделить", + "Select Data" => "Выбрать данные", + "Select Data Source" => "Выбрать источник данных", + "Select a Solving Method:" => "Выберите метод решения:", + "Select a chart first." => "Сначала выделите диаграмму.", + "Select and move ink and shapes" => "Выделять и перемещать рукописные штрихи и фигуры", + "Semicolon" => "Точка с запятой", + "Series" => "Ряд", + "Series in" => "Ряды в", + "Series{n}" => "Ряд{n}", + "Series…" => "Ряд…", + "Set Objective:" => "Задать цель:", + "Set cell:" => "Ячейка:", + "Share: export or save a copy" => "Поделиться: экспорт или сохранение копии", + "Sheets: {sheets}" => "Листы: {sheets}", + "Shift cells down" => "Сдвинуть ячейки вниз", + "Shift cells left" => "Сдвинуть ячейки влево", + "Shift cells right" => "Сдвинуть ячейки вправо", + "Shift cells up" => "Сдвинуть ячейки вверх", + "Show Formulas (⌃`)" => "Показать формулы (⌃`)", + "Show password" => "Показать пароль", + "Shrink to fit" => "Сжать по размеру", + "Simplex LP: for linear models; integer and binary constraints by branch and bound." => { + "Симплекс-метод: для линейных моделей; целочисленные и двоичные ограничения — методом ветвей и границ." + } + "Single" => "Одинарное", + "Single Accounting" => "Одинарное бухгалтерское", + "Skip blanks" => "Пропускать пустые ячейки", + "Slicer" => "Срез", + "Solve" => "Решить", + "Solver Parameters" => "Параметры поиска решения", + "Solver Results" => "Результаты поиска решения", + "Sort & Filter" => "Сортировка и фильтр", + "Sort by" => "Сортировать по", + "Space" => "Пробел", + "Special…" => "Особые…", + "Spelling (F7)" => "Орфография (F7)", + "Stacked Area" => "С областями с накоплением", + "Stacked Bar" => "Линейчатая с накоплением", + "Stacked Column" => "Гистограмма с накоплением", + "Stacked Line" => "График с накоплением", + "Standard Errors" => "Стандартные ошибки", + "Standard Width" => "Стандартная ширина", + "Standard column width (pixels):" => "Стандартная ширина столбца (пиксели):", + "Standardized Residuals" => "Стандартизированные остатки", + "Start a conversation:" => "Начать разговор:", + "Start a new workbook" => "Создать новую книгу", + "Statistic" => "Статистика", + "Statistical" => "Статистические", + "Step value:" => "Шаг:", + "Stop" => "Стоп", + "Stop value:" => "Конечное значение:", + "Subject to the Constraints:" => "С учётом ограничений:", + "Subscript" => "Подстрочный", + "Subtract" => "Вычесть", + "Suggestions:" => "Варианты:", + "Sum" => "Сумма", + "Summary statistics" => "Итоговая статистика", + "Superscript" => "Надстрочный", + "Switch Row/Column" => "Поменять строки и столбцы", + "Tab" => "Табуляция", + "Table (⌘T)" => "Таблица (⌘T)", + "Table Styles" => "Стили таблиц", + "Text Box" => "Текстовое поле", + "Text length" => "Длина текста", + "Text to Columns" => "Текст по столбцам", + "The output range already holds data. Replace it with the report?" => "Выходной диапазон уже содержит данные. Заменить их отчётом?", + "The spelling check is complete for the entire sheet." => "Проверка орфографии на всём листе завершена.", + "Then by" => "Затем по", + "There are no conditional formatting rules on this sheet." => "На этом листе нет правил условного форматирования.", + "This month" => "В текущем месяце", + "This week" => "На текущей неделе", + "This workbook" => "Эта книга", + "This workbook has no scripts or macros." => "В этой книге нет сценариев и макросов.", + "Timeline range (blank: first column of the selection):" => "Диапазон временной шкалы (пусто — первый столбец выделения):", + "Title:" => "Заголовок:", + "To value:" => "Значение:", + "To:" => "До:", + "Today" => "Сегодня", + "Tomorrow" => "Завтра", + "Top" => "Верхняя", + "Top Align" => "Выровнять по верхнему краю", + "Top margin (in):" => "Верхнее поле (дюймы):", + "Transpose (E)" => "Транспонировать (E)", + "Treat consecutive delimiters as one" => "Считать последовательные разделители одним", + "Type" => "Тип", + "Type (custom format code):" => "Тип (код пользовательского формата):", + "Type a command, e.g. freeze, chart, bold…" => "Введите команду, например закрепить, диаграмма, полужирный…", + "Undo (⌘Z)" => "Отменить (⌘Z)", + "Undo list" => "Список отмены", + "Unhide" => "Показать", + "Unhide Sheet…" => "Показать лист…", + "Unhide sheet:" => "Показать лист:", + "Unlock with password" => "Разблокировать паролем", + "Unprotect Sheet" => "Снять защиту листа", + "Unprotect Workbook" => "Снять защиту книги", + "Up" => "Вверх", + "Use AutoFilter" => "Использовать автофильтр", + "Use function" => "Функция", + "Validation" => "Проверка данных", + "Value" => "Значение", + "Value Of:" => "Значение:", + "Values (V)" => "Значения (V)", + "Values and number formats" => "Значения и форматы чисел", + "Values range (blank: second column of the selection):" => "Диапазон значений (пусто — второй столбец выделения):", + "Variable 1 Range:" => "Диапазон переменной 1:", + "Variable 2 Range:" => "Диапазон переменной 2:", + "Version {version}" => "Версия {version}", + "Vertical" => "По вертикали", + "Vertical axis title:" => "Название вертикальной оси:", + "Visible cells only" => "Только видимые ячейки", + "Warning" => "Предупреждение", + "Waterfall" => "Каскадная", + "Web" => "Веб", + "Weekday" => "Рабочий день", + "What-If Analysis" => "Анализ «что если»", + "Whole number" => "Целое число", + "Within workbook" => "В книге", + "Workbook" => "Книга", + "Workbook Statistics" => "Статистика книги", + "Wrap text" => "Переносить текст", + "Year" => "Год", + "Yellow Fill with Dark Yellow Text" => "Жёлтая заливка и тёмно-жёлтый текст", + "Z to A" => "От Я до А", + "Zeros" => "Нули", + "Zoom In" => "Увеличить", + "Zoom Out" => "Уменьшить", + "Zoom to 100%" => "Масштаб 100%", + "Zoom to Selection" => "Масштаб по выделенному", + "Zoom…" => "Масштаб…", + "and:" => "и:", + "applies to" => "применяется к", + "between" => "между", + "dashed" => "штриховая", + "dotted" => "точечная", + "double" => "двойная", + "e.g. lookup, average, date" => "например: поиск, среднее, дата", + "equal to" => "равно", + "greater than" => "больше", + "greater than or equal to" => "больше или равно", + "less than" => "меньше", + "less than or equal to" => "меньше или равно", + "medium" => "средняя", + "none" => "нет", + "not between" => "не между", + "not equal to" => "не равно", + "t-Test: Paired Two Sample for Means" => "t-тест: парные выборки для средних", + "t-Test: Two-Sample Assuming Equal Variances" => "t-тест: две выборки с равными дисперсиями", + "t-Test: Two-Sample Assuming Unequal Variances" => "t-тест: две выборки с неравными дисперсиями", + "thick" => "толстая", + "thin" => "тонкая", + "this workbook" => "эта книга", + "with" => "с", + "{count} cell(s) found" => "Найдено ячеек: {count}", + "{count} engine commands are available. See docs/mcp.md and docs/control-protocol.md." => { + "Доступно команд движка: {count}. См. docs/mcp.md и docs/control-protocol.md." + } + "£ English (United Kingdom)" => "£ английский (Великобритания)", + "¥ Japanese" => "¥ японский", + "— 1 step" => "— 1 шаг", + "— VBA project (preserved, not run)" => "— проект VBA (сохраняется, не выполняется)", + "— {steps} steps" => "— шагов: {steps}", + "€ Euro" => "€ евро", + "Tab|Insert" => "Вставка", + "AutoSave" => "Автосохранение", + "AutoSave: save after every change (workbooks saved to a file)" => { + "Автосохранение: сохранять после каждого изменения (для книг, сохранённых в файл)" + } + // PivotTable field list. + "PivotTable Fields" => "Поля сводной таблицы", + "Choose fields to add to report:" => "Выберите поля для отчёта:", + "Use a field's menu to move it between areas:" => "Чтобы переместить поле в другую область, откройте его меню:", + "Filters" => "Фильтры", + "Values" => "Значения", + "Move to Row Labels" => "Переместить в названия строк", + "Move to Column Labels" => "Переместить в названия столбцов", + "Move to Report Filter" => "Переместить в фильтр отчёта", + "Move to Values" => "Переместить в значения", + "Summarize by Sum" => "Итоги по сумме", + "Summarize by Count" => "Итоги по количеству", + "Summarize by Average" => "Итоги по среднему", + "Summarize by Max" => "Итоги по максимуму", + "Summarize by Min" => "Итоги по минимуму", + "Show as No Calculation" => "Показать без вычислений", + "Show as % of Grand Total" => "Показать как % от общего итога", + "Show as Running Total" => "Показать как нарастающий итог", + "Show as Rank" => "Показать как ранг", + "Group by Years" => "Группировать по годам", + "Group by Quarters" => "Группировать по кварталам", + "Group by Months" => "Группировать по месяцам", + "Remove Field" => "Удалить поле", + "Refresh" => "Обновить", + "Calculated Field…" => "Вычисляемое поле…", + "Report Layout" => "Макет отчёта", + "Compact" => "Сжатый", + "Tabular" => "Табличный", "Rename" => "Переименовать", "Move or Copy…" => "Переместить или скопировать…", "Duplicate" => "Дублировать", "Tab Color" => "Цвет вкладки", "No Color" => "Без цвета", - "Hide" => "Скрыть", "Unhide…" => "Показать…", "Select All Sheets" => "Выделить все листы", - "AutoSave" => "Автосохранение", - "AutoSave: save after every change (workbooks saved to a file)" => { - "Автосохранение: сохранять после каждого изменения (книги, сохранённые в файл)" - } - "Previous sheet" => "Предыдущий лист", - "Next sheet" => "Следующий лист", "All sheets" => "Все листы", "New sheet (⇧F11)" => "Новый лист (⇧F11)", "Text Box" => "Надпись", @@ -458,6 +1033,236 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Stop" => "Остановить", "New Macro" => "Новый макрос", "Show" => "Показать", + "Name Box" => "Поле имени", + "Cell Editor" => "Редактор ячейки", + "Font Size" => "Размер шрифта", + "Print What" => "Что печатать", + // Page Layout view and the Header & Footer tab. + "Maximum Iterations:" => "Предельное число итераций:", + "Maximum Change:" => "Максимальное изменение:", + "Maximum Iterations must be a whole number from 1 to 32767." => "Предельное число итераций должно быть целым числом от 1 до 32767.", + "Maximum Change must be a number of 0 or more." => "Максимальное изменение должно быть числом не меньше 0.", + "100% Stacked Area" => "Областная с накоплением 100%", + "20% - Accent1" => "20% — Акцент 1", + "20% - Accent2" => "20% — Акцент 2", + "20% - Accent3" => "20% — Акцент 3", + "20% - Accent4" => "20% — Акцент 4", + "20% - Accent5" => "20% — Акцент 5", + "20% - Accent6" => "20% — Акцент 6", + "3 Arrows" => "3 стрелки", + "3 Symbols" => "3 символа", + "3 Traffic Lights" => "3 светофора", + "4 Ratings" => "4 оценки", + "40% - Accent1" => "40% — Акцент 1", + "40% - Accent2" => "40% — Акцент 2", + "40% - Accent3" => "40% — Акцент 3", + "40% - Accent4" => "40% — Акцент 4", + "40% - Accent5" => "40% — Акцент 5", + "40% - Accent6" => "40% — Акцент 6", + "5 Arrows" => "5 стрелок", + "5 Quarters" => "5 четвертей", + "60% - Accent1" => "60% — Акцент 1", + "60% - Accent2" => "60% — Акцент 2", + "60% - Accent3" => "60% — Акцент 3", + "60% - Accent4" => "60% — Акцент 4", + "60% - Accent5" => "60% — Акцент 5", + "60% - Accent6" => "60% — Акцент 6", + "A Date Occurring…" => "Дата…", + "Accent1" => "Акцент 1", + "Accent2" => "Акцент 2", + "Accent3" => "Акцент 3", + "Accent4" => "Акцент 4", + "Accent5" => "Акцент 5", + "Accent6" => "Акцент 6", + "All Borders" => "Все границы", + "Apply" => "Применить", + "Arrow" => "Стрелка", + "AutoSum (⌘⇧T)" => "Автосумма (⌘⇧T)", + "Bad" => "Плохо", + "Bar" => "Линейчатая", + "Between…" => "Между…", + "Blue" => "Синий", + "Blue - White - Red" => "Синий — белый — красный", + "Bold (⌘B)" => "Полужирный (⌘B)", + "Bottom Border" => "Нижняя граница", + "Bottom Double Border" => "Двойная нижняя граница", + "Box & Whisker" => "Ящик с усами", + "Bubble" => "Пузырьковая", + "Calculation" => "Вычисления", + "Check Cell" => "Проверить ячейку", + "Clear Rules" => "Удалить правила", + "Column" => "Гистограмма", + "Comma [0]" => "Разделитель разрядов [0]", + "Convert to Number" => "Преобразовать в число", + "Copies" => "Копии", + "Copy (⌘C)" => "Копировать (⌘C)", + "Currency [0]" => "Денежный [0]", + "Cut (⌘X)" => "Вырезать (⌘X)", + "Data and Model" => "Данные и модель", + "Drag to fill cells. Drag from inside a cell to select a range." => { + "Перетащите для заполнения ячеек. Для выделения диапазона начните перетаскивание внутри ячейки." + } + "Duplicate Values…" => "Повторяющиеся значения…", + "End date:" => "Дата окончания:", + "Equal To…" => "Равно…", + "Error checking options" => "Параметры проверки ошибок", + "Explanatory Text" => "Пояснение", + "Export as CSV…" => "Экспортировать в CSV…", + "Export as PDF…" => "Экспортировать в PDF…", + "Export as Web Page (.html)…" => "Экспортировать как веб-страницу (.html)…", + "Finding printers…" => "Поиск принтеров…", + "Forecast start (a date or number in the history; blank: after the last point):" => { + "Начало прогноза (дата или число из истории; пусто: после последней точки):" + } + "Format Chart Area…" => "Формат области диаграммы…", + "Good" => "Хорошо", + "Good, Bad and Neutral" => "Хорошо, плохо и нейтрально", + "Grayscale" => "Оттенки серого", + "Greater Than…" => "Больше…", + "Green" => "Зелёный", + "Green - White - Red" => "Зелёный — белый — красный", + "Green - Yellow - Red" => "Зелёный — жёлтый — красный", + "Heading 1" => "Заголовок 1", + "Heading 2" => "Заголовок 2", + "Heading 3" => "Заголовок 3", + "Heading 4" => "Заголовок 4", + "Hex" => "Шестнадцатеричный код", + "Highlight Cells Rules" => "Правила выделения ячеек", + "Input" => "Ввод", + "Insert Link" => "Вставить ссылку", + "Insert\nSlicer" => "Вставить\nсрез", + "Inside Borders" => "Внутренние границы", + "Italic (⌘I)" => "Курсив (⌘I)", + "Left Border" => "Левая граница", + "Less Than…" => "Меньше…", + "Light Blue" => "Голубой", + "Linked Cell" => "Связанная ячейка", + "Manage Rules…" => "Управление правилами…", + "More Borders…" => "Другие границы…", + "More Colors…" => "Другие цвета…", + "More Functions…" => "Другие функции…", + "Neutral" => "Нейтрально", + "New Rule…" => "Создать правило…", + "New Workbook" => "Новая книга", + "No Border" => "Нет границ", + "No Fill" => "Нет заливки", + "No printers found. Configure a system printer or export a PDF." => { + "Принтеры не найдены. Настройте системный принтер или экспортируйте в PDF." + } + "Note" => "Заметка", + "Number Format" => "Числовой формат", + "Number Stored as Text" => "Число сохранено как текст", + "One-sided" => "Односторонняя", + "Options|Editing" => "Редактирование", + "Orange" => "Оранжевый", + "Output" => "Результат", + "Outside Borders" => "Внешние границы", + "Oval" => "Овал", + "Page Break Preview" => "Страничный режим", + "Paste formulas that refer to the copied cells" => "Вставить формулы со ссылками на скопированные ячейки", + "Pen\nBlack" => "Чёрное\nперо", + "Pen\nBlue" => "Синее\nперо", + "Pen\nRed" => "Красное\nперо", + "Percent" => "Процентный", + "Place in Cell" => "Поместить в ячейку", + "Place over Cells" => "Поместить поверх ячеек", + "Print failed: the printing worker stopped." => "Не удалось напечатать: обработчик печати остановлен.", + "Print failed: {error}" => "Не удалось напечатать: {error}", + "Print job submitted." => "Задание отправлено на печать.", + "Print job submitted: {receipt}" => "Задание отправлено на печать: {receipt}", + "Printer" => "Принтер", + "Printer default" => "По умолчанию для принтера", + "Printer discovery failed." => "Не удалось найти принтеры.", + "Print…" => "Печать…", + "Protect\nSheet" => "Защитить\nлист", + "Purple" => "Фиолетовый", + "Recent Colors" => "Последние цвета", + "Rectangle" => "Прямоугольник", + "Red" => "Красный", + "Red - Yellow - Green" => "Красный — жёлтый — зелёный", + "Redo" => "Повторить", + "Refresh printers" => "Обновить список принтеров", + "Right Border" => "Правая граница", + "Rounded Rectangle" => "Прямоугольник со скруглёнными углами", + "Save As…" => "Сохранить как…", + "Save a Copy as Excel Workbook (.xlsx)…" => "Сохранить копию как книгу Excel (.xlsx)…", + "Search" => "Поиск", + "Search commands" => "Поиск команд", + "Select Data…" => "Выбрать данные…", + "Select a printer" => "Выберите принтер", + "Sort Ascending" => "Сортировать по возрастанию", + "Sort Descending" => "Сортировать по убыванию", + "Source:" => "Источник:", + "Standard Colors" => "Стандартные цвета", + "Stock" => "Биржевая", + "Submitting print job…" => "Отправка задания на печать…", + "Sunburst" => "Солнечные лучи", + "Text that Contains…" => "Текст содержит…", + "Theme Colors" => "Цвета темы", + "Themed Cell Styles" => "Стили ячеек темы", + "Thick Bottom Border" => "Толстая нижняя граница", + "Thick Outside Borders" => "Толстые внешние границы", + "Titles and Headings" => "Названия и заголовки", + "Top Border" => "Верхняя граница", + "Top and Bottom Border" => "Верхняя и нижняя границы", + "Top and Double Bottom Border" => "Верхняя и двойная нижняя границы", + "Top and Thick Bottom Border" => "Верхняя и толстая нижняя границы", + "Top/Bottom Rules" => "Правила отбора первых и последних значений", + "Total" => "Итог", + "Treemap" => "Древовидная", + "Triangle" => "Треугольник", + "Two-sided printing" => "Двусторонняя печать", + "Two-sided: long edge" => "Двусторонняя: по длинному краю", + "Two-sided: short edge" => "Двусторонняя: по короткому краю", + "Type to filter…" => "Введите текст для фильтрации…", + "Underline (⌘U)" => "Подчёркивание (⌘U)", + "Undo" => "Отменить", + "Warning Text" => "Предупреждение", + "Watch\nWindow" => "Контроль\nзначений", + "White - Green" => "Белый — зелёный", + "White - Red" => "Белый — красный", + "Win/Loss" => "Выигрыш/проигрыш", + "Workbook\nStatistics" => "Статистика\nкниги", + "no workbook open" => "Нет открытой книги", + "formula is nested too deeply" => "Слишком глубокая вложенность формулы", + "unknown error literal" => "Неизвестное обозначение ошибки", + "array rows must have the same length" => "Строки массива должны иметь одинаковую длину", + "too many arguments" => "Слишком много аргументов", + "invalid 3-D reference" => "Неверная трёхмерная ссылка", + "unexpected end of formula" => "Неожиданный конец формулы", + "unbalanced [" => "Незакрытая квадратная скобка [", + "expected ! after sheet name" => "После имени листа ожидался символ !", + "No cells found." => "Ячейки не найдены.", + "No chart selected." => "Диаграмма не выделена.", + "unknown command `{command}`" => "Неизвестная команда `{command}`", + "formula is longer than {limit} characters" => "Длина формулы превышает {limit} символов", + "bad number `{number}`" => "Неверное число `{number}`", + "number too large `{number}`" => "Слишком большое число `{number}`", + "unexpected character `{character}`" => "Недопустимый символ `{character}`", + "invalid cell reference `{cell}` for `{key}`" => "Неверная ссылка на ячейку `{cell}` в параметре `{key}`", + "expected `,` or `)`, found {token}" => "Ожидался разделитель аргументов или `)`, получено: {token}", + "expected a reference after the sheet name, found {token}" => "После имени листа ожидалась ссылка, получено: {token}", + "expected a number in array, found {token}" => "В массиве ожидалось число, получено: {token}", + "array constants may only contain constants, found {token}" => "В константе массива допустимы только постоянные значения, получено: {token}", + "expected {expected}, found {actual}" => "Ожидалось: {expected}, получено: {actual}", + "unexpected {token}" => "Неожиданный элемент: {token}", + "{function} has too few arguments. Use {signature}" => "В функции {function} недостаточно аргументов. Синтаксис: {signature}", + "{function} has too many arguments. Use {signature}" => "В функции {function} слишком много аргументов. Синтаксис: {signature}", + "no sheet named `{sheet}`" => "Лист `{sheet}` не найден", + "not a range: `{range}`" => "Неверный диапазон: `{range}`", + "missing `{parameter}`" => "Не указан параметр `{parameter}`", + "`{cell}` for `{key}` names a sheet other than the one this command works on" => { + "Ссылка `{cell}` в параметре `{key}` указывает на другой лист" + } + "There's a problem with this formula:" => "Ошибка в формуле:", + "There's a problem with this formula." => "Ошибка в формуле.", + "no such sheet" => "Такого листа нет", + "no such name to edit" => "Имя для изменения не найдено", + "missing `input` (or `value`)" => "Не указан параметр `input` (или `value`)", + "The name that you entered is not valid." => "Введено недопустимое имя.", + "The name entered already exists. Enter a unique name." => "Такое имя уже существует. Введите уникальное имя.", + "There's no room to the right of the selection for the total." => "Справа от выделенного диапазона нет места для итога.", + "There's no room below the selection for the totals." => "Под выделенным диапазоном нет места для итогов.", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/ru_functions.rs b/crates/ui-egui/src/i18n/ru_functions.rs new file mode 100644 index 0000000000..dbacf380be --- /dev/null +++ b/crates/ui-egui/src/i18n/ru_functions.rs @@ -0,0 +1,66 @@ +//! Russian function help adapted from PR #1850 to the current English-keyed catalog. + +pub(super) const DESCRIPTIONS: &[(&str, &str)] = &[ + ("Adds all the numbers in the arguments.", "Суммирует аргументы."), + ("Adds the cells that meet a condition.", "Суммирует ячейки, удовлетворяющие условию."), + ("Arithmetic mean of the numbers.", "Возвращает среднее арифметическое аргументов."), + ("Builds a date serial number from a year, month and day.", "Возвращает число, представляющее дату."), + ("Counts the cells that meet a condition.", "Подсчитывает ячейки, удовлетворяющие условию."), + ("Counts the characters in a text.", "Возвращает количество символов в тексте."), + ("Counts the numbers.", "Подсчитывает количество ячеек с числами."), + ( + "Finds a value in the first column of a table and returns the value in the same row of another column.", + "Ищет значение в первом столбце таблицы и возвращает значение из указанного столбца.", + ), + ("Joins several text values into one.", "Объединяет несколько текстовых строк в одну."), + ("Largest number (0 when there are none).", "Возвращает наибольшее значение из набора."), + ("Returns a fallback when the value is an error, otherwise the value.", "Возвращает заданное значение, если вычисление дало ошибку."), + ( + "Returns one value when a condition is TRUE and another when it is FALSE.", + "Возвращает одно значение, если условие истинно, и другое, если ложно.", + ), + ("Returns the current date and time.", "Возвращает текущие дату и время."), + ("Returns the current date.", "Возвращает текущую дату."), + ("Returns the first characters of a text.", "Возвращает первые символы текста."), + ("Returns the last characters of a text.", "Возвращает последние символы текста."), + ("Returns the logical value FALSE.", "Возвращает логическое значение ЛОЖЬ."), + ("Returns the logical value TRUE.", "Возвращает логическое значение ИСТИНА."), + ("Reverses a logical value.", "Меняет логическое значение на противоположное."), + ("Rounds a number to a given number of digits, halves away from zero.", "Округляет число до указанного количества разрядов."), + ("Smallest number (0 when there are none).", "Возвращает наименьшее значение из набора."), + ("TRUE when at least one argument is TRUE.", "Возвращает ИСТИНА, если хотя бы один аргумент истинен."), + ("TRUE when every argument is TRUE.", "Возвращает ИСТИНА, если все аргументы истинны."), +]; + +pub(super) const ARGUMENTS: &[(&str, &str)] = &[ + ("array", "массив"), + ("col_index_num", "номер_столбца"), + ("column_num", "номер_столбца"), + ("criteria", "условие"), + ("criteria_range", "диапазон_условия"), + ("day", "день"), + ("if_not_found", "если_не_найдено"), + ("logical", "логическое_значение"), + ("logical_test", "лог_выражение"), + ("lookup_array", "просматриваемый_массив"), + ("lookup_value", "искомое_значение"), + ("match_mode", "режим_сопоставления"), + ("month", "месяц"), + ("num_chars", "число_знаков"), + ("num_digits", "число_разрядов"), + ("number", "число"), + ("range", "диапазон"), + ("range_lookup", "интервальный_просмотр"), + ("reference", "ссылка"), + ("return_array", "возвращаемый_массив"), + ("row_num", "номер_строки"), + ("search_mode", "режим_поиска"), + ("sum_range", "диапазон_суммирования"), + ("table_array", "таблица"), + ("text", "текст"), + ("value", "значение"), + ("value_if_error", "значение_если_ошибка"), + ("value_if_false", "значение_если_ложь"), + ("value_if_true", "значение_если_истина"), + ("year", "год"), +]; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4695227fe8..7676341bc8 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -296,6 +296,9 @@ pub struct SheetApp { /// Strips the engine's error prefixes for message boxes. pub fn clean_error(e: &str) -> String { + if let Some(rest) = e.strip_prefix("command `").and_then(|s| s.split_once("` is not available right now: ")).map(|(_, message)| message) { + return rest.to_string(); + } if let Some(rest) = e.split_once("`: ").map(|(_, r)| r) { return rest.to_string(); } diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 49aba2b0e1..e18e85d1ab 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -73,7 +73,7 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) { let active = app.ui.ribbon_tab == tab; let contextual = ctx_tabs.contains(&tab); let font = theme::ui_font(13.5); - let label = app.ui.language.tr(tab); + let label = app.ui.language.tr_tab(tab); let tw = ui.painter().layout_no_wrap(label.to_string(), font.clone(), t.text).size().x; let (r, resp) = ui.allocate_exact_size(vec2(tw + 22.0, 30.0), Sense::click()); if resp.hovered() && !active { diff --git a/crates/ui-egui/tests/it/formula_locale.rs b/crates/ui-egui/tests/it/formula_locale.rs index 78af96a1af..9ffed70ff0 100644 --- a/crates/ui-egui/tests/it/formula_locale.rs +++ b/crates/ui-egui/tests/it/formula_locale.rs @@ -231,3 +231,75 @@ fn turkish_interface_retains_its_canonical_formula_fallback() { let restored: UiState = serde_json::from_str(&serde_json::to_string(&app.ui).unwrap()).unwrap(); assert_eq!(restored.language, Language::Tr); } +#[test] +fn russian_entry_roundtrips_through_xlsx_and_keeps_api_canonical() { + let mut app = app(); + app.run("app.language.set", json!({"language":"ru"})).unwrap(); + assert!(enter(&mut app, "=если(истина;сумм(1,5;2,25);0)")); + assert_eq!(formula(&app), "IF(TRUE,SUM(1.5,2.25),0)"); + let saved = app.run("file.save", json!({"format":"xlsx"})).unwrap(); + app.run("file.open", json!({"name":"russian.xlsx","base64":saved["base64"]})).unwrap(); + app.begin_edit(None, false); + assert_eq!(app.editor.as_ref().unwrap().text, "=ЕСЛИ(ИСТИНА;СУММ(1,5;2,25);0)"); + assert!(app.commit_edit(0, 0, false, false)); + assert!(app.run("cell.set", json!({"cell":"A2","input":"=SUM(1,2)"})).is_ok()); + assert!(app.run("cell.set", json!({"cell":"A3","input":"=СУММ(1;2)"})).is_err()); + assert!(enter(&mut app, "=ЕСЛИОШИБКА(#ССЫЛКА!;42)")); + assert_eq!(formula(&app), "IFERROR(#REF!,42)"); + assert_eq!(app.session.active().unwrap().wb.active().unwrap().value(CellRef::new(0, 0)), Value::Number(42.0)); +} + +#[test] +fn russian_constants_and_literal_text_do_not_change_type_when_edited() { + let mut app = app(); + app.run("app.language.set", json!({"language":"ru"})).unwrap(); + for (typed, expected) in [("#ССЫЛКА!", Value::Error(CellError::Ref)), ("истина", Value::Bool(true)), ("ложь", Value::Bool(false))] + { + assert!(enter(&mut app, typed)); + assert_eq!(app.session.active().unwrap().wb.active().unwrap().value(CellRef::new(0, 0)), expected); + app.begin_edit(None, false); + assert!(app.commit_edit(0, 0, false, false)); + assert_eq!(app.session.active().unwrap().wb.active().unwrap().value(CellRef::new(0, 0)), expected); + } + for text in ["#ССЫЛКА!", "ИСТИНА", "ЛОЖЬ", "+сумм(1)"] { + app.run("cell.set", json!({"cell":"A1","input":format!("'{text}")})).unwrap(); + app.begin_edit(None, false); + assert!(app.editor.as_ref().unwrap().text.starts_with('\'')); + assert!(app.commit_edit(0, 0, false, false)); + assert_eq!(app.session.active().unwrap().wb.active().unwrap().value(CellRef::new(0, 0)), Value::text(text)); + } +} + +#[test] +fn russian_autocomplete_and_argument_insertion_use_unicode_caret_positions() { + use gridcraft_engine::formula::FormulaLocale; + use gridcraft_ui_egui::editor::EditState; + let mut editor = EditState::new(0, 0, CellRef::new(0, 0), "=сче".into(), true, false); + editor.locale = FormulaLocale::Ru; + editor.update_autocomplete(&["СЧЁТ".into(), "СЧЁТЕСЛИ".into()]); + assert_eq!(editor.autocomplete, vec!["СЧЁТ", "СЧЁТЕСЛИ"]); + assert!(editor.accept_autocomplete()); + assert_eq!(editor.text, "=СЧЁТ("); + assert_eq!(editor.caret, editor.text.chars().count()); + assert_eq!(editor.current_function(), Some(("СЧЁТ".into(), 0))); + assert!(editor.insert_argument_names(Language::Ru)); + assert!(editor.text.contains(Language::Ru.function_argument("value").unwrap())); +} + +#[test] +fn localized_value_editing_respects_explicit_text_format_in_both_languages() { + for code in ["es", "ru"] { + let mut app = app(); + app.run("app.language.set", json!({"language":code})).unwrap(); + app.run("home.numberFormat", json!({"format":"@"})).unwrap(); + let locale = app.ui.language.formula_locale(); + for text in [locale.function_name("TRUE"), locale.error_name(CellError::Ref)] { + assert!(enter(&mut app, text)); + app.begin_edit(None, false); + assert_eq!(app.editor.as_ref().unwrap().text, text); + app.editor.as_mut().unwrap().text.push('!'); + assert!(app.commit_edit(0, 0, false, false)); + assert_eq!(app.session.active().unwrap().wb.active().unwrap().value(CellRef::new(0, 0)), Value::text(format!("{text}!"))); + } + } +} diff --git a/docs/formula-input.md b/docs/formula-input.md index e07132c8d9..6147332880 100644 --- a/docs/formula-input.md +++ b/docs/formula-input.md @@ -2,7 +2,8 @@ View → Interface language is the existing, persisted language preference. Choose **Español** for Spanish formula entry and display. On first run the preference follows the system language. -English and the other interface languages currently use the English formula table and separators. +Choose **Русский** for Russian formula entry and display. English and the remaining interface +languages currently use the English formula table and separators. | Spanish | Canonical English | | --- | --- | @@ -36,3 +37,49 @@ clipboard exchange retain strict English syntax. This also applies to advanced d Name Manager and conditional-format rules. Localization happens at the cell/formula-bar editor boundary, including filling the selected range with Ctrl+Enter; it does not change the workbook format or teach the calculation engine ambiguous aliases. + +## Russian + +Russian uses the same decimal comma, semicolon arguments/reference unions and backslash array +columns as Spanish. The 199-name table in `crates/formula/src/input/ru.rs`, adapted from #1850, +includes `СУММ`, `СРЗНАЧ`, `ЕСЛИ`, `ВПР`, `СЧЁТЕСЛИ`, `ПРОСМОТРX` and `ПОИСКПОЗX` (the final +`X` in the last two is Latin). Unlisted functions retain their English names. English function +names are also accepted with Russian separators. Alias lookup ignores case and accepts `е` for +`ё`; that extra equivalence does not apply to workbook names or LET/LAMBDA bindings. + +`=ЕСЛИ(ИСТИНА;СУММ(1,5;2,25);0)` is stored as `IF(TRUE,SUM(1.5,2.25),0)`. +Russian formula literals include `#ДЕЛ/0!`, `#ЗНАЧ!`, `#ССЫЛКА!`, `#ИМЯ?`, `#Н/Д`, +`#ПЕРЕНОС!` and `#ВЫЧИС!`; canonical English errors remain accepted. Error constants and +ИСТИНА/ЛОЖЬ also work as direct cell input. Literal text stays text during an unchanged edit. +The grid, formula bar and editor localize error values; the engine, API and XLSX retain canonical +error values and formula syntax. The internal circular-reference marker remains `#CIRC!`. + +Function descriptions for 23 common functions and argument labels live in +`crates/ui-egui/src/i18n/ru_functions.rs`, keyed by the current English catalog. Signatures are +assembled from the canonical metadata, preserving optional arguments. Missing help falls back +to English. Regional number/date display and localized syntax in advanced dialogs remain +separate work. + +## Adding another language + +Formula syntax is described by `LocaleData` in `crates/formula/src/input.rs`, with each locale's data in `input/.rs`: function aliases, +error spellings, the decimal/list/array-column/array-row separators, and the alias case-folding +function. Add the locale's data and enum selection there, then connect the interface language +in `Language::formula_locale`. The lexer, parser, printer, completion and value editor consume +the shared methods; they must not gain per-language branches. Alias folding is separate from +`same_name`, which defines workbook and LET/LAMBDA name identity. + +Interface translations remain in `i18n/.rs`. Optional function descriptions and +argument names use the existing English-keyed `_functions.rs` tables. Ribbon tab +names can use `Tab|name`; `Language::tr_tab` falls back to the ordinary label translation. + +Dynamic error matching is shared in `i18n/error_templates.rs`. To translate an existing message +shape, add its template key to the language catalog. A new engine message shape needs one shared +template and a regression test generated through the real command/parser boundary. Placeholders +are substituted once, preserving braces and other text inside user-provided data. Built-in +function names and signatures use that language's existing formula/help tables. Unknown +messages or untranslated templates retain the original text. + +Every new locale should test alias collisions, case rules, defined/LET/LAMBDA names, all four +separators, error literals, Unicode editing, and UI/XLSX round trips. Changes to shared machinery +must keep the existing English and Spanish cases passing too. diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 39a9b3f91e..b3791327a0 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,12 +1,26 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (missing translations are listed by `cargo xtask i18n-report` and never fail a check) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (shared locale data and Russian interface, formulas and errors) · **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, right-to-left sheets and full complex-script support. Part of [target-app-parity.md](target-app-parity.md). **Localization: ~10% ready, 100–165 h** (estimated). +## Russian update (2026-10-29) + +Russian now extends #676's catalog with the current catalog union and newer main-only keys. +The Insert ribbon tab uses the noun «Вставка» separately from the Insert command «Вставить»; +the Start and Save Changes dialogs look up their visible text at draw time. Missing strings +continue to fall back to English; this is coverage of catalog keys, not every literal in the UI. + +Formula input adapts #1850's 199 aliases to the current parser and help architecture. Unicode +LET/LAMBDA name precedence and iterative handling of long flat expressions are retained. +Russian help for 23 functions is English-keyed in `i18n/ru_functions.rs`. Localized error +literals round-trip at the editor boundary, with canonical storage/API/XLSX syntax. Fixed +messages use `tr_error`; selected dynamic parser/parameter messages preserve captured user data. +Russian regional number/date formats and the remaining function help are still outstanding. + ## Where we are (measured from source on `main`, after the 2026-10-10 merges) - **A first catalog exists, for the ribbon only.** `crates/ui-egui/src/i18n.rs` (merged 10-10 in @@ -22,8 +36,8 @@ right-to-left sheets and full complex-script support. Part of menus) follows the interface language through per-language tables keyed by the catalog's English text (`i18n/_functions.rs`): descriptions, and argument names from which the hint is assembled with the formula syntax's function name and separator. Spanish translates - the 10 common functions it had help for (SUM, AVERAGE, IF…); other languages show the English - help. + 10 common functions (SUM, AVERAGE, IF…); Russian now translates 23 through the same tables. + Other languages show the English help where no help table is supplied. - **A first catalog exists.** `crates/ui-egui/src/i18n.rs` (merged 10-10 in #31, #104, #82; German added 10-11) adds an interface-language preference that defaults to the system language (closes #20) and a View-tab switcher, with English-keyed lookup tables: Simplified Chinese, Korean, @@ -37,10 +51,10 @@ 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 the ribbon-only languages cover **~12%** and Japanese and Portuguese **~43%** (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). +- **Canonical file/API formulas, localized cell entry.** Files and APIs keep English names, + comma arguments and decimal points. Spanish and Russian cell/formula-bar entry use locale data + for aliases and separators. Number/date display still uses the canonical formats; regional + formats and other language tables are separate open work (including #163 and #2822). - **Script support.** System-font fallback for CJK, Thai and Hebrew landed in #38 (closes #32; #142 is still open pending confirmation). Native CJK fallback also probes Flatpak's read-only `/run/host/fonts`, `/run/host/local-fonts` and `/run/host/user-fonts` layouts, so installed @@ -85,9 +99,9 @@ separators, number/date formats, machine translation with agent review, and scri | Vietnamese | vi | 0 (0%) | no | Latin with stacked diacritics: unverified | no | none | 6–10 h | | Turkish | tr | 382 (~17%) | no | Latin: yes | yes | menus only | 4–8 h | -Other shipped languages: Russian (ru), German (de), Czech (cs), French (fr) and Turkish (tr), menus only. +Russian is described in the current update above. Other shipped languages in this historical table include German (de), Czech (cs), French (fr) and Turkish (tr). | Vietnamese | vi | 398 (~18%) | no | Latin with stacked diacritics: unverified | no | menus, File view | 5–9 h | -Other shipped languages: Russian (ru) and German (de), 266 labels each (~12%), menus only; Dutch (nl), 518 labels (~24%): ribbon, context menus, File view, dialog titles and the Options page. +Russian is described in the current update above. Historical counts for German (de) and Dutch (nl) below predate the current Russian catalog. **Total:** catalog work 10–20 h + languages 92–144 h ≈ **100–165 h**. Parallelizes almost fully once the catalog covers dialogs and commands. **Needs a human:** native-speaker review of every @@ -99,9 +113,9 @@ language (none so far) and of localized function names. status and error messages, command labels, function descriptions). A string a catalog lacks shows in English and never fails a check: `cargo xtask i18n-report` lists the missing strings per language, and `cargo xtask ci` runs it first. Language switching and the system-language default already exist (#31). -2. Locale-aware formulas: localized function-name tables and separators at the formula-bar - boundary only (files keep en-US invariant names), locale number and date formats, list - separators in CSV. +2. Extend locale-aware formulas through the shared `LocaleData` and per-language modules at the + formula-bar boundary (files keep invariant names); regional number/date formats and CSV + separator policies remain separate work. 3. Text shaping and fallback: a shaping engine for grid, editor and PDF text (Arabic, Devanagari, Thai), system font fallback for CJK, bidirectional runs, right-to-left sheet tabs and printing; embedded subset fonts in PDF. @@ -110,6 +124,7 @@ language (none so far) and of localized function names. | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | Russian: refresh #676 catalog, adapt #1850 formula aliases to current help/name handling, localize error literals and selected parameter/parser diagnostics at the UI boundary | | 2026-10-23 | minor | Missing translations are reported, never a failure: `cargo xtask i18n-report` prints, per language, the strings other catalogs translate and it lacks (plus Spanish function help rows keyed by English the function catalog no longer has) and always passes; `cargo xtask ci` runs it first. Coverage and per-language parity tests and tests of one language's exact wording are gone; UI tests take the expected text from the catalog. Duplicate-key, placeholder, context-prefix and line-shape checks stay | | 2026-10-21 | minor | Romanian interface (`ro`, Română): 970 labels covering every key of the other catalogs (`crates/ui-egui/src/i18n/ro.rs`), `ro-*` system locales, the View-tab switcher and `app.language.set`; context keys show no prefix (File › Options test). English formulas and formats; AI-assisted draft pending native-speaker review | | 2026-10-20 | minor | Japanese: furigana (`rPh` phonetic runs and `` in `xl/sharedStrings.xml`) is no longer deleted by an open + save; it is kept on the workbook and written back (a `` without runs is kept too, while duplicate texts with different readings keep only the first, reported on open). Display of the readings is still missing (`crates/xlsx/src/tests/phonetic_tests.rs`) | diff --git a/docs/russian-localization.md b/docs/russian-localization.md new file mode 100644 index 0000000000..bba1abe1fe --- /dev/null +++ b/docs/russian-localization.md @@ -0,0 +1,57 @@ +# Russian localization + +This contribution builds on [#676](https://github.com/storytold/gridcraft/pull/676) +(@xmaks82, Russian interface) and [#1850](https://github.com/storytold/gridcraft/pull/1850) +(@dressky, Russian formulas). It adapts their translation data to current `main` rather than +restoring their older parser/editor/help implementations. + +## Shared architecture + +- `FormulaLocale` selects one `LocaleData`: function/error names, four separators and alias + case folding. The lexer, parser, printer and editor share that data. Russian-specific alias + folding and error spellings live alongside its function-name table. +- `Language::translation` exposes the existing catalogs without English fallback. Shared + contextual-tab lookup and dynamic error matching use it; a language adds catalog rows rather + than its own rendering implementation. +- Function help uses the existing English-keyed description/argument tables. Signatures come + from canonical function metadata, keeping optional arguments and argument order consistent. +- Localization occurs at UI entry/display boundaries. Engine values, commands, clipboard and + XLSX retain canonical English syntax. Workbook content and LET/LAMBDA name identity are not + translated or folded as function aliases. + +See [Adding another language](formula-input.md#adding-another-language) for extension points. + +## Adaptation details + +The Russian interface catalog incorporates #676, newer main-only entries and the previously +missing keys from the other current catalogs. New code routes Start/Save Changes text and ribbon +tab labels through the shared lookup. Coverage of catalog keys does not imply every UI literal +has already been wired to translation. + +The formula table retains #1850's 199 pairs. `XLOOKUP` and `XMATCH` use a Latin final `X` +(`ПРОСМОТРX`, `ПОИСКПОЗX`), matching the public [Microsoft reference](https://support.microsoft.com/ru-ru/excel/look-up-values-with-vlookup-index-or-match). +The extra `е`/`ё` equivalence applies to aliases only. Current main's Unicode name matching and +iterative traversal of long flat formulas are retained. The table is tested in full for unique +canonical names, unique folded aliases and parse/print round trips. + +Help for 23 common functions is migrated to `i18n/ru_functions.rs` using current English keys. +For example, IF's optional arguments follow the engine rather than #1850's hand-written Russian +signature. Functions without Russian help retain the English fallback. + +Russian cell/formula error literals are in `input/ru.rs`. `#CALC!` is `#ВЫЧИС!`, as in the +public [LAMBDA reference](https://support.microsoft.com/ru-ru/excel/functions/lambda-function); +`#SPILL!` is `#ПЕРЕНОС!`, as in the [spill-error reference](https://support.microsoft.com/ru-ru/excel/how-to-correct-a-spill-error). +The internal circular-reference marker remains `#CIRC!`. Literal text resembling a localized +Boolean/error value keeps its type during editing. + +Fixed diagnostics use the catalog. Shared dynamic templates cover selected parser, argument-count +and parameter errors; filenames, sheet names and other captured values are preserved. Unknown +system/engine messages remain unchanged. This is not a claim to translate every diagnostic. + +## Remaining work + +- Verify and extend the remaining built-in function names and help with native-speaker review. +- Integrate regional Russian number/date/currency formats with the shared region work. +- Translate remaining UI literals and diagnostics, coordinating with the other open localization + PRs instead of adding competing per-language code paths. +- Localized formula syntax in advanced dialogs remains outside the cell-editor boundary. From a4148f15cc13bf2e85670a15ca7f5656f32fe33b Mon Sep 17 00:00:00 2001 From: reeeji <63110560+reeeji@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:03:40 +0300 Subject: [PATCH 02/97] i18n: deduplicate catalog keys introduced by current main --- crates/ui-egui/src/i18n/ru.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index e878af8b56..97e5523c3f 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -693,7 +693,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Защита ячеек и скрытие формул действуют только после защиты листа (Рецензирование › Защитить лист)." } "Lookup & Reference" => "Поиск и ссылки", - "Macros" => "Макросы", "Macros and scripts in this workbook (Alt+F8)" => "Макросы и сценарии этой книги (Alt+F8)", "Make Unconstrained Variables Non-Negative" => "Сделать переменные без ограничений неотрицательными", "Markers" => "Маркеры", @@ -722,7 +721,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "New Comment" => "Новый комментарий", "New Name" => "Новое имя", "New Worksheet Ply:" => "Новый лист:", - "New sheet (⇧F11)" => "Новый лист (⇧F11)", "New title (Modify):" => "Новое название (изменить):", "New…" => "Создать…", "Next month" => "В следующем месяце", @@ -867,7 +865,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Statistic" => "Статистика", "Statistical" => "Статистические", "Step value:" => "Шаг:", - "Stop" => "Стоп", "Stop value:" => "Конечное значение:", "Subject to the Constraints:" => "С учётом ограничений:", "Subscript" => "Подстрочный", @@ -880,7 +877,6 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Tab" => "Табуляция", "Table (⌘T)" => "Таблица (⌘T)", "Table Styles" => "Стили таблиц", - "Text Box" => "Текстовое поле", "Text length" => "Длина текста", "Text to Columns" => "Текст по столбцам", "The output range already holds data. Replace it with the report?" => "Выходной диапазон уже содержит данные. Заменить их отчётом?", From dc81f4ea5fcfe389bfb2e89ddb8bd7286bb0724e Mon Sep 17 00:00:00 2001 From: reeeji <63110560+reeeji@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:22:38 +0300 Subject: [PATCH 03/97] docs: document localized formula boundaries and regression tests --- crates/formula/src/input.rs | 7 +++++++ crates/formula/src/input/ru.rs | 1 + crates/formula/src/lexer.rs | 6 ++++++ crates/formula/src/parser.rs | 2 ++ crates/formula/tests/it/russian_input.rs | 10 ++++++++++ crates/ui-egui/src/formula_bar.rs | 2 ++ crates/ui-egui/src/formula_locale.rs | 6 ++++++ crates/ui-egui/src/grid/quadrant.rs | 2 ++ crates/ui-egui/src/i18n.rs | 4 ++++ crates/ui-egui/src/i18n/error_templates.rs | 4 ++++ crates/ui-egui/src/i18n/ru.rs | 2 ++ crates/ui-egui/tests/it/formula_locale.rs | 4 ++++ 12 files changed, 50 insertions(+) diff --git a/crates/formula/src/input.rs b/crates/formula/src/input.rs index 4490c9efdc..ebf2a1db9b 100644 --- a/crates/formula/src/input.rs +++ b/crates/formula/src/input.rs @@ -47,6 +47,7 @@ fn normalize_function_case(name: &str, fold: fn(&str) -> String) -> String { } impl FormulaLocale { + /// Returns the static syntax tables and alias cache for this formula locale. fn data(self) -> &'static LocaleData { match self { Self::En => &en::DATA, @@ -72,6 +73,7 @@ impl FormulaLocale { self.data().separators.array_row } + /// Whether a character separates either rows or columns in a localized array constant. pub(crate) fn is_array_separator(self, character: char) -> bool { character == self.array_column_separator() || character == self.array_row_separator() } @@ -185,6 +187,7 @@ pub fn print_input(expr: &Expr, locale: FormulaLocale, known_name: impl Fn(&str) Ok(out) } +/// Resolves a localized Boolean alias; English literals are handled by the canonical parser. pub(crate) fn boolean_alias(name: &str, locale: FormulaLocale) -> Option { if locale == FormulaLocale::En { return None; @@ -196,6 +199,8 @@ pub(crate) fn boolean_alias(name: &str, locale: FormulaLocale) -> Option { } } +/// Rewrites aliases between canonical and display forms while preserving workbook and local bindings. +/// Bounds nested expressions and walks flat binary chains iteratively; display selects the direction. fn normalize( expr: &mut Expr, locale: FormulaLocale, @@ -292,9 +297,11 @@ fn normalize( mod case_rule_tests { use super::normalize_function_case; + /// Verifies token normalization preserves distinctions required by a locale-specific case fold. #[test] fn locale_case_rules_survive_token_normalization() { // A data-only example of a case rule where ASCII upper-casing loses information. + /// Models dotted-I alias folding to exercise a case distinction lost by ASCII uppercasing. fn dotted_i_fold(name: &str) -> String { name.chars().flat_map(|c| if c == 'i' { 'İ'.to_uppercase() } else { c.to_uppercase() }).collect() } diff --git a/crates/formula/src/input/ru.rs b/crates/formula/src/input/ru.rs index 4b9d8516f1..9c18d00b4a 100644 --- a/crates/formula/src/input/ru.rs +++ b/crates/formula/src/input/ru.rs @@ -233,6 +233,7 @@ pub(super) const ERRORS: &[(gridcraft_core::CellError, &str)] = &[ mod tests { use super::*; use crate::FormulaLocale; + /// Checks every Russian alias for unique canonical/folded names and parse/print round trips. #[test] fn every_russian_alias_is_unique_and_round_trips() { let mut canonical = std::collections::HashSet::new(); diff --git a/crates/formula/src/lexer.rs b/crates/formula/src/lexer.rs index a724088bf7..3b092a4b50 100644 --- a/crates/formula/src/lexer.rs +++ b/crates/formula/src/lexer.rs @@ -124,6 +124,8 @@ fn spelling_prefix<'a>(source: &'a str, spelling: &str) -> &'a str { source.get(..end).unwrap_or("") } +/// Consumes a canonical or localized error literal and records its original UTF-8 byte span. +/// Returns the next character index, or a lexical error for an unknown error spelling. fn error_literal( src: &str, chars: &[(usize, char)], @@ -228,6 +230,8 @@ fn number(src: &str, chars: &[(usize, char)], locale: crate::FormulaLocale, i: u Ok(j) } +/// Emits a word, function, Boolean, or sheet token using the locale and array context. +/// Returns the character index immediately after the consumed token. fn word(src: &str, chars: &[(usize, char)], locale: crate::FormulaLocale, array_depth: usize, i: usize, start: usize, out: &mut Vec) -> usize { let ch = |index| char_at(chars, index); let mut j = i; @@ -264,6 +268,7 @@ fn word(src: &str, chars: &[(usize, char)], locale: crate::FormulaLocale, array_ } } +/// Whether the colon begins a sheet-range qualifier rather than a cell range or array delimiter. fn sheet_range_end(chars: &[(usize, char)], locale: crate::FormulaLocale, array_depth: usize, value: &str, colon: usize, out: &[Token]) -> bool { let ch = |index| char_at(chars, index); let mut end = colon + 1; @@ -404,6 +409,7 @@ pub fn tokenize_locale(src: &str, locale: crate::FormulaLocale) -> Result Result { match self.next() { Tok::Number(n) => Ok(Expr::Number(n)), diff --git a/crates/formula/tests/it/russian_input.rs b/crates/formula/tests/it/russian_input.rs index 3925ca9df3..10c677efe4 100644 --- a/crates/formula/tests/it/russian_input.rs +++ b/crates/formula/tests/it/russian_input.rs @@ -1,9 +1,11 @@ use gridcraft_formula::{FormulaLocale, parse, parse_input, print, print_input}; +/// Parses Russian input without workbook-defined names and prints canonical file syntax. fn input(src: &str) -> String { print(&parse_input(src, FormulaLocale::Ru, |_| false).unwrap()) } +/// Checks Russian aliases, case folding, and argument separators against canonical formulas. #[test] fn russian_aliases_and_argument_separators() { for (src, expected) in [ @@ -30,6 +32,7 @@ fn russian_aliases_and_argument_separators() { } } +/// Checks that Russian entry retains sheet, structured-reference, and array semantics. #[test] fn russian_input_preserves_reference_and_array_syntax() { for (src, expected) in [ @@ -48,6 +51,7 @@ fn russian_input_preserves_reference_and_array_syntax() { assert!(parse("{ИСТИНА}").is_err()); } +/// Checks workbook names and LET/LAMBDA bindings take precedence over translated aliases. #[test] fn russian_input_preserves_defined_and_local_names() { let known = |name: &str| ["СУММ", "ЛОЖЬ"].iter().any(|n| n.to_lowercase() == name.to_lowercase()); @@ -63,6 +67,7 @@ fn russian_input_preserves_defined_and_local_names() { } } +/// Checks the locale supplies decimal, argument, array-row, and array-column separators. #[test] fn russian_locale_controls_decimal_list_array_separators_and_display() { let ru = FormulaLocale::Ru; @@ -82,6 +87,7 @@ fn russian_locale_controls_decimal_list_array_separators_and_display() { assert_eq!(print(&parse_input(&shown, ru, |_| false).unwrap()), print(&expr)); } +/// Checks separators inside quoted text survive localized printing and reparsing. #[test] fn russian_display_round_trips_text_containing_separators() { let ru = FormulaLocale::Ru; @@ -91,6 +97,7 @@ fn russian_display_round_trips_text_containing_separators() { assert_eq!(print(&parse_input(&shown, ru, |_| false).unwrap()), print(&expr)); } +/// Checks the public Russian alias mappings do not collide after locale case folding. #[test] fn russian_table_has_unique_names() { let mut seen = std::collections::HashSet::new(); @@ -103,6 +110,7 @@ fn russian_table_has_unique_names() { assert_eq!(FormulaLocale::Ru.canonical_name("UNKNOWN"), "UNKNOWN"); } +/// Checks Unicode LET/LAMBDA names retain their existing case-insensitive identity. #[test] fn russian_bound_names_keep_unicode_case_identity() { assert_eq!(input("LET(ЛОЖЬ;7;ложь)"), "LET(ЛОЖЬ,7,ложь)"); @@ -111,6 +119,7 @@ fn russian_bound_names_keep_unicode_case_identity() { assert_eq!(FormulaLocale::Ru.canonical_name("поискпозx"), "XMATCH"); } +/// Checks localized error literals round-trip while canonical file spellings remain accepted. #[test] fn russian_errors_parse_print_and_preserve_canonical_file_syntax() { use gridcraft_core::CellError; @@ -130,6 +139,7 @@ fn russian_errors_parse_print_and_preserve_canonical_file_syntax() { assert_eq!(input("\"#ССЫЛКА!\""), "\"#ССЫЛКА!\""); } +/// Checks long flat formulas remain valid without consuming recursive nesting depth. #[test] fn russian_locale_keeps_flat_chains_iterative() { let source = std::iter::repeat_n("СУММ(1)", 400).collect::>().join("+"); diff --git a/crates/ui-egui/src/formula_bar.rs b/crates/ui-egui/src/formula_bar.rs index 64c0f769fd..a3ac06fb23 100644 --- a/crates/ui-egui/src/formula_bar.rs +++ b/crates/ui-egui/src/formula_bar.rs @@ -156,6 +156,8 @@ pub(crate) fn active_spill_anchor(app: &SheetApp) -> Option