From f96298413fcac62d5ec64826e9a6e90ff0da3537 Mon Sep 17 00:00:00 2001 From: quantmind-br Date: Sun, 11 Oct 2026 19:29:22 -0300 Subject: [PATCH 1/7] M12: formulas in German, Brazilian Portuguese and Russian, full Spanish table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The interface language picks the formula language, as in Excel. Each language is a data table in crates/formula/src/input/.rs (function names, booleans, errors, structured reference specifiers and separators) behind one Dialect, so the lexer, parser and display printer take no per-language code: - es (424 names), de (403), pt-BR (412), ru (459), each entry with its source; Microsoft's localized pages are machine-translated, so a name needs two agreeing signals - local error literals (#WERT!, #Н/Д, #¡VALOR!), longest match wins - local table specifiers ([#Kopfzeilen], [#Эта строка]) and separators inside [] - German `.` between array columns; Russian `:` between array rows (no row ranges inside braces), merged with upstream trim references (A1.:.F10, 1.:3) - LET/LAMBDA bindings and workbook names shadow local names case-insensitively in any script - editor autocomplete and argument hints take Unicode names; Insert Function searches and sorts by the local name; help shows the local name with local separators - Czech and French interfaces keep English formulas until they get a table Files, clipboard, engine commands, CLI and MCP keep canonical English syntax. --- README.md | 2 +- ROADMAP.md | 9 +- crates/calc/src/tests.rs | 9 + crates/formula/src/input.rs | 282 ++++++-- crates/formula/src/input/de.rs | 476 +++++++++++++ crates/formula/src/input/es.rs | 506 ++++++++++++++ crates/formula/src/input/pt.rs | 490 +++++++++++++ crates/formula/src/input/ru.rs | 870 ++++++++++++++++++++++++ crates/formula/src/lexer.rs | 26 +- crates/formula/src/parser.rs | 8 +- crates/formula/tests/localized_input.rs | 138 ++++ crates/ui-egui/src/dialogs.rs | 15 +- crates/ui-egui/src/editor.rs | 44 +- crates/ui-egui/src/formula_locale.rs | 10 +- crates/ui-egui/src/i18n.rs | 12 +- crates/ui-egui/tests/formula_locale.rs | 28 +- docs/decisions.md | 17 +- docs/formula-input.md | 78 ++- docs/function-parity.md | 5 +- docs/localization-parity.md | 20 +- 20 files changed, 2910 insertions(+), 135 deletions(-) create mode 100644 crates/formula/src/input/de.rs create mode 100644 crates/formula/src/input/es.rs create mode 100644 crates/formula/src/input/pt.rs create mode 100644 crates/formula/src/input/ru.rs create mode 100644 crates/formula/tests/localized_input.rs diff --git a/README.md b/README.md index 678495f1de..e028acf03b 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,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, Deutsch, Português (Brasil) or Русский under View → Interface language for [local formula names, booleans, errors and separators](docs/formula-input.md). Workbooks, clipboard exchange, and programmatic APIs retain canonical English syntax. ### Web diff --git a/ROADMAP.md b/ROADMAP.md index 070552d07e..7b8ce57940 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -93,8 +93,11 @@ The ribbon is translated into Japanese, Simplified Chinese, Korean, Brazilian Po and German (266–268 labels each, ~12% of ~2,200 strings; merged 10-10 in #31, #104, #82, German added 10-11), the interface follows the system language, and CJK, Thai and Hebrew text falls back to system fonts -(#38). Dialogs, menus and messages are English; formulas and number formats are en-US only. -Localized German formulas and numbers are in PR #163. Detail: [localization-parity.md](docs/localization-parity.md). +(#38). Dialogs, menus and messages are English. Formulas follow the interface language in +Spanish, German, Brazilian Portuguese and Russian (function names, booleans, errors, table +specifiers and separators at the editor; files keep English: [formula-input.md](docs/formula-input.md)); +number formats are en-US only. PR #163 (German formulas and numbers) is open. Detail: +[localization-parity.md](docs/localization-parity.md). | Language | Code | Status | UI strings | |---|---|---|---| @@ -148,6 +151,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | 2026-10-12 | Ink strokes save as unfilled DrawingML paths and reopen as editable ink with their points, colour and width; icons in #551 remain a follow-up | | 2026-10-12 | The selection outline stays visible when extended to the last row (Ctrl+Shift+Down): its sides are cut to the viewport instead of stroking a 20-million-point rectangle (#687) | | 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available | +| 2026-10-11 | Formulas in German, Brazilian Portuguese and Russian, and the full Spanish table: 403–459 local function names per language, local booleans, errors and table specifiers, each language's separators, Unicode autocomplete and Insert Function search; files and APIs keep English | | 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found | | 2026-10-11 | Password-protected workbooks open: GridCraft asks for the password and decrypts Agile and Standard encryption (#11) | | 2026-10-11 | Text boxes keep their formatting: run size, bold, italic, typeface, text colour and outline, alignment, vertical anchor and "no outline" (#377) | @@ -200,6 +204,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | 2026-10-12 | minor | Progress log: margins dragged on the Page Layout rulers (#792) | | 2026-10-12 | minor | Ink preservation across XLSX save/reopen (ink portion of #551) | | 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. | +| 2026-10-11 | minor | Languages: formulas follow the interface language in es, de, pt-BR and ru; progress log row | | 2026-10-11 | minor | Password-encrypted workbooks open (#11); progress log row | | 2026-10-11 | minor | Forecast Sheet (#322); progress log row | | 2026-10-11 | minor | Worksheet functions 505 / 523 (ODD* bond functions); progress log row | diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 196c57de78..bdc0218f9b 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -1266,3 +1266,12 @@ fn offset_extreme_arguments_return_ref() { assert_eq!(crate::evaluate(&wb, 0, c("D1"), formula), Value::Number(expected), "{formula}"); } } + +#[test] +fn formula_language_tables_only_name_functions_the_engine_knows() { + // A typo'd canonical name in a language table would turn a valid local name into #NAME?. + for locale in gridcraft_formula::FormulaLocale::ALL { + let unknown: Vec<&str> = locale.translations().iter().map(|(en, _)| *en).filter(|en| !crate::eval::is_known_function(en)).collect(); + assert!(unknown.is_empty(), "{locale:?}: {unknown:?}"); + } +} diff --git a/crates/formula/src/input.rs b/crates/formula/src/input.rs index 8568023ed1..20dadbe505 100644 --- a/crates/formula/src/input.rs +++ b/crates/formula/src/input.rs @@ -1,61 +1,220 @@ //! Localized aliases accepted at user-entry boundaries; file syntax stays English. +//! +//! Each formula language is a [`Dialect`]: function names, booleans, error literals and +//! structured-reference specifiers as that language's Excel spells them, and its separators. +//! Functions a language doesn't list keep their English name, and English names are accepted in +//! every language. + +use gridcraft_core::CellError; use crate::{Expr, ParseError, parser}; -/// Formula-entry syntax selected by the interface language. Other interface languages -/// currently use the canonical English function table until a translation table is supplied. +mod de; +mod es; +mod pt; +mod ru; + +/// Formula-entry syntax selected by the interface language. Interface languages without a +/// formula table (Japanese, Chinese and Korean Excel use English names; Czech, French and Serbian +/// have no table yet) use [`FormulaLocale::En`]. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum FormulaLocale { #[default] En, Es, + De, + PtBr, + Ru, +} + +/// One formula language's words and separators. +pub(crate) struct Dialect { + list: char, + decimal: char, + array_column: char, + array_row: char, + bool_true: &'static str, + bool_false: &'static str, + /// Every error literal, as this language spells it. + errors: &'static [(CellError, &'static str)], + /// The local spellings of [`TABLE_ITEMS`], in the same order. + table_items: [&'static str; 5], + /// `(canonical, local)` for the functions spelled differently from English, sorted by the + /// canonical name. + functions: &'static [(&'static str, &'static str)], } -const SPANISH: &[(&str, &str)] = &[ - ("SUM", "SUMA"), - ("AVERAGE", "PROMEDIO"), - ("IF", "SI"), - ("COUNT", "CONTAR"), - ("SUMIF", "SUMAR.SI"), - ("COUNTIF", "CONTAR.SI"), - ("VLOOKUP", "BUSCARV"), - ("TODAY", "HOY"), - ("TRUE", "VERDADERO"), - ("FALSE", "FALSO"), -]; +/// Structured-reference specifiers in canonical syntax. +const TABLE_ITEMS: [&str; 5] = ["#All", "#Data", "#Headers", "#Totals", "#This Row"]; impl FormulaLocale { + pub const ALL: [Self; 5] = [Self::En, Self::Es, Self::De, Self::PtBr, Self::Ru]; + + fn dialect(self) -> Option<&'static Dialect> { + match self { + Self::En => None, + Self::Es => Some(&es::DIALECT), + Self::De => Some(&de::DIALECT), + Self::PtBr => Some(&pt::DIALECT), + Self::Ru => Some(&ru::DIALECT), + } + } + pub fn list_separator(self) -> char { - if self == Self::Es { ';' } else { ',' } + self.dialect().map_or(',', |d| d.list) } pub fn decimal_separator(self) -> char { - if self == Self::Es { ',' } else { '.' } + self.dialect().map_or('.', |d| d.decimal) } pub fn array_column_separator(self) -> char { - if self == Self::Es { '\\' } else { ',' } + self.dialect().map_or(',', |d| d.array_column) + } + pub fn array_row_separator(self) -> char { + self.dialect().map_or(';', |d| d.array_row) } + + /// The local name of a canonical function name (itself when the language keeps English). 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.dialect() + .and_then(|d| d.functions.binary_search_by(|(en, _)| cmp_ascii_ignore_case(en, canonical)).ok().and_then(|i| d.functions.get(i))) + .map_or(canonical, |(_, local)| *local) } + + /// The canonical name of a local function name, case-insensitively (itself when it isn't one). 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) + self.dialect().and_then(|d| d.functions.iter().find(|(_, local)| eq_ignore_case(local, localized))).map_or(localized, |(en, _)| *en) + } + + /// `(canonical, local)` for every function this language spells differently from English, + /// sorted by the canonical name. + pub fn translations(self) -> &'static [(&'static str, &'static str)] { + self.dialect().map_or(&[], |d| d.functions) + } + + pub fn boolean_name(self, value: bool) -> &'static str { + match (self.dialect(), value) { + (Some(d), true) => d.bool_true, + (Some(d), false) => d.bool_false, + (None, true) => "TRUE", + (None, false) => "FALSE", + } + } + + /// The boolean a local word spells, case-insensitively. English `TRUE`/`FALSE` are lexed as + /// booleans in every language, so only the local words are matched here. + pub(crate) fn boolean(self, word: &str) -> Option { + let d = self.dialect()?; + if eq_ignore_case(word, d.bool_true) { + Some(true) + } else if eq_ignore_case(word, d.bool_false) { + Some(false) } else { - localized + None + } + } + + pub fn error_name(self, error: CellError) -> &'static str { + self.dialect().and_then(|d| d.errors.iter().find(|(e, _)| *e == error)).map_or_else(|| error.as_str(), |(_, local)| *local) + } + + /// The longest local error literal `text` starts with, and its length in characters. + pub(crate) fn error_prefix(self, text: &str) -> Option<(CellError, usize)> { + let d = self.dialect()?; + d.errors + .iter() + .filter(|(_, local)| starts_with_ignore_case(text, local)) + .map(|(e, local)| (*e, local.chars().count())) + .max_by_key(|(_, len)| *len) + } + + /// Converts the inside of a local structured reference (`[...]` without its outer brackets) + /// to canonical syntax: specifiers in English, `,` between bracketed items. + pub(crate) fn struct_to_canonical(self, inner: &str) -> String { + match self.dialect() { + Some(d) => translate_struct(inner, &d.table_items, &TABLE_ITEMS, d.list, ','), + None => inner.to_string(), } } + + /// Converts the inside of a canonical structured reference to this language. + pub(crate) fn struct_to_local(self, inner: &str) -> String { + match self.dialect() { + Some(d) => translate_struct(inner, &TABLE_ITEMS, &d.table_items, ',', d.list), + None => inner.to_string(), + } + } +} + +fn cmp_ascii_ignore_case(a: &str, b: &str) -> std::cmp::Ordering { + a.bytes().map(|c| c.to_ascii_uppercase()).cmp(b.bytes().map(|c| c.to_ascii_uppercase())) +} + +/// Unicode case-insensitive equality (`zählenwenn` = `ZÄHLENWENN`, `сумм` = `СУММ`). +fn eq_ignore_case(a: &str, b: &str) -> bool { + a.chars().flat_map(char::to_uppercase).eq(b.chars().flat_map(char::to_uppercase)) +} + +fn starts_with_ignore_case(text: &str, prefix: &str) -> bool { + text.chars().take(prefix.chars().count()).flat_map(char::to_uppercase).eq(prefix.chars().flat_map(char::to_uppercase)) +} + +/// Rewrites specifiers (`#Headers` <-> `#Kopfzeilen`) and the separator between bracketed +/// items, leaving column names and `'` escapes alone. +fn translate_struct(inner: &str, from: &[&'static str; 5], to: &[&'static str; 5], from_sep: char, to_sep: char) -> String { + let item = |text: &str| -> Option<&'static str> { from.iter().position(|f| eq_ignore_case(text.trim(), f)).and_then(|i| to.get(i).copied()) }; + if !inner.starts_with('[') { + return item(inner).map_or_else(|| inner.to_string(), str::to_string); + } + let mut out = String::with_capacity(inner.len()); + let mut group = String::new(); + let mut depth = 0usize; + let mut chars = inner.chars(); + while let Some(c) = chars.next() { + match c { + '\'' => { + let target = if depth > 0 { &mut group } else { &mut out }; + target.push(c); + if let Some(escaped) = chars.next() { + target.push(escaped); + } + } + '[' => { + if depth == 0 { + group.clear(); + } else { + group.push(c); + } + depth += 1; + } + ']' if depth > 0 => { + depth -= 1; + if depth == 0 { + out.push('['); + out.push_str(item(&group).unwrap_or(&group)); + out.push(']'); + } else { + group.push(c); + } + } + c if depth == 0 && c == from_sep => out.push(to_sep), + c if depth > 0 => group.push(c), + c => out.push(c), + } + } + if depth > 0 { + // Unbalanced input: keep what was read so the parser reports the error. + out.push('['); + out.push_str(&group); + } + out } /// Parses UI input using the selected function table and separators. Existing names and /// 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) } @@ -89,7 +248,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(); @@ -108,36 +267,41 @@ pub fn print_input(expr: &Expr, locale: FormulaLocale, known_name: impl Fn(&str) } 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() }), + Tok::Semicolon if array_depth > 0 => out.push(locale.array_row_separator()), + Tok::Error(e) => out.push_str(locale.error_name(e)), + Tok::Struct(inner) => { + out.push('['); + out.push_str(&locale.struct_to_local(&inner)); + out.push(']'); + } _ => out.push_str(text), } } 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 - } -} - -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| n.eq_ignore_ascii_case(name)) || known_name(name); + // Names ignore case in any script, so a binding `zählenwenn` shadows the call `ZÄHLENWENN(…)`. + let bound = |name: &str, bindings: &[String]| bindings.iter().rev().any(|n| eq_ignore_case(n, name)) || known_name(name); match expr { Expr::Bool(value) if display => { - let alias = if *value { "VERDADERO" } else { "FALSO" }; + let alias = locale.boolean_name(*value); 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) = locale.boolean(name) { *expr = Expr::Bool(value); } } @@ -147,13 +311,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); } @@ -165,34 +329,46 @@ 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(); } for arg in args { - normalize(arg, known_name, bindings, depth + 1, display)?; + // A worksheet function passed by name (`BYROW(A1:C3;SOMA)`, saved as `_xleta.SUM`) + // is spelled like its call. A local boolean word stays a boolean. + if let Expr::Name(function) = arg + && !bound(function, bindings) + && (display || locale.boolean(function).is_none()) + { + let alias = if display { locale.function_name(function) } else { locale.canonical_name(function) }; + if alias != function.as_str() && (!display || (!bound(alias, bindings) && locale.boolean(alias).is_none())) { + *function = alias.to_string(); + continue; + } + } + 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(_, left, right) => { - normalize(left, known_name, bindings, depth + 1, display)?; - normalize(right, known_name, bindings, depth + 1, display)?; + normalize(left, locale, known_name, bindings, depth + 1, display)?; + normalize(right, locale, known_name, bindings, depth + 1, 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)?; } } } diff --git a/crates/formula/src/input/de.rs b/crates/formula/src/input/de.rs new file mode 100644 index 0000000000..bdf4727b21 --- /dev/null +++ b/crates/formula/src/input/de.rs @@ -0,0 +1,476 @@ +//! Deutsch (Deutschland): the formula words German Excel shows (function names, booleans, errors, +//! structured-reference specifiers) and its formula separators. +//! +//! Function names come from Microsoft Support's de-de Excel function pages (retrieved +//! 2026-10-10; the pages are machine-translated): per page, a vote among the title, the meta +//! description, the syntax paragraph (aligned with the en-US page) and `=NAME(` examples, and an +//! entry needs two agreeing signals. Entries marked `reviewed` were decided by hand from the page +//! evidence and independent function-name references. Functions not listed keep their English name. +//! +//! Sources for the other words: +//! - bool: https://support.microsoft.com/de-de/excel/functions/cell-function (WAHR/FALSCH) | https://support.microsoft.com/de-de/excel/is-functions +//! - table_items: https://it-service-ruhr.de/excel-strukturierte-verweise/ ([#Alle] [#Kopfzeilen] [#Daten] [#Ergebnisse] [#Diese Zeile]) | https://fasteasy.at/tippshop/index.php?mp=1&id=1&id2=17&id3=1728 (#Alle #Daten #Kopfzeilen #Ergebnisse, "[#Diese Zeile]") +//! - errors.#WERT!..#NV,#DATEN_ABRUFEN: https://support.microsoft.com/de-de/excel/functions/error-type-function (ERROR.TYPE table) | https://support.microsoft.com/de-de/excel/functions/cubemember-function ("#DATEN_ABRUFEN…") +//! - errors.#SPILL!: https://support.microsoft.com/de-de/excel/how-to-correct-a-spill-error (title "#ÜBERLAUF!-Fehler") +//! - errors.#CALC!: https://support.microsoft.com/de-de/excel/functions/lambda-function ("#KALK!-Fehlermeldung") | https://excelhero.de/funktionen/excel-filter-funktion/ | https://www.tabellenexperte.de/die-neue-filter-funktion-mit-jokersuche/ | https://www.tippscout.de/excel-daten-filtern-ganz-einfach.html + +use super::Dialect; +use gridcraft_core::CellError; + +pub(super) const DIALECT: Dialect = Dialect { + list: ';', + decimal: ',', + array_column: '.', + array_row: ';', + bool_true: "WAHR", + bool_false: "FALSCH", + errors: &[ + (CellError::Null, "#NULL!"), + (CellError::Div0, "#DIV/0!"), + (CellError::Value, "#WERT!"), + (CellError::Ref, "#BEZUG!"), + (CellError::Name, "#NAME?"), + (CellError::Num, "#ZAHL!"), + (CellError::NA, "#NV"), + (CellError::GettingData, "#DATEN_ABRUFEN"), + (CellError::Spill, "#ÜBERLAUF!"), + (CellError::Calc, "#KALK!"), + ], + table_items: ["#Alle", "#Daten", "#Kopfzeilen", "#Ergebnisse", "#Diese Zeile"], + functions: &[ + ("ACCRINT", "AUFGELZINS"), + ("ACCRINTM", "AUFGELZINSF"), + ("ACOS", "ARCCOS"), + ("ACOSH", "ARCCOSHYP"), + ("ACOT", "ARCCOT"), + ("ACOTH", "ARCCOTHYP"), + ("ADDRESS", "ADRESSE"), + ("AGGREGATE", "AGGREGAT"), + ("AMORDEGRC", "AMORDEGRK"), + ("AMORLINC", "AMORLINEARK"), + ("AND", "UND"), + ("ARABIC", "ARABISCH"), + ("AREAS", "BEREICHE"), + ("ARRAYTOTEXT", "MATRIXZUTEXT"), + ("ASIN", "ARCSIN"), + ("ASINH", "ARCSINHYP"), + ("ATAN", "ARCTAN"), + ("ATAN2", "ARCTAN2"), + ("ATANH", "ARCTANHYP"), + ("AVEDEV", "MITTELABW"), + ("AVERAGE", "MITTELWERT"), + ("AVERAGEA", "MITTELWERTA"), + ("AVERAGEIF", "MITTELWERTWENN"), + ("AVERAGEIFS", "MITTELWERTWENNS"), + ("BASE", "BASIS"), + ("BETA.DIST", "BETA.VERT"), + ("BETADIST", "BETAVERT"), + ("BIN2DEC", "BININDEZ"), + ("BIN2HEX", "BININHEX"), + ("BIN2OCT", "BININOKT"), + ("BINOM.DIST", "BINOM.VERT"), + ("BINOM.DIST.RANGE", "BINOM.VERT.BEREICH"), + ("BINOMDIST", "BINOMVERT"), + ("BITAND", "BITUND"), + ("BITLSHIFT", "BITLVERSCHIEB"), + ("BITOR", "BITODER"), + ("BITRSHIFT", "BITRVERSCHIEB"), + ("BITXOR", "BITXODER"), + // reviewed: sidebar link on MS de-de function pages (page body left BYCOL); pairs with BYROW=NACHZEILE | sources: https://support.microsoft.com/de-de/excel/functions/bycol-function + ("BYCOL", "NACHSPALTE"), + // reviewed: sidebar link on MS de-de function pages (page body left BYROW) | sources: https://support.microsoft.com/de-de/excel/functions/byrow-function + ("BYROW", "NACHZEILE"), + ("CEILING", "OBERGRENZE"), + ("CEILING.MATH", "OBERGRENZE.MATHEMATIK"), + ("CEILING.PRECISE", "OBERGRENZE.GENAU"), + ("CELL", "ZELLE"), + ("CHAR", "ZEICHEN"), + ("CHIDIST", "CHIVERT"), + ("CHISQ.DIST", "CHIQU.VERT"), + ("CHISQ.DIST.RT", "CHIQU.VERT.RE"), + ("CHISQ.INV", "CHIQU.INV"), + ("CHISQ.INV.RT", "CHIQU.INV.RE"), + ("CHISQ.TEST", "CHIQU.TEST"), + ("CHOOSE", "WAHL"), + ("CHOOSECOLS", "SPALTENWAHL"), + // reviewed: sidebar link; pairs with CHOOSECOLS=SPALTENWAHL (agreeing vote); page example uses MT variant ZEILENAUSWAHL | sources: https://support.microsoft.com/de-de/excel/functions/chooserows-function + ("CHOOSEROWS", "ZEILENWAHL"), + ("CLEAN", "SÄUBERN"), + ("COLUMN", "SPALTE"), + ("COLUMNS", "SPALTEN"), + ("COMBIN", "KOMBINATIONEN"), + ("COMBINA", "KOMBINATIONEN2"), + ("COMPLEX", "KOMPLEXE"), + ("CONCAT", "TEXTKETTE"), + ("CONCATENATE", "VERKETTEN"), + ("CONFIDENCE", "KONFIDENZ"), + ("CONFIDENCE.NORM", "KONFIDENZ.NORM"), + ("CONFIDENCE.T", "KONFIDENZ.T"), + ("CONVERT", "UMWANDELN"), + ("CORREL", "KORREL"), + ("COSH", "COSHYP"), + ("COTH", "COTHYP"), + ("COUNT", "ANZAHL"), + ("COUNTA", "ANZAHL2"), + // reviewed: page title ANZAHLLEEREZELLEN; meta/syntax ANZAHLLEERE/ANZAHLLEER are MT truncations; en-US COUNTBLANK page | sources: https://support.microsoft.com/de-de/excel/functions/countblank-function + ("COUNTBLANK", "ANZAHLLEEREZELLEN"), + ("COUNTIF", "ZÄHLENWENN"), + ("COUNTIFS", "ZÄHLENWENNS"), + ("COUPDAYBS", "ZINSTERMTAGVA"), + ("COUPDAYS", "ZINSTERMTAGE"), + ("COUPDAYSNC", "ZINSTERMTAGNZ"), + ("COUPNCD", "ZINSTERMNZ"), + ("COUPNUM", "ZINSTERMZAHL"), + ("COUPPCD", "ZINSTERMVZ"), + ("COVAR", "KOVAR"), + ("COVARIANCE.P", "KOVARIANZ.P"), + ("COVARIANCE.S", "KOVARIANZ.S"), + ("CRITBINOM", "KRITBINOM"), + ("CSC", "COSEC"), + ("CSCH", "COSECHYP"), + ("CUBEKPIMEMBER", "CUBEKPIELEMENT"), + ("CUBEMEMBER", "CUBEELEMENT"), + ("CUBEMEMBERPROPERTY", "CUBEELEMENTEIGENSCHAFT"), + ("CUBERANKEDMEMBER", "CUBERANGELEMENT"), + ("CUBESET", "CUBEMENGE"), + ("CUBESETCOUNT", "CUBEMENGENANZAHL"), + ("CUBEVALUE", "CUBEWERT"), + ("CUMIPMT", "KUMZINSZ"), + ("CUMPRINC", "KUMKAPITAL"), + ("DATE", "DATUM"), + ("DATEVALUE", "DATWERT"), + ("DAVERAGE", "DBMITTELWERT"), + ("DAY", "TAG"), + ("DAYS", "TAGE"), + ("DAYS360", "TAGE360"), + ("DB", "GDA2"), + ("DCOUNT", "DBANZAHL"), + ("DCOUNTA", "DBANZAHL2"), + ("DDB", "GDA"), + ("DEC2BIN", "DEZINBIN"), + ("DEC2HEX", "DEZINHEX"), + ("DEC2OCT", "DEZINOKT"), + ("DECIMAL", "DEZIMAL"), + ("DEGREES", "GRAD"), + ("DEVSQ", "SUMQUADABW"), + ("DGET", "DBAUSZUG"), + ("DISC", "DISAGIO"), + ("DMAX", "DBMAX"), + ("DMIN", "DBMIN"), + // reviewed: page title "DM"; German Excel DOLLAR is DM (meta/syntax left English) | sources: https://support.microsoft.com/de-de/excel/functions/dollar-function + ("DOLLAR", "DM"), + ("DOLLARDE", "NOTIERUNGDEZ"), + ("DOLLARFR", "NOTIERUNGBRU"), + ("DPRODUCT", "DBPRODUKT"), + ("DROP", "WEGLASSEN"), + ("DSTDEV", "DBSTDABW"), + ("DSTDEVP", "DBSTDABWN"), + ("DSUM", "DBSUMME"), + ("DVAR", "DBVARIANZ"), + ("DVARP", "DBVARIANZEN"), + ("EDATE", "EDATUM"), + ("EFFECT", "EFFEKTIV"), + ("ENCODEURL", "URLCODIEREN"), + ("EOMONTH", "MONATSENDE"), + ("ERF", "GAUSSFEHLER"), + ("ERF.PRECISE", "GAUSSF.GENAU"), + ("ERFC", "GAUSSFKOMPL"), + ("ERFC.PRECISE", "GAUSSFKOMPL.GENAU"), + ("ERROR.TYPE", "FEHLER.TYP"), + ("EVEN", "GERADE"), + ("EXACT", "IDENTISCH"), + ("EXPAND", "ERWEITERN"), + ("EXPON.DIST", "EXPON.VERT"), + ("EXPONDIST", "EXPONVERT"), + // reviewed: page title F.VERT + sidebar; F.DIST.RT=F.VERT.RE agrees | sources: https://support.microsoft.com/de-de/excel/functions/f-dist-function + ("F.DIST", "F.VERT"), + ("F.DIST.RT", "F.VERT.RE"), + ("F.INV.RT", "F.INV.RE"), + ("FACT", "FAKULTÄT"), + ("FACTDOUBLE", "ZWEIFAKULTÄT"), + ("FALSE", "FALSCH"), + ("FDIST", "FVERT"), + ("FILTERXML", "XMLFILTERN"), + // reviewed: SEARCH page: "…Groß- und Kleinschreibung… können Sie FINDEN verwenden" (FIND page retired) | sources: https://support.microsoft.com/de-de/excel/functions/search-function + ("FIND", "FINDEN"), + // reviewed: B-variant pattern of FINDEN (LINKSB, TEILB, RECHTSB, LÄNGEB, SUCHENB documented); name itself not on a cached page [inferred] | sources: https://support.microsoft.com/de-de/excel/functions/c7912941-af2a-4bdf-a553-d0d89b0a0628 + ("FINDB", "FINDENB"), + ("FIXED", "FEST"), + ("FLOOR", "UNTERGRENZE"), + ("FLOOR.MATH", "UNTERGRENZE.MATHEMATIK"), + ("FLOOR.PRECISE", "UNTERGRENZE.GENAU"), + // reviewed: page: "PROGNOSE.LINEAR(x;Y_Werte;X_Werte) oder SCHÄTZER(x;Y_Werte;X_Werte)"; PROGNOSE replaced SCHÄTZER only as .LINEAR | sources: https://support.microsoft.com/de-de/excel/forecast-and-forecast-linear-functions + ("FORECAST", "SCHÄTZER"), + // reviewed: same page syntax and example =PROGNOSE.LINEAR(30,A2:A6,B2:B6) | sources: https://support.microsoft.com/de-de/excel/forecast-and-forecast-linear-functions + ("FORECAST.LINEAR", "PROGNOSE.LINEAR"), + ("FORMULATEXT", "FORMELTEXT"), + ("FREQUENCY", "HÄUFIGKEIT"), + ("FV", "ZW"), + ("FVSCHEDULE", "ZW2"), + ("GAMMA.DIST", "GAMMA.VERT"), + ("GAMMADIST", "GAMMAVERT"), + ("GAMMALN.PRECISE", "GAMMALN.GENAU"), + ("GCD", "GGT"), + ("GEOMEAN", "GEOMITTEL"), + ("GESTEP", "GGANZZAHL"), + ("GROUPBY", "GRUPPIERENNACH"), + ("GROWTH", "VARIATION"), + ("HARMEAN", "HARMITTEL"), + ("HEX2BIN", "HEXINBIN"), + ("HEX2DEC", "HEXINDEZ"), + ("HEX2OCT", "HEXINOKT"), + ("HLOOKUP", "WVERWEIS"), + ("HOUR", "STUNDE"), + ("HSTACK", "HSTAPELN"), + ("HYPGEOM.DIST", "HYPGEOM.VERT"), + ("HYPGEOMDIST", "HYPGEOMVERT"), + ("IF", "WENN"), + ("IFERROR", "WENNFEHLER"), + ("IFNA", "WENNNV"), + ("IFS", "WENNS"), + ("IMAGINARY", "IMAGINÄRTEIL"), + ("IMCONJUGATE", "IMKONJUGIERTE"), + ("IMCOSH", "IMCOSHYP"), + ("IMCSC", "IMCOSEC"), + ("IMCSCH", "IMCOSECHYP"), + ("IMPOWER", "IMAPOTENZ"), + ("IMPRODUCT", "IMPRODUKT"), + ("IMREAL", "IMREALTEIL"), + ("IMSECH", "IMSECHYP"), + ("IMSINH", "IMSINHYP"), + ("IMSQRT", "IMWURZEL"), + ("IMSUM", "IMSUMME"), + ("INDIRECT", "INDIREKT"), + ("INT", "GANZZAHL"), + ("INTERCEPT", "ACHSENABSCHNITT"), + ("INTRATE", "ZINSSATZ"), + ("IPMT", "ZINSZ"), + ("IRR", "IKV"), + // reviewed: IS-functions page syntax list and examples =ISTLEER(A2) | sources: https://support.microsoft.com/de-de/excel/is-functions + ("ISBLANK", "ISTLEER"), + // reviewed: IS-functions page syntax list, example =ISTFEHL(A6) | sources: https://support.microsoft.com/de-de/excel/is-functions + ("ISERR", "ISTFEHL"), + // reviewed: IS-functions page syntax list, example =WENN(ISTFEHLER(A1)… | sources: https://support.microsoft.com/de-de/excel/is-functions + ("ISERROR", "ISTFEHLER"), + ("ISEVEN", "ISTGERADE"), + ("ISFORMULA", "ISTFORMEL"), + // reviewed: IS-functions page syntax list, example =ISTLOG(WAHR) | sources: https://support.microsoft.com/de-de/excel/is-functions + ("ISLOGICAL", "ISTLOG"), + // reviewed: IS-functions page syntax list, example =ISTNV(A4) | sources: https://support.microsoft.com/de-de/excel/is-functions + ("ISNA", "ISTNV"), + // reviewed: IS-functions page syntax list ISTKTEXT(Wert) and description row | sources: https://support.microsoft.com/de-de/excel/is-functions + ("ISNONTEXT", "ISTKTEXT"), + // reviewed: IS-functions page syntax list, example =ISTZAHL(4) | sources: https://support.microsoft.com/de-de/excel/is-functions + ("ISNUMBER", "ISTZAHL"), + ("ISO.CEILING", "ISO.OBERGRENZE"), + // reviewed: live MS page https://support.microsoft.com/de-de/excel/functions/isodd-function: title/syntax/examples ISTUNGERADE (the IS-functions page shows MT-garbled "ISUNGERADE") + ("ISODD", "ISTUNGERADE"), + // reviewed: ISOMITTED page syntax and example | sources: https://support.microsoft.com/de-de/excel/functions/isomitted-function + ("ISOMITTED", "WURDEAUSGELASSEN"), + ("ISOWEEKNUM", "ISOKALENDERWOCHE"), + // reviewed: IS-functions page syntax list, example =ISTBEZUG(G8) | sources: https://support.microsoft.com/de-de/excel/is-functions + ("ISREF", "ISTBEZUG"), + // reviewed: IS-functions page syntax list, example =ISTTEXT(A3) | sources: https://support.microsoft.com/de-de/excel/is-functions + ("ISTEXT", "ISTTEXT"), + ("LARGE", "KGRÖSSTE"), + ("LCM", "KGV"), + ("LEFT", "LINKS"), + ("LEFTB", "LINKSB"), + ("LEN", "LÄNGE"), + ("LENB", "LÄNGEB"), + ("LINEST", "RGP"), + ("LOGEST", "RKP"), + ("LOGNORM.DIST", "LOGNORM.VERT"), + ("LOGNORMDIST", "LOGNORMVERT"), + ("LOOKUP", "VERWEIS"), + ("LOWER", "KLEIN"), + // reviewed: sidebar link on MS de-de function pages (page body left MAKEARRAY) | sources: https://support.microsoft.com/de-de/excel/functions/makearray-function + ("MAKEARRAY", "MATRIXERSTELLEN"), + // reviewed: MAP page syntax "=ZUORDNEN (Array1; lambda_or_array<#>)" and example; sidebar says KARTE (MT of the title) – rejected | sources: https://support.microsoft.com/de-de/excel/functions/map-function + ("MAP", "ZUORDNEN"), + ("MATCH", "VERGLEICH"), + ("MAXIFS", "MAXWENNS"), + ("MDETERM", "MDET"), + ("MID", "TEIL"), + ("MIDB", "TEILB"), + ("MINIFS", "MINWENNS"), + ("MINVERSE", "MINV"), + ("MIRR", "QIKV"), + ("MOD", "REST"), + ("MODE", "MODALWERT"), + ("MODE.MULT", "MODUS.VIELF"), + ("MODE.SNGL", "MODUS.EINF"), + ("MONTH", "MONAT"), + ("MROUND", "VRUNDEN"), + ("MULTINOMIAL", "POLYNOMIAL"), + ("MUNIT", "MEINHEIT"), + ("NA", "NV"), + ("NEGBINOM.DIST", "NEGBINOM.VERT"), + ("NEGBINOMDIST", "NEGBINOMVERT"), + ("NETWORKDAYS", "NETTOARBEITSTAGE"), + ("NETWORKDAYS.INTL", "NETTOARBEITSTAGE.INTL"), + ("NORM.DIST", "NORM.VERT"), + ("NORM.S.DIST", "NORM.S.VERT"), + ("NORMDIST", "NORMVERT"), + ("NORMSDIST", "STANDNORMVERT"), + ("NORMSINV", "STANDNORMINV"), + ("NOT", "NICHT"), + ("NOW", "JETZT"), + ("NPER", "ZZR"), + ("NPV", "NBW"), + ("NUMBERVALUE", "ZAHLENWERT"), + ("OCT2BIN", "OKTINBIN"), + ("OCT2DEC", "OKTINDEZ"), + ("OCT2HEX", "OKTINHEX"), + ("ODD", "UNGERADE"), + ("OFFSET", "BEREICH.VERSCHIEBEN"), + ("OR", "ODER"), + ("PERCENTILE", "QUANTIL"), + ("PERCENTILE.EXC", "QUANTIL.EXKL"), + ("PERCENTILE.INC", "QUANTIL.INKL"), + ("PERCENTRANK", "QUANTILSRANG"), + ("PERCENTRANK.EXC", "QUANTILSRANG.EXKL"), + ("PERCENTRANK.INC", "QUANTILSRANG.INKL"), + ("PERMUT", "VARIATIONEN"), + ("PERMUTATIONA", "VARIATIONEN2"), + ("PIVOTBY", "PIVOTMIT"), + ("PMT", "RMZ"), + ("POISSON.DIST", "POISSON.VERT"), + ("POWER", "POTENZ"), + ("PPMT", "KAPZ"), + ("PRICE", "KURS"), + ("PRICEDISC", "KURSDISAGIO"), + ("PRICEMAT", "KURSFÄLLIG"), + ("PROB", "WAHRSCHBEREICH"), + ("PRODUCT", "PRODUKT"), + ("PROPER", "GROSS2"), + ("PV", "BW"), + ("QUARTILE.EXC", "QUARTILE.EXKL"), + ("QUARTILE.INC", "QUARTILE.INKL"), + ("RADIANS", "BOGENMASS"), + ("RAND", "ZUFALLSZAHL"), + ("RANDARRAY", "ZUFALLSMATRIX"), + ("RANDBETWEEN", "ZUFALLSBEREICH"), + ("RANK", "RANG"), + ("RANK.AVG", "RANG.MITTELW"), + ("RANK.EQ", "RANG.GLEICH"), + ("RATE", "ZINS"), + ("RECEIVED", "AUSZAHLUNG"), + ("REPLACE", "ERSETZEN"), + // reviewed: B-variant pattern of ERSETZEN; not named on a cached page [inferred] | sources: https://support.microsoft.com/de-de/excel/functions/replace-function + ("REPLACEB", "ERSETZENB"), + ("REPT", "WIEDERHOLEN"), + ("RIGHT", "RECHTS"), + ("RIGHTB", "RECHTSB"), + ("ROMAN", "RÖMISCH"), + ("ROUND", "RUNDEN"), + ("ROUNDDOWN", "ABRUNDEN"), + ("ROUNDUP", "AUFRUNDEN"), + ("ROW", "ZEILE"), + ("ROWS", "ZEILEN"), + ("RRI", "ZSATZINVEST"), + ("RSQ", "BESTIMMTHEITSMASS"), + ("SEARCH", "SUCHEN"), + ("SEARCHB", "SUCHENB"), + ("SECH", "SECHYP"), + ("SECOND", "SEKUNDE"), + ("SEQUENCE", "SEQUENZ"), + ("SERIESSUM", "POTENZREIHE"), + ("SHEET", "BLATT"), + ("SHEETS", "BLÄTTER"), + ("SIGN", "VORZEICHEN"), + ("SINH", "SINHYP"), + ("SKEW", "SCHIEFE"), + ("SKEW.P", "SCHIEFE.P"), + ("SLN", "LIA"), + ("SLOPE", "STEIGUNG"), + ("SMALL", "KKLEINSTE"), + ("SORT", "SORTIEREN"), + ("SORTBY", "SORTIERENNACH"), + ("SQRT", "WURZEL"), + ("SQRTPI", "WURZELPI"), + ("STANDARDIZE", "STANDARDISIERUNG"), + ("STDEV", "STABW"), + ("STDEV.P", "STABW.N"), + ("STDEV.S", "STABW.S"), + ("STDEVA", "STABWA"), + ("STDEVP", "STABWN"), + ("STDEVPA", "STABWNA"), + ("STEYX", "STFEHLERYX"), + ("SUBSTITUTE", "WECHSELN"), + ("SUBTOTAL", "TEILERGEBNIS"), + ("SUM", "SUMME"), + ("SUMIF", "SUMMEWENN"), + ("SUMIFS", "SUMMEWENNS"), + ("SUMPRODUCT", "SUMMENPRODUKT"), + ("SUMSQ", "QUADRATESUMME"), + ("SUMX2MY2", "SUMMEX2MY2"), + ("SUMX2PY2", "SUMMEX2PY2"), + ("SUMXMY2", "SUMMEXMY2"), + // reviewed: SWITCH page meta + examples + sidebar | sources: https://support.microsoft.com/de-de/excel/functions/switch-function + ("SWITCH", "ERSTERWERT"), + ("SYD", "DIA"), + ("T.DIST", "T.VERT"), + ("T.DIST.2T", "T.VERT.2S"), + ("T.DIST.RT", "T.VERT.RE"), + ("T.INV.2T", "T.INV.2S"), + ("TAKE", "ÜBERNEHMEN"), + ("TANH", "TANHYP"), + ("TBILLEQ", "TBILLÄQUIV"), + ("TBILLPRICE", "TBILLKURS"), + ("TBILLYIELD", "TBILLRENDITE"), + ("TDIST", "TVERT"), + // reviewed: TEXTAFTER page title + meta (TEXTNACH); syntax paragraph left English | sources: https://support.microsoft.com/de-de/excel/functions/textafter-function + ("TEXTAFTER", "TEXTNACH"), + // reviewed: page syntax + examples + sidebar (title/meta left English) | sources: https://support.microsoft.com/de-de/excel/functions/textbefore-function + ("TEXTBEFORE", "TEXTVOR"), + ("TEXTJOIN", "TEXTVERKETTEN"), + // reviewed: page syntax/examples and MS TEXTSPLIT page example =TEXTTEILEN(A1,{",","."}) | sources: https://support.microsoft.com/de-de/excel/functions/textsplit-function | https://support.microsoft.com/de-de/office/textsplit-funktion-b1ca414e-4c21-4ca0-b1b7-bdecace8a6e7 + ("TEXTSPLIT", "TEXTTEILEN"), + ("TIME", "ZEIT"), + ("TIMEVALUE", "ZEITWERT"), + ("TOCOL", "ZUSPALTE"), + ("TODAY", "HEUTE"), + ("TOROW", "ZUZEILE"), + ("TRANSPOSE", "MTRANS"), + ("TRIM", "GLÄTTEN"), + ("TRIMMEAN", "GESTUTZTMITTEL"), + ("TRUE", "WAHR"), + ("TRUNC", "KÜRZEN"), + ("TYPE", "TYP"), + ("UNICHAR", "UNIZEICHEN"), + ("UNIQUE", "EINDEUTIG"), + ("UPPER", "GROSS"), + ("VALUE", "WERT"), + // reviewed: page syntax "WERTZUTEXT(Wert;[Format])" + sidebar; pairs with ARRAYTOTEXT=MATRIXZUTEXT | sources: https://support.microsoft.com/de-de/excel/functions/valuetotext-function + ("VALUETOTEXT", "WERTZUTEXT"), + ("VAR", "VARIANZ"), + ("VARA", "VARIANZA"), + ("VARP", "VARIANZEN"), + ("VARPA", "VARIANZENA"), + ("VLOOKUP", "SVERWEIS"), + ("VSTACK", "VSTAPELN"), + ("WEBSERVICE", "WEBDIENST"), + ("WEEKDAY", "WOCHENTAG"), + ("WEEKNUM", "KALENDERWOCHE"), + ("WEIBULL.DIST", "WEIBULL.VERT"), + ("WORKDAY", "ARBEITSTAG"), + ("WORKDAY.INTL", "ARBEITSTAG.INTL"), + // reviewed: sidebar link (page body left WRAPCOLS) | sources: https://support.microsoft.com/de-de/excel/functions/wrapcols-function + ("WRAPCOLS", "SPALTENUMBRUCH"), + // reviewed: page example =ZEILENUMBRUCH + sidebar | sources: https://support.microsoft.com/de-de/excel/functions/wraprows-function + ("WRAPROWS", "ZEILENUMBRUCH"), + ("XIRR", "XINTZINSFUSS"), + ("XLOOKUP", "XVERWEIS"), + ("XMATCH", "XVERGLEICH"), + ("XNPV", "XKAPITALWERT"), + ("XOR", "XODER"), + ("YEAR", "JAHR"), + ("YEARFRAC", "BRTEILJAHRE"), + ("YIELD", "RENDITE"), + ("YIELDDISC", "RENDITEDIS"), + ("YIELDMAT", "RENDITEFÄLL"), + ("Z.TEST", "G.TEST"), + ("ZTEST", "GTEST"), + ], +}; diff --git a/crates/formula/src/input/es.rs b/crates/formula/src/input/es.rs new file mode 100644 index 0000000000..b5a51b142c --- /dev/null +++ b/crates/formula/src/input/es.rs @@ -0,0 +1,506 @@ +//! Español (España): the formula words Spanish Excel shows (function names, booleans, errors, +//! structured-reference specifiers) and its formula separators. +//! +//! Function names come from Microsoft Support's es-es Excel function pages (retrieved +//! 2026-10-10; the pages are machine-translated): per page, a vote among the title, the meta +//! description, the syntax paragraph (aligned with the en-US page) and `=NAME(` examples, and an +//! entry needs two agreeing signals. Entries marked `reviewed` were decided by hand from the page +//! evidence and independent function-name references. Functions not listed keep their English name. +//! +//! Sources for the other words: +//! - bool: https://support.microsoft.com/es-es/excel/functions/true-function | https://raw.githubusercontent.com/PHPOffice/PhpSpreadsheet/master/src/PhpSpreadsheet/Calculation/locale/es/functions | https://raw.githubusercontent.com/handsontable/hyperformula/master/src/i18n/languages/esES.ts +//! - table_items: https://github.com/GrapeCity/spreadjs-resources (SpreadJS v18 ES resource: #Todo,#Datos,#Encabezados,#Totales,#Esta Fila) | https://support.microsoft.com/es-es/excel/using-structured-references-with-excel-tables (lists #Todas for #All; same #Datos,#Encabezados,#Totales,#Esta Fila) +//! - errors.numeric: https://support.microsoft.com/es-es/excel/functions/error-type-function | https://raw.githubusercontent.com/PHPOffice/PhpSpreadsheet/master/src/PhpSpreadsheet/Calculation/locale/es/config | https://github.com/LibreOffice/translations/blob/master/source/es/formula/messages.po +//! - errors.#N/A: https://support.microsoft.com/es-es/excel/functions/na-function (#N/D) | https://raw.githubusercontent.com/handsontable/hyperformula/master/src/i18n/languages/esES.ts (#N/D) | https://github.com/LibreOffice/translations/blob/master/source/es/formula/messages.po (#N/D) | https://es.stackoverflow.com/questions/538707 (cell value #N/D); error-type page table says #N/A +//! - errors.#SPILL!: https://raw.githubusercontent.com/handsontable/hyperformula/master/src/i18n/languages/esES.ts | https://github.com/LibreOffice/translations/blob/master/source/es/formula/messages.po (#¡DESBORDAMIENTO!) | https://support.microsoft.com/es-es/excel/how-to-correct-a-spill-error (drops the ¡) +//! - errors.#CALC!: https://github.com/ibarrasuarez22-ux/MC_regiduria (real Spanish Excel CSV exports contain #¡CALC!) | https://github.com/Carlos1200/gridcalc/blob/main/src/i18n/es.ts +//! - errors.#GETTING_DATA: https://support.microsoft.com/es-es/excel/functions/error-type-function + +use super::Dialect; +use gridcraft_core::CellError; + +pub(super) const DIALECT: Dialect = Dialect { + list: ';', + decimal: ',', + array_column: '\\', + array_row: ';', + bool_true: "VERDADERO", + bool_false: "FALSO", + errors: &[ + (CellError::Null, "#¡NULO!"), + (CellError::Div0, "#¡DIV/0!"), + (CellError::Value, "#¡VALOR!"), + (CellError::Ref, "#¡REF!"), + (CellError::Name, "#¿NOMBRE?"), + (CellError::Num, "#¡NUM!"), + (CellError::NA, "#N/D"), + (CellError::GettingData, "#OBTENIENDO_DATOS"), + (CellError::Spill, "#¡DESBORDAMIENTO!"), + (CellError::Calc, "#¡CALC!"), + ], + table_items: ["#Todo", "#Datos", "#Encabezados", "#Totales", "#Esta fila"], + functions: &[ + ("ACCRINT", "INT.ACUM"), + ("ACCRINTM", "INT.ACUM.V"), + ("ADDRESS", "DIRECCION"), + ("AGGREGATE", "AGREGAR"), + ("AMORDEGRC", "AMORTIZ.PROGRE"), + ("AMORLINC", "AMORTIZ.LIN"), + ("AND", "Y"), + ("ARABIC", "NUMERO.ARABE"), + ("ARRAYTOTEXT", "MATRIZATEXTO"), + ("ASIN", "ASENO"), + ("ASINH", "ASENOH"), + ("AVEDEV", "DESVPROM"), + ("AVERAGE", "PROMEDIO"), + ("AVERAGEA", "PROMEDIOA"), + ("AVERAGEIF", "PROMEDIO.SI"), + ("AVERAGEIFS", "PROMEDIO.SI.CONJUNTO"), + ("BETA.DIST", "DISTR.BETA.N"), + ("BETA.INV", "INV.BETA.N"), + ("BETADIST", "DISTR.BETA"), + ("BETAINV", "DISTR.BETA.INV"), + ("BIN2DEC", "BIN.A.DEC"), + ("BIN2HEX", "BIN.A.HEX"), + ("BIN2OCT", "BIN.A.OCT"), + ("BINOM.DIST", "DISTR.BINOM.N"), + ("BINOM.DIST.RANGE", "DISTR.BINOM.SERIE"), + ("BINOM.INV", "INV.BINOM"), + ("BINOMDIST", "DISTR.BINOM"), + ("BITAND", "BIT.Y"), + ("BITLSHIFT", "BIT.DESPLIZQDA"), + ("BITOR", "BIT.O"), + ("BITRSHIFT", "BIT.DESPLDCHA"), + ("BITXOR", "BIT.XO"), + ("CEILING", "MULTIPLO.SUPERIOR"), + // reviewed: vote was truncated "MATH"; page title/examples MULTIPLO.SUPERIOR.MAT; ONLYOFFICE, zekke, PhpSpreadsheet agree | sources: OO=https://raw.githubusercontent.com/ONLYOFFICE/web-apps/master/apps/spreadsheeteditor/main/resources/formula-lang/es.json | ZK=https://raw.githubusercontent.com/zekke-io/zekke/main/src/lib/spreadsheets/function-names/es.json + ("CEILING.MATH", "MULTIPLO.SUPERIOR.MAT"), + ("CEILING.PRECISE", "MULTIPLO.SUPERIOR.EXACTO"), + ("CELL", "CELDA"), + ("CHAR", "CARACTER"), + ("CHIDIST", "DISTR.CHI"), + ("CHIINV", "PRUEBA.CHI.INV"), + ("CHISQ.DIST", "DISTR.CHICUAD"), + ("CHISQ.DIST.RT", "DISTR.CHICUAD.CD"), + ("CHISQ.INV", "INV.CHICUAD"), + ("CHISQ.INV.RT", "INV.CHICUAD.CD"), + ("CHISQ.TEST", "PRUEBA.CHICUAD"), + ("CHITEST", "PRUEBA.CHI"), + ("CHOOSE", "ELEGIR"), + ("CHOOSECOLS", "ELEGIRCOLS"), + // reviewed: page example ELEGIRFILAS; ONLYOFFICE, zekke agree; page syntax is untranslated MT | sources: OO=https://raw.githubusercontent.com/ONLYOFFICE/web-apps/master/apps/spreadsheeteditor/main/resources/formula-lang/es.json | ZK=https://raw.githubusercontent.com/zekke-io/zekke/main/src/lib/spreadsheets/function-names/es.json + ("CHOOSEROWS", "ELEGIRFILAS"), + ("CLEAN", "LIMPIAR"), + ("CODE", "CODIGO"), + ("COLUMN", "COLUMNA"), + ("COLUMNS", "COLUMNAS"), + ("COMBIN", "COMBINAT"), + ("COMPLEX", "COMPLEJO"), + ("CONCATENATE", "CONCATENAR"), + ("CONFIDENCE", "INTERVALO.CONFIANZA"), + ("CONFIDENCE.NORM", "INTERVALO.CONFIANZA.NORM"), + ("CONFIDENCE.T", "INTERVALO.CONFIANZA.T"), + ("CONVERT", "CONVERTIR"), + ("CORREL", "COEF.DE.CORREL"), + ("COUNT", "CONTAR"), + ("COUNTA", "CONTARA"), + ("COUNTBLANK", "CONTAR.BLANCO"), + ("COUNTIF", "CONTAR.SI"), + ("COUNTIFS", "CONTAR.SI.CONJUNTO"), + ("COUPDAYBS", "CUPON.DIAS.L1"), + ("COUPDAYS", "CUPON.DIAS"), + ("COUPDAYSNC", "CUPON.DIAS.L2"), + ("COUPNCD", "CUPON.FECHA.L2"), + ("COUPNUM", "CUPON.NUM"), + ("COUPPCD", "CUPON.FECHA.L1"), + ("COVARIANCE.P", "COVARIANZA.P"), + ("COVARIANCE.S", "COVARIANZA.M"), + ("CRITBINOM", "BINOM.CRIT"), + ("CUBEKPIMEMBER", "MIEMBROKPICUBO"), + ("CUBEMEMBER", "MIEMBROCUBO"), + ("CUBEMEMBERPROPERTY", "PROPIEDADMIEMBROCUBO"), + ("CUBERANKEDMEMBER", "MIEMBRORANGOCUBO"), + ("CUBESET", "CONJUNTOCUBO"), + ("CUBESETCOUNT", "RECUENTOCONJUNTOCUBO"), + ("CUBEVALUE", "VALORCUBO"), + ("CUMIPMT", "PAGO.INT.ENTRE"), + ("CUMPRINC", "PAGO.PRINC.ENTRE"), + ("DATE", "FECHA"), + ("DATEDIF", "SIFECHA"), + ("DATEVALUE", "FECHANUMERO"), + ("DAVERAGE", "BDPROMEDIO"), + ("DAY", "DIA"), + ("DAYS", "DIAS"), + ("DAYS360", "DIAS360"), + ("DCOUNT", "BDCONTAR"), + ("DCOUNTA", "BDCONTARA"), + ("DEC2BIN", "DEC.A.BIN"), + ("DEC2HEX", "DEC.A.HEX"), + ("DEC2OCT", "DEC.A.OCT"), + ("DECIMAL", "CONV.DECIMAL"), + ("DEGREES", "GRADOS"), + ("DEVSQ", "DESVIA2"), + ("DGET", "BDEXTRAER"), + ("DISC", "TASA.DESC"), + ("DMAX", "BDMAX"), + ("DMIN", "BDMIN"), + ("DOLLAR", "MONEDA"), + ("DOLLARDE", "MONEDA.DEC"), + ("DOLLARFR", "MONEDA.FRAC"), + ("DPRODUCT", "BDPRODUCTO"), + // reviewed: page syntax/examples EXCLUIR; ONLYOFFICE, zekke agree | sources: OO=https://raw.githubusercontent.com/ONLYOFFICE/web-apps/master/apps/spreadsheeteditor/main/resources/formula-lang/es.json | ZK=https://raw.githubusercontent.com/zekke-io/zekke/main/src/lib/spreadsheets/function-names/es.json + ("DROP", "EXCLUIR"), + ("DSTDEV", "BDDESVEST"), + ("DSTDEVP", "BDDESVESTP"), + ("DSUM", "BDSUMA"), + ("DURATION", "DURACION"), + ("DVAR", "BDVAR"), + ("DVARP", "BDVARP"), + ("EDATE", "FECHA.MES"), + ("EFFECT", "INT.EFECTIVO"), + // reviewed: page syntax URLCODIF(texto); zekke, PhpSpreadsheet agree; title is untranslated MT | sources: OO=https://raw.githubusercontent.com/ONLYOFFICE/web-apps/master/apps/spreadsheeteditor/main/resources/formula-lang/es.json | ZK=https://raw.githubusercontent.com/zekke-io/zekke/main/src/lib/spreadsheets/function-names/es.json + ("ENCODEURL", "URLCODIF"), + ("EOMONTH", "FIN.MES"), + ("ERF", "FUN.ERROR"), + ("ERF.PRECISE", "FUN.ERROR.EXACTO"), + ("ERFC", "FUN.ERROR.COMPL"), + ("ERFC.PRECISE", "FUN.ERROR.COMPL.EXACTO"), + ("ERROR.TYPE", "TIPO.DE.ERROR"), + ("EVEN", "REDONDEA.PAR"), + ("EXACT", "IGUAL"), + // reviewed: page syntax/examples and nav; ONLYOFFICE, zekke agree | sources: OO=https://raw.githubusercontent.com/ONLYOFFICE/web-apps/master/apps/spreadsheeteditor/main/resources/formula-lang/es.json | ZK=https://raw.githubusercontent.com/zekke-io/zekke/main/src/lib/spreadsheets/function-names/es.json + ("EXPAND", "EXPANDIR"), + ("EXPON.DIST", "DISTR.EXP.N"), + ("EXPONDIST", "DISTR.EXP"), + ("F.DIST", "DISTR.F.N"), + ("F.DIST.RT", "DISTR.F.CD"), + ("F.INV", "INV.F"), + ("F.INV.RT", "INV.F.CD"), + ("F.TEST", "PRUEBA.F.N"), + ("FACTDOUBLE", "FACT.DOBLE"), + ("FALSE", "FALSO"), + ("FDIST", "DISTR.F"), + ("FILTER", "FILTRAR"), + // reviewed: page meta/syntax; zekke, PhpSpreadsheet agree | sources: OO=https://raw.githubusercontent.com/ONLYOFFICE/web-apps/master/apps/spreadsheeteditor/main/resources/formula-lang/es.json | ZK=https://raw.githubusercontent.com/zekke-io/zekke/main/src/lib/spreadsheets/function-names/es.json + ("FILTERXML", "XMLFILTRO"), + // reviewed: FIND/FINDB page absent/retired in cache; ONLYOFFICE, PhpSpreadsheet, HyperFormula agree | sources: OO=https://raw.githubusercontent.com/ONLYOFFICE/web-apps/master/apps/spreadsheeteditor/main/resources/formula-lang/es.json | ZK=https://raw.githubusercontent.com/zekke-io/zekke/main/src/lib/spreadsheets/function-names/es.json + ("FIND", "ENCONTRAR"), + // reviewed: B-variant of ENCONTRAR; ONLYOFFICE only (zekke has English FINDB) | sources: OO=https://raw.githubusercontent.com/ONLYOFFICE/web-apps/master/apps/spreadsheeteditor/main/resources/formula-lang/es.json | ZK=https://raw.githubusercontent.com/zekke-io/zekke/main/src/lib/spreadsheets/function-names/es.json + ("FINDB", "ENCONTRARB"), + ("FINV", "DISTR.F.INV"), + ("FISHERINV", "PRUEBA.FISHER.INV"), + ("FIXED", "DECIMAL"), + ("FLOOR", "MULTIPLO.INFERIOR"), + ("FLOOR.MATH", "MULTIPLO.INFERIOR.MAT"), + ("FLOOR.PRECISE", "MULTIPLO.INFERIOR.EXACTO"), + ("FORECAST", "PRONOSTICO"), + // reviewed: page syntax and meta; PhpSpreadsheet agrees | sources: OO=https://raw.githubusercontent.com/ONLYOFFICE/web-apps/master/apps/spreadsheeteditor/main/resources/formula-lang/es.json | ZK=https://raw.githubusercontent.com/zekke-io/zekke/main/src/lib/spreadsheets/function-names/es.json + ("FORECAST.LINEAR", "PRONOSTICO.LINEAL"), + ("FORMULATEXT", "FORMULATEXTO"), + ("FREQUENCY", "FRECUENCIA"), + ("FTEST", "PRUEBA.F"), + ("FV", "VF"), + ("FVSCHEDULE", "VF.PLAN"), + ("GAMMA.DIST", "DISTR.GAMMA.N"), + ("GAMMA.INV", "INV.GAMMA"), + ("GAMMADIST", "DISTR.GAMMA"), + ("GAMMAINV", "DISTR.GAMMA.INV"), + ("GAMMALN", "GAMMA.LN"), + ("GAMMALN.PRECISE", "GAMMA.LN.EXACTO"), + ("GCD", "M.C.D"), + ("GEOMEAN", "MEDIA.GEOM"), + ("GESTEP", "MAYOR.O.IGUAL"), + ("GROUPBY", "AGRUPARPOR"), + ("GROWTH", "CRECIMIENTO"), + ("HARMEAN", "MEDIA.ARMO"), + ("HEX2BIN", "HEX.A.BIN"), + ("HEX2DEC", "HEX.A.DEC"), + ("HEX2OCT", "HEX.A.OCT"), + ("HLOOKUP", "BUSCARH"), + // reviewed: page title/syntax/examples HORA; all independent sources agree | sources: OO=https://raw.githubusercontent.com/ONLYOFFICE/web-apps/master/apps/spreadsheeteditor/main/resources/formula-lang/es.json | ZK=https://raw.githubusercontent.com/zekke-io/zekke/main/src/lib/spreadsheets/function-names/es.json + ("HOUR", "HORA"), + ("HSTACK", "APILARH"), + ("HYPERLINK", "HIPERVINCULO"), + ("HYPGEOM.DIST", "DISTR.HIPERGEOM.N"), + ("HYPGEOMDIST", "DISTR.HIPERGEOM"), + ("IF", "SI"), + ("IFERROR", "SI.ERROR"), + ("IFNA", "SI.ND"), + ("IFS", "SI.CONJUNTO"), + ("IMABS", "IM.ABS"), + ("IMAGINARY", "IMAGINARIO"), + ("IMARGUMENT", "IM.ANGULO"), + ("IMCONJUGATE", "IM.CONJUGADA"), + ("IMCOS", "IM.COS"), + ("IMCOSH", "IM.COSH"), + ("IMCOT", "IM.COT"), + ("IMCSC", "IM.CSC"), + ("IMCSCH", "IM.CSCH"), + ("IMDIV", "IM.DIV"), + ("IMEXP", "IM.EXP"), + ("IMLN", "IM.LN"), + ("IMLOG10", "IM.LOG10"), + ("IMLOG2", "IM.LOG2"), + ("IMPOWER", "IM.POT"), + ("IMPRODUCT", "IM.PRODUCT"), + ("IMREAL", "IM.REAL"), + ("IMSEC", "IM.SEC"), + ("IMSECH", "IM.SECH"), + ("IMSIN", "IM.SENO"), + ("IMSINH", "IM.SENOH"), + ("IMSQRT", "IM.RAIZ2"), + ("IMSUB", "IM.SUSTR"), + ("IMSUM", "IM.SUM"), + ("IMTAN", "IM.TAN"), + ("INDEX", "INDICE"), + ("INDIRECT", "INDIRECTO"), + ("INT", "ENTERO"), + ("INTERCEPT", "INTERSECCION.EJE"), + ("INTRATE", "TASA.INT"), + ("IPMT", "PAGOINT"), + ("IRR", "TIR"), + // reviewed: IS* page examples/meta list ESBLANCO; ONLYOFFICE, zekke, PhpSpreadsheet, HyperFormula agree | sources: OO=https://raw.githubusercontent.com/ONLYOFFICE/web-apps/master/apps/spreadsheeteditor/main/resources/formula-lang/es.json | ZK=https://raw.githubusercontent.com/zekke-io/zekke/main/src/lib/spreadsheets/function-names/es.json + ("ISBLANK", "ESBLANCO"), + // reviewed: IS* page examples/meta list ESERR; ONLYOFFICE, zekke, PhpSpreadsheet, HyperFormula agree | sources: OO=https://raw.githubusercontent.com/ONLYOFFICE/web-apps/master/apps/spreadsheeteditor/main/resources/formula-lang/es.json | ZK=https://raw.githubusercontent.com/zekke-io/zekke/main/src/lib/spreadsheets/function-names/es.json + ("ISERR", "ESERR"), + // reviewed: IS* page examples/meta list ESERROR; ONLYOFFICE, zekke, PhpSpreadsheet, HyperFormula agree | sources: OO=https://raw.githubusercontent.com/ONLYOFFICE/web-apps/master/apps/spreadsheeteditor/main/resources/formula-lang/es.json | ZK=https://raw.githubusercontent.com/zekke-io/zekke/main/src/lib/spreadsheets/function-names/es.json + ("ISERROR", "ESERROR"), + ("ISEVEN", "ES.PAR"), + ("ISFORMULA", "ESFORMULA"), + // reviewed: IS* page examples/meta list ESLOGICO; ONLYOFFICE, zekke, PhpSpreadsheet, HyperFormula agree | sources: OO=https://raw.githubusercontent.com/ONLYOFFICE/web-apps/master/apps/spreadsheeteditor/main/resources/formula-lang/es.json | ZK=https://raw.githubusercontent.com/zekke-io/zekke/main/src/lib/spreadsheets/function-names/es.json + ("ISLOGICAL", "ESLOGICO"), + // reviewed: IS* page examples/meta list ESNOD; ONLYOFFICE, zekke, PhpSpreadsheet, HyperFormula agree | sources: OO=https://raw.githubusercontent.com/ONLYOFFICE/web-apps/master/apps/spreadsheeteditor/main/resources/formula-lang/es.json | ZK=https://raw.githubusercontent.com/zekke-io/zekke/main/src/lib/spreadsheets/function-names/es.json + ("ISNA", "ESNOD"), + // reviewed: IS* page examples/meta list ESNOTEXTO; ONLYOFFICE, zekke, PhpSpreadsheet, HyperFormula agree | sources: OO=https://raw.githubusercontent.com/ONLYOFFICE/web-apps/master/apps/spreadsheeteditor/main/resources/formula-lang/es.json | ZK=https://raw.githubusercontent.com/zekke-io/zekke/main/src/lib/spreadsheets/function-names/es.json + ("ISNONTEXT", "ESNOTEXTO"), + // reviewed: IS* page examples/meta list ESNUMERO; ONLYOFFICE, zekke, PhpSpreadsheet, HyperFormula agree | sources: OO=https://raw.githubusercontent.com/ONLYOFFICE/web-apps/master/apps/spreadsheeteditor/main/resources/formula-lang/es.json | ZK=https://raw.githubusercontent.com/zekke-io/zekke/main/src/lib/spreadsheets/function-names/es.json + ("ISNUMBER", "ESNUMERO"), + ("ISO.CEILING", "MULTIPLO.SUPERIOR.ISO"), + ("ISODD", "ES.IMPAR"), + ("ISOWEEKNUM", "ISO.NUM.DE.SEMANA"), + ("ISPMT", "INT.PAGO.DIR"), + // reviewed: IS* page examples/meta list ESREF; ONLYOFFICE, zekke, PhpSpreadsheet, HyperFormula agree | sources: OO=https://raw.githubusercontent.com/ONLYOFFICE/web-apps/master/apps/spreadsheeteditor/main/resources/formula-lang/es.json | ZK=https://raw.githubusercontent.com/zekke-io/zekke/main/src/lib/spreadsheets/function-names/es.json + ("ISREF", "ESREF"), + // reviewed: IS* page examples/meta list ESTEXTO; ONLYOFFICE, zekke, PhpSpreadsheet, HyperFormula agree | sources: OO=https://raw.githubusercontent.com/ONLYOFFICE/web-apps/master/apps/spreadsheeteditor/main/resources/formula-lang/es.json | ZK=https://raw.githubusercontent.com/zekke-io/zekke/main/src/lib/spreadsheets/function-names/es.json + ("ISTEXT", "ESTEXTO"), + ("KURT", "CURTOSIS"), + ("LARGE", "K.ESIMO.MAYOR"), + ("LCM", "M.C.M"), + ("LEFT", "IZQUIERDA"), + ("LEFTB", "IZQUIERDAB"), + // reviewed: page meta/syntax: LARGO(texto), LARGOB; ONLYOFFICE, zekke, PhpSpreadsheet, HyperFormula agree; vote picked English page title | sources: OO=https://raw.githubusercontent.com/ONLYOFFICE/web-apps/master/apps/spreadsheeteditor/main/resources/formula-lang/es.json | ZK=https://raw.githubusercontent.com/zekke-io/zekke/main/src/lib/spreadsheets/function-names/es.json + ("LEN", "LARGO"), + // reviewed: page meta says LARGOB devuelve el número de bytes; ONLYOFFICE, zekke agree | sources: OO=https://raw.githubusercontent.com/ONLYOFFICE/web-apps/master/apps/spreadsheeteditor/main/resources/formula-lang/es.json | ZK=https://raw.githubusercontent.com/zekke-io/zekke/main/src/lib/spreadsheets/function-names/es.json + ("LENB", "LARGOB"), + ("LINEST", "ESTIMACION.LINEAL"), + ("LOGEST", "ESTIMACION.LOGARITMICA"), + ("LOGINV", "DISTR.LOG.INV"), + ("LOGNORM.DIST", "DISTR.LOGNORM"), + ("LOGNORM.INV", "INV.LOGNORM"), + ("LOGNORMDIST", "DISTR.LOG.NORM"), + ("LOOKUP", "BUSCAR"), + ("LOWER", "MINUSC"), + ("MATCH", "COINCIDIR"), + ("MAXIFS", "MAX.SI.CONJUNTO"), + ("MDURATION", "DURACION.MODIF"), + ("MEDIAN", "MEDIANA"), + ("MID", "EXTRAE"), + ("MIDB", "EXTRAEB"), + ("MINIFS", "MIN.SI.CONJUNTO"), + ("MINUTE", "MINUTO"), + ("MINVERSE", "MINVERSA"), + ("MIRR", "TIRM"), + ("MOD", "RESIDUO"), + ("MODE", "MODA"), + ("MODE.MULT", "MODA.VARIOS"), + ("MODE.SNGL", "MODA.UNO"), + ("MONTH", "MES"), + ("MROUND", "REDOND.MULT"), + ("MUNIT", "M.UNIDAD"), + ("NA", "NOD"), + ("NETWORKDAYS", "DIAS.LAB"), + ("NETWORKDAYS.INTL", "DIAS.LAB.INTL"), + ("NOMINAL", "TASA.NOMINAL"), + ("NORM.DIST", "DISTR.NORM.N"), + ("NORM.INV", "INV.NORM"), + ("NORM.S.DIST", "DISTR.NORM.ESTAND.N"), + ("NORM.S.INV", "INV.NORM.ESTAND"), + ("NORMDIST", "DISTR.NORM"), + ("NORMINV", "DISTR.NORM.INV"), + ("NORMSDIST", "DISTR.NORM.ESTAND"), + ("NORMSINV", "DISTR.NORM.ESTAND.INV"), + ("NOT", "NO"), + ("NOW", "AHORA"), + ("NPV", "VNA"), + ("NUMBERVALUE", "VALOR.NUMERO"), + ("OCT2BIN", "OCT.A.BIN"), + ("OCT2DEC", "OCT.A.DEC"), + ("OCT2HEX", "OCT.A.HEX"), + ("ODD", "REDONDEA.IMPAR"), + ("OFFSET", "DESREF"), + ("OR", "O"), + ("PDURATION", "P.DURACION"), + ("PERCENTILE", "PERCENTIL"), + // reviewed: vote was truncated "EXC"; ONLYOFFICE, zekke, PhpSpreadsheet, HyperFormula and Microsoft nav list agree | sources: OO=https://raw.githubusercontent.com/ONLYOFFICE/web-apps/master/apps/spreadsheeteditor/main/resources/formula-lang/es.json | ZK=https://raw.githubusercontent.com/zekke-io/zekke/main/src/lib/spreadsheets/function-names/es.json + ("PERCENTILE.EXC", "PERCENTIL.EXC"), + ("PERCENTILE.INC", "PERCENTIL.INC"), + ("PERCENTRANK", "RANGO.PERCENTIL"), + ("PERCENTRANK.EXC", "RANGO.PERCENTIL.EXC"), + ("PERCENTRANK.INC", "RANGO.PERCENTIL.INC"), + ("PERMUT", "PERMUTACIONES"), + ("PERMUTATIONA", "PERMUTACIONES.A"), + ("PHI", "FI"), + ("PHONETIC", "FONETICO"), + ("PIVOTBY", "PIVOTARPOR"), + ("PMT", "PAGO"), + ("POWER", "POTENCIA"), + ("PPMT", "PAGOPRIN"), + ("PRICE", "PRECIO"), + ("PRICEDISC", "PRECIO.DESCUENTO"), + ("PRICEMAT", "PRECIO.VENCIMIENTO"), + ("PROB", "PROBABILIDAD"), + ("PRODUCT", "PRODUCTO"), + ("PROPER", "NOMPROPIO"), + ("PV", "VA"), + ("QUARTILE", "CUARTIL"), + ("QUARTILE.EXC", "CUARTIL.EXC"), + ("QUARTILE.INC", "CUARTIL.INC"), + ("QUOTIENT", "COCIENTE"), + ("RADIANS", "RADIANES"), + // reviewed: vote was truncated "ALEAT"; page examples contain ALEATORIO; ONLYOFFICE, zekke, PhpSpreadsheet, HyperFormula agree | sources: OO=https://raw.githubusercontent.com/ONLYOFFICE/web-apps/master/apps/spreadsheeteditor/main/resources/formula-lang/es.json | ZK=https://raw.githubusercontent.com/zekke-io/zekke/main/src/lib/spreadsheets/function-names/es.json + ("RAND", "ALEATORIO"), + ("RANDARRAY", "MATRIZALEAT"), + ("RANDBETWEEN", "ALEATORIO.ENTRE"), + ("RANK", "JERARQUIA"), + ("RANK.AVG", "JERARQUIA.MEDIA"), + ("RANK.EQ", "JERARQUIA.EQV"), + ("RATE", "TASA"), + ("RECEIVED", "CANTIDAD.RECIBIDA"), + ("REGEXEXTRACT", "REGEXEXTRACCION"), + // reviewed: page title/meta; ONLYOFFICE agrees (zekke English) | sources: OO=https://raw.githubusercontent.com/ONLYOFFICE/web-apps/master/apps/spreadsheeteditor/main/resources/formula-lang/es.json | ZK=https://raw.githubusercontent.com/zekke-io/zekke/main/src/lib/spreadsheets/function-names/es.json + ("REGEXREPLACE", "REGEXREEMPLAZAR"), + // reviewed: page title/meta; ONLYOFFICE agrees (zekke English) | sources: OO=https://raw.githubusercontent.com/ONLYOFFICE/web-apps/master/apps/spreadsheeteditor/main/resources/formula-lang/es.json | ZK=https://raw.githubusercontent.com/zekke-io/zekke/main/src/lib/spreadsheets/function-names/es.json + ("REGEXTEST", "REGEXPRUEBA"), + ("REPLACE", "REEMPLAZAR"), + // reviewed: REEMPLAZAR page meta lists REEMPLAZARB; ONLYOFFICE, zekke agree | sources: OO=https://raw.githubusercontent.com/ONLYOFFICE/web-apps/master/apps/spreadsheeteditor/main/resources/formula-lang/es.json | ZK=https://raw.githubusercontent.com/zekke-io/zekke/main/src/lib/spreadsheets/function-names/es.json + ("REPLACEB", "REEMPLAZARB"), + ("REPT", "REPETIR"), + ("RIGHT", "DERECHA"), + ("RIGHTB", "DERECHAB"), + ("ROMAN", "NUMERO.ROMANO"), + ("ROUND", "REDONDEAR"), + ("ROUNDDOWN", "REDONDEAR.MENOS"), + ("ROUNDUP", "REDONDEAR.MAS"), + ("ROW", "FILA"), + ("ROWS", "FILAS"), + ("RSQ", "COEFICIENTE.R2"), + ("SEARCH", "HALLAR"), + ("SEARCHB", "HALLARB"), + ("SECOND", "SEGUNDO"), + ("SEQUENCE", "SECUENCIA"), + ("SERIESSUM", "SUMA.SERIES"), + ("SHEET", "HOJA"), + ("SHEETS", "HOJAS"), + ("SIGN", "SIGNO"), + ("SIN", "SENO"), + // reviewed: page syntax SENOH(número); ONLYOFFICE, zekke, PhpSpreadsheet, HyperFormula agree; consistent with SENO/ASENOH | sources: OO=https://raw.githubusercontent.com/ONLYOFFICE/web-apps/master/apps/spreadsheeteditor/main/resources/formula-lang/es.json | ZK=https://raw.githubusercontent.com/zekke-io/zekke/main/src/lib/spreadsheets/function-names/es.json + ("SINH", "SENOH"), + ("SKEW", "COEFICIENTE.ASIMETRIA"), + ("SKEW.P", "COEFICIENTE.ASIMETRIA.P"), + ("SLOPE", "PENDIENTE"), + ("SMALL", "K.ESIMO.MENOR"), + ("SORT", "ORDENAR"), + ("SORTBY", "ORDENARPOR"), + ("SQRT", "RAIZ"), + ("SQRTPI", "RAIZ2PI"), + ("STANDARDIZE", "NORMALIZACION"), + ("STDEV", "DESVEST"), + ("STDEV.P", "DESVEST.P"), + ("STDEV.S", "DESVEST.M"), + ("STDEVA", "DESVESTA"), + ("STDEVP", "DESVESTP"), + ("STDEVPA", "DESVESTPA"), + ("STEYX", "ERROR.TIPICO.XY"), + ("SUBSTITUTE", "SUSTITUIR"), + ("SUBTOTAL", "SUBTOTALES"), + ("SUM", "SUMA"), + ("SUMIF", "SUMAR.SI"), + ("SUMIFS", "SUMAR.SI.CONJUNTO"), + ("SUMPRODUCT", "SUMAPRODUCTO"), + ("SUMSQ", "SUMA.CUADRADOS"), + ("SUMX2MY2", "SUMAX2MENOSY2"), + ("SUMX2PY2", "SUMAX2MASY2"), + ("SUMXMY2", "SUMAXMENOSY2"), + ("SWITCH", "CAMBIAR"), + ("T.DIST", "DISTR.T.N"), + ("T.DIST.2T", "DISTR.T.2C"), + ("T.DIST.RT", "DISTR.T.CD"), + ("T.INV", "INV.T"), + ("T.INV.2T", "INV.T.2C"), + ("T.TEST", "PRUEBA.T.N"), + ("TAKE", "TOMAR"), + // reviewed: syntax+examples+Microsoft nav list say TEST; page title/meta and ONLYOFFICE say TES; zekke/PhpSpreadsheet/HyperFormula say TEST | sources: MS nav+syntax, PhpSpreadsheet, zekke, HyperFormula (TEST); ONLYOFFICE TES + ("TBILLEQ", "LETRA.DE.TEST.EQV.A.BONO"), + ("TBILLPRICE", "LETRA.DE.TES.PRECIO"), + ("TBILLYIELD", "LETRA.DE.TES.RENDTO"), + ("TDIST", "DISTR.T"), + ("TEXT", "TEXTO"), + // reviewed: page meta and example TEXTODESPUES; ONLYOFFICE, zekke agree; syntax line is untranslated MT | sources: OO=https://raw.githubusercontent.com/ONLYOFFICE/web-apps/master/apps/spreadsheeteditor/main/resources/formula-lang/es.json | ZK=https://raw.githubusercontent.com/zekke-io/zekke/main/src/lib/spreadsheets/function-names/es.json + ("TEXTAFTER", "TEXTODESPUES"), + // reviewed: page example TEXTOANTES; ONLYOFFICE, zekke agree; syntax line is untranslated MT | sources: OO=https://raw.githubusercontent.com/ONLYOFFICE/web-apps/master/apps/spreadsheeteditor/main/resources/formula-lang/es.json | ZK=https://raw.githubusercontent.com/zekke-io/zekke/main/src/lib/spreadsheets/function-names/es.json + ("TEXTBEFORE", "TEXTOANTES"), + ("TEXTJOIN", "UNIRCADENAS"), + // reviewed: page syntax/examples, nav; ONLYOFFICE, zekke agree | sources: OO=https://raw.githubusercontent.com/ONLYOFFICE/web-apps/master/apps/spreadsheeteditor/main/resources/formula-lang/es.json | ZK=https://raw.githubusercontent.com/zekke-io/zekke/main/src/lib/spreadsheets/function-names/es.json + ("TEXTSPLIT", "DIVIDIRTEXTO"), + // reviewed: page meta/examples NSHORA (HORA is HOUR); ONLYOFFICE, zekke, PhpSpreadsheet, HyperFormula agree | sources: OO=https://raw.githubusercontent.com/ONLYOFFICE/web-apps/master/apps/spreadsheeteditor/main/resources/formula-lang/es.json | ZK=https://raw.githubusercontent.com/zekke-io/zekke/main/src/lib/spreadsheets/function-names/es.json + ("TIME", "NSHORA"), + ("TIMEVALUE", "HORANUMERO"), + ("TINV", "DISTR.T.INV"), + // reviewed: page syntax/examples, nav; ONLYOFFICE, zekke agree | sources: OO=https://raw.githubusercontent.com/ONLYOFFICE/web-apps/master/apps/spreadsheeteditor/main/resources/formula-lang/es.json | ZK=https://raw.githubusercontent.com/zekke-io/zekke/main/src/lib/spreadsheets/function-names/es.json + ("TOCOL", "ENCOL"), + ("TODAY", "HOY"), + // reviewed: Microsoft nav list ENFILA; ONLYOFFICE, zekke agree; page body untranslated MT | sources: OO=https://raw.githubusercontent.com/ONLYOFFICE/web-apps/master/apps/spreadsheeteditor/main/resources/formula-lang/es.json | ZK=https://raw.githubusercontent.com/zekke-io/zekke/main/src/lib/spreadsheets/function-names/es.json + ("TOROW", "ENFILA"), + ("TRANSPOSE", "TRANSPONER"), + ("TREND", "TENDENCIA"), + ("TRIM", "ESPACIOS"), + ("TRIMMEAN", "MEDIA.ACOTADA"), + // reviewed: page meta/examples VERDADERO; all independent sources agree; vote picked truncated "VERDAD" | sources: OO=https://raw.githubusercontent.com/ONLYOFFICE/web-apps/master/apps/spreadsheeteditor/main/resources/formula-lang/es.json | ZK=https://raw.githubusercontent.com/zekke-io/zekke/main/src/lib/spreadsheets/function-names/es.json + ("TRUE", "VERDADERO"), + ("TRUNC", "TRUNCAR"), + ("TTEST", "PRUEBA.T"), + ("TYPE", "TIPO"), + ("UNICHAR", "UNICAR"), + // reviewed: page title "Función UNICOS" and meta; HyperFormula UNICOS; ONLYOFFICE/zekke only have English fallback | sources: OO=https://raw.githubusercontent.com/ONLYOFFICE/web-apps/master/apps/spreadsheeteditor/main/resources/formula-lang/es.json | ZK=https://raw.githubusercontent.com/zekke-io/zekke/main/src/lib/spreadsheets/function-names/es.json + ("UNIQUE", "UNICOS"), + ("UPPER", "MAYUSC"), + ("VALUE", "VALOR"), + ("VALUETOTEXT", "VALORATEXTO"), + ("VDB", "DVS"), + ("VLOOKUP", "BUSCARV"), + ("VSTACK", "APILARV"), + ("WEBSERVICE", "SERVICIOWEB"), + ("WEEKDAY", "DIASEM"), + ("WEEKNUM", "NUM.DE.SEMANA"), + ("WEIBULL", "DIST.WEIBULL"), + ("WEIBULL.DIST", "DISTR.WEIBULL"), + ("WORKDAY", "DIA.LAB"), + ("WORKDAY.INTL", "DIA.LAB.INTL"), + // reviewed: Microsoft nav list AJUSTARCOLS + ONLYOFFICE; zekke English; page body untranslated MT | sources: OO=https://raw.githubusercontent.com/ONLYOFFICE/web-apps/master/apps/spreadsheeteditor/main/resources/formula-lang/es.json | ZK=https://raw.githubusercontent.com/zekke-io/zekke/main/src/lib/spreadsheets/function-names/es.json + ("WRAPCOLS", "AJUSTARCOLS"), + // reviewed: Microsoft nav list AJUSTARFILAS + ONLYOFFICE; zekke English; page body untranslated MT | sources: OO=https://raw.githubusercontent.com/ONLYOFFICE/web-apps/master/apps/spreadsheeteditor/main/resources/formula-lang/es.json | ZK=https://raw.githubusercontent.com/zekke-io/zekke/main/src/lib/spreadsheets/function-names/es.json + ("WRAPROWS", "AJUSTARFILAS"), + ("XIRR", "TIR.NO.PER"), + ("XLOOKUP", "BUSCARX"), + ("XMATCH", "COINCIDIRX"), + ("XNPV", "VNA.NO.PER"), + ("XOR", "XO"), + ("YEAR", "AÑO"), + ("YEARFRAC", "FRAC.AÑO"), + ("YIELD", "RENDTO"), + ("YIELDDISC", "RENDTO.DESC"), + ("YIELDMAT", "RENDTO.VENCTO"), + ("Z.TEST", "PRUEBA.Z.N"), + ("ZTEST", "PRUEBA.Z"), + ], +}; diff --git a/crates/formula/src/input/pt.rs b/crates/formula/src/input/pt.rs new file mode 100644 index 0000000000..92a8df7977 --- /dev/null +++ b/crates/formula/src/input/pt.rs @@ -0,0 +1,490 @@ +//! Português (Brasil): the formula words Brazilian Portuguese Excel shows (function names, booleans, errors, +//! structured-reference specifiers) and its formula separators. +//! +//! Function names come from Microsoft Support's pt-br Excel function pages (retrieved +//! 2026-10-10; the pages are machine-translated): per page, a vote among the title, the meta +//! description, the syntax paragraph (aligned with the en-US page) and `=NAME(` examples, and an +//! entry needs two agreeing signals. Entries marked `reviewed` were decided by hand from the page +//! evidence and independent function-name references. Functions not listed keep their English name. +//! +//! Sources for the other words: +//! - bool/errors: https://support.microsoft.com/pt-br/excel/functions/error-type-function +//! - errors.#GETTING_DATA: https://support.microsoft.com/pt-br/excel/functions/cubesetcount-function | tudoexcel.com.br +//! - errors.#SPILL!: https://support.microsoft.com/pt-br/excel/how-to-correct-a-spill-error | https://www.guiadoexcel.com.br/erro-despejar-no-excel-o-porque-ocorre-como-corrigir-e-evitar/ | https://ninjadoexcel.com.br/ +//! - errors.#CALC!: https://support.microsoft.com/pt-br/excel/how-to-correct-a-calc-error (page keeps #CALC!) +//! - table_items: https://support.microsoft.com/pt-br/excel/using-structured-references-with-excel-tables | blogs [[#Tudo];...] [#Cabeçalhos] + +use super::Dialect; +use gridcraft_core::CellError; + +pub(super) const DIALECT: Dialect = Dialect { + list: ';', + decimal: ',', + array_column: '\\', + array_row: ';', + bool_true: "VERDADEIRO", + bool_false: "FALSO", + errors: &[ + (CellError::Null, "#NULO!"), + (CellError::Div0, "#DIV/0!"), + (CellError::Value, "#VALOR!"), + (CellError::Ref, "#REF!"), + (CellError::Name, "#NOME?"), + (CellError::Num, "#NÚM!"), + (CellError::NA, "#N/D"), + (CellError::GettingData, "#OBTENDO_DADOS"), + (CellError::Spill, "#DESPEJAR!"), + (CellError::Calc, "#CALC!"), + ], + table_items: ["#Tudo", "#Dados", "#Cabeçalhos", "#Totais", "#Esta Linha"], + functions: &[ + ("ACCRINT", "JUROSACUM"), + ("ACCRINTM", "JUROSACUMV"), + ("ADDRESS", "ENDEREÇO"), + ("AGGREGATE", "AGREGAR"), + ("AND", "E"), + ("ARABIC", "ARÁBICO"), + ("AREAS", "ÁREAS"), + ("ARRAYTOTEXT", "MATRIZPARATEXTO"), + ("ASIN", "ASEN"), + ("ASINH", "ASENH"), + ("AVEDEV", "DESV.MÉDIO"), + ("AVERAGE", "MÉDIA"), + ("AVERAGEA", "MÉDIAA"), + ("AVERAGEIF", "MÉDIASE"), + ("AVERAGEIFS", "MÉDIASES"), + ("BETA.DIST", "DIST.BETA"), + ("BETA.INV", "INV.BETA"), + ("BETADIST", "DISTBETA"), + ("BETAINV", "BETA.ACUM.INV"), + ("BIN2DEC", "BINADEC"), + ("BIN2HEX", "BINAHEX"), + ("BIN2OCT", "BINAOCT"), + ("BINOM.DIST", "DISTR.BINOM"), + ("BINOM.DIST.RANGE", "INTERV.DISTR.BINOM"), + ("BINOM.INV", "INV.BINOM"), + ("BINOMDIST", "DISTRBINOM"), + ("BITLSHIFT", "DESLOCESQBIT"), + ("BITRSHIFT", "DESLOCDIRBIT"), + ("CEILING", "TETO"), + // reviewed: MS title/examples give TETO.MAT; real blog formula uses TETO.MAT + ("CEILING.MATH", "TETO.MAT"), + ("CEILING.PRECISE", "TETO.PRECISO"), + ("CELL", "CÉL"), + ("CHAR", "CARACT"), + ("CHIDIST", "DIST.QUI"), + ("CHIINV", "INV.QUI"), + ("CHISQ.DIST", "DIST.QUIQUA"), + ("CHISQ.DIST.RT", "DIST.QUIQUA.CD"), + ("CHISQ.INV", "INV.QUIQUA"), + ("CHISQ.INV.RT", "INV.QUIQUA.CD"), + ("CHISQ.TEST", "TESTE.QUIQUA"), + ("CHITEST", "TESTE.QUI"), + ("CHOOSE", "ESCOLHER"), + ("CHOOSECOLS", "ESCOLHERCOLS"), + // reviewed: real formulas | sources: https://www.guiadoexcel.com.br/funcao-escolherlins-excel/ + ("CHOOSEROWS", "ESCOLHERLINS"), + ("CLEAN", "TIRAR"), + ("CODE", "CÓDIGO"), + ("COLUMN", "COL"), + ("COLUMNS", "COLS"), + ("COMPLEX", "COMPLEXO"), + ("CONCATENATE", "CONCATENAR"), + ("CONFIDENCE", "INT.CONFIANÇA"), + ("CONFIDENCE.NORM", "INT.CONFIANÇA.NORM"), + ("CONFIDENCE.T", "INT.CONFIANÇA.T"), + ("CONVERT", "CONVERTER"), + ("COUNT", "CONT.NÚM"), + ("COUNTA", "CONT.VALORES"), + // reviewed: MS title; 5 blog pages + ("COUNTBLANK", "CONTAR.VAZIO"), + // reviewed: MS syntax/examples CONT.SE; ~139 blog pages use CONT.SE in formulas + ("COUNTIF", "CONT.SE"), + ("COUNTIFS", "CONT.SES"), + ("COUPDAYBS", "CUPDIASINLIQ"), + ("COUPDAYS", "CUPDIAS"), + ("COUPDAYSNC", "CUPDIASPRÓX"), + ("COUPNCD", "CUPDATAPRÓX"), + ("COUPNUM", "CUPNÚM"), + ("COUPPCD", "CUPDATAANT"), + ("COVARIANCE.P", "COVARIAÇÃO.P"), + ("COVARIANCE.S", "COVARIAÇÃO.S"), + ("CRITBINOM", "CRIT.BINOM"), + ("CSC", "COSEC"), + ("CSCH", "COSECH"), + ("CUBEKPIMEMBER", "MEMBROKPICUBO"), + ("CUBEMEMBER", "MEMBROCUBO"), + ("CUBEMEMBERPROPERTY", "PROPRIEDADEMEMBROCUBO"), + ("CUBERANKEDMEMBER", "MEMBROCLASSIFICADOCUBO"), + ("CUBESET", "CONJUNTOCUBO"), + ("CUBESETCOUNT", "CONTAGEMCONJUNTOCUBO"), + ("CUBEVALUE", "VALORCUBO"), + ("CUMIPMT", "PGTOJURACUM"), + ("CUMPRINC", "PGTOCAPACUM"), + ("DATE", "DATA"), + ("DATEDIF", "DATADIF"), + ("DATEVALUE", "DATA.VALOR"), + ("DAVERAGE", "BDMÉDIA"), + ("DAY", "DIA"), + ("DAYS", "DIAS"), + ("DAYS360", "DIAS360"), + ("DB", "BD"), + ("DCOUNT", "BDCONTAR"), + ("DCOUNTA", "BDCONTARA"), + ("DDB", "BDD"), + ("DEC2BIN", "DECABIN"), + ("DEC2HEX", "DECAHEX"), + ("DEC2OCT", "DECAOCT"), + ("DEGREES", "GRAUS"), + ("DEVSQ", "DESVQ"), + ("DGET", "BDEXTRAIR"), + ("DISC", "DESC"), + ("DMAX", "BDMÁX"), + ("DMIN", "BDMÍN"), + ("DOLLAR", "MOEDA"), + ("DOLLARDE", "MOEDADEC"), + ("DOLLARFR", "MOEDAFRA"), + ("DPRODUCT", "BDMULTIPL"), + // reviewed: blog formulas + ("DROP", "DESCARTAR"), + ("DSTDEV", "BDEST"), + ("DSTDEVP", "BDDESVPA"), + ("DSUM", "BDSOMA"), + ("DURATION", "DURAÇÃO"), + ("DVAR", "BDVAREST"), + ("DVARP", "BDVARP"), + ("EDATE", "DATAM"), + ("EFFECT", "EFETIVA"), + ("ENCODEURL", "CODIFURL"), + ("EOMONTH", "FIMMÊS"), + ("ERF", "FUNERRO"), + ("ERF.PRECISE", "FUNERRO.PRECISO"), + ("ERFC", "FUNERROCOMPL"), + ("ERFC.PRECISE", "FUNERROCOMPL.PRECISO"), + ("ERROR.TYPE", "TIPO.ERRO"), + ("EVEN", "PAR"), + ("EXACT", "EXATO"), + // reviewed: real formulas | sources: https://www.guiadoexcel.com.br/funcao-expandir-excel/ + ("EXPAND", "EXPANDIR"), + ("EXPON.DIST", "DISTR.EXPON"), + ("EXPONDIST", "DISTEXPON"), + ("F.DIST", "DIST.F"), + ("F.DIST.RT", "DIST.F.CD"), + ("F.INV", "INV.F"), + ("F.INV.RT", "INV.F.CD"), + ("F.TEST", "TESTE.F"), + ("FACT", "FATORIAL"), + ("FACTDOUBLE", "FATDUPLO"), + ("FALSE", "FALSO"), + ("FDIST", "DISTF"), + ("FILTER", "FILTRO"), + ("FILTERXML", "FILTROXML"), + // reviewed: blog formulas use PROCURAR (MS vote weak) | sources: https://www.guiadoexcel.com.br/funcoes-de-texto/ + ("FIND", "PROCURAR"), + // reviewed: B-variant of PROCURAR; follows ESQUERDAB/DIREITAB/NÚM.CARACTB/LOCALIZARB pattern; no direct source for the B form + ("FINDB", "PROCURARB"), + ("FINV", "INVF"), + // reviewed: MS syntax/examples; blog | sources: https://www.guiadoexcel.com.br/funcoes-de-texto/ + ("FIXED", "DEF.NÚM.DEC"), + ("FLOOR", "ARREDMULTB"), + ("FLOOR.MATH", "ARREDMULTB.MAT"), + ("FLOOR.PRECISE", "ARREDMULTB.PRECISO"), + ("FORECAST", "PREVISÃO"), + ("FORECAST.LINEAR", "PREVISÃO.LINEAR"), + ("FORMULATEXT", "FÓRMULATEXTO"), + ("FREQUENCY", "FREQÜÊNCIA"), + ("FTEST", "TESTEF"), + ("FV", "VF"), + ("FVSCHEDULE", "VFPLANO"), + ("GAMMA", "GAMA"), + ("GAMMA.DIST", "DIST.GAMA"), + ("GAMMA.INV", "INV.GAMA"), + ("GAMMADIST", "DISTGAMA"), + ("GAMMAINV", "INVGAMA"), + ("GAMMALN", "LNGAMA"), + ("GAMMALN.PRECISE", "LNGAMA.PRECISO"), + ("GCD", "MDC"), + ("GEOMEAN", "MÉDIA.GEOMÉTRICA"), + ("GESTEP", "DEGRAU"), + // reviewed: blog | sources: https://ninjadoexcel.com.br/funcao-agruparpor-no-excel/ + ("GROUPBY", "AGRUPARPOR"), + ("GROWTH", "CRESCIMENTO"), + ("HARMEAN", "MÉDIA.HARMÔNICA"), + ("HEX2BIN", "HEXABIN"), + ("HEX2DEC", "HEXADEC"), + ("HEX2OCT", "HEXAOCT"), + ("HLOOKUP", "PROCH"), + ("HOUR", "HORA"), + ("HSTACK", "EMPILHARH"), + // reviewed: ~12 blog pages use HIPERLINK in formulas + ("HYPERLINK", "HIPERLINK"), + ("HYPGEOM.DIST", "DIST.HIPERGEOM.N"), + ("HYPGEOMDIST", "DIST.HIPERGEOM"), + ("IF", "SE"), + ("IFERROR", "SEERRO"), + ("IFNA", "SENÃODISP"), + ("IFS", "SES"), + ("IMAGINARY", "IMAGINÁRIO"), + ("IMARGUMENT", "IMARG"), + ("IMCONJUGATE", "IMCONJ"), + ("IMCSC", "IMCOSEC"), + ("IMCSCH", "IMCOSECH"), + ("IMPOWER", "IMPOT"), + ("IMPRODUCT", "IMPROD"), + ("IMSIN", "IMSENO"), + ("IMSINH", "IMSENH"), + ("IMSQRT", "IMRAIZ"), + ("IMSUB", "IMSUBTR"), + ("IMSUM", "IMSOMA"), + ("INDEX", "ÍNDICE"), + ("INDIRECT", "INDIRETO"), + ("INFO", "INFORMAÇÃO"), + ("INTERCEPT", "INTERCEPÇÃO"), + ("INTRATE", "TAXAJUROS"), + ("IPMT", "IPGTO"), + ("IRR", "TIR"), + // reviewed: blog formulas ÉCÉL.VAZIA; MS is-functions table + ("ISBLANK", "ÉCÉL.VAZIA"), + // reviewed: MS is-functions table: ÉERRO = "exceto #N/D" (ISERR) | sources: https://support.microsoft.com/pt-br/excel/is-functions + ("ISERR", "ÉERRO"), + // reviewed: blog formulas show ÉERROS = ISERROR; ÉERRO is ISERR + ("ISERROR", "ÉERROS"), + ("ISEVEN", "ÉPAR"), + ("ISFORMULA", "ÉFÓRMULA"), + // reviewed: MS is-functions table and blogs + ("ISLOGICAL", "ÉLÓGICO"), + // reviewed: MS is-functions table and blogs + ("ISNA", "É.NÃO.DISP"), + // reviewed: MS is-functions table and blogs + ("ISNONTEXT", "É.NÃO.TEXTO"), + // reviewed: 34 blog pages use ÉNÚM; MS table ÉNÚMERO is MT noise + ("ISNUMBER", "ÉNÚM"), + ("ISO.CEILING", "ISO.TETO"), + // reviewed: blog formulas; MS iseven page gives ÉPAR for ISEVEN + ("ISODD", "ÉIMPAR"), + ("ISOWEEKNUM", "NÚMSEMANAISO"), + ("ISPMT", "ÉPGTO"), + // reviewed: MS is-functions table + ("ISREF", "ÉREF"), + // reviewed: MS is-functions table and blogs + ("ISTEXT", "ÉTEXTO"), + ("KURT", "CURT"), + ("LARGE", "MAIOR"), + ("LCM", "MMC"), + ("LEFT", "ESQUERDA"), + ("LEFTB", "ESQUERDAB"), + ("LEN", "NÚM.CARACT"), + ("LENB", "NÚM.CARACTB"), + ("LINEST", "PROJ.LIN"), + ("LOGEST", "PROJ.LOG"), + ("LOGINV", "INVLOG"), + ("LOGNORM.DIST", "DIST.LOGNORMAL.N"), + ("LOGNORM.INV", "INV.LOGNORMAL"), + ("LOGNORMDIST", "DIST.LOGNORMAL"), + ("LOOKUP", "PROC"), + ("LOWER", "MINÚSCULA"), + ("MATCH", "CORRESP"), + ("MAX", "MÁXIMO"), + ("MAXA", "MÁXIMOA"), + ("MAXIFS", "MÁXIMOSES"), + ("MDETERM", "MATRIZ.DETERM"), + ("MDURATION", "MDURAÇÃO"), + ("MEDIAN", "MED"), + ("MID", "EXT.TEXTO"), + // reviewed: B-variant of EXT.TEXTO; pattern ESQUERDAB/DIREITAB; no direct source + ("MIDB", "EXT.TEXTOB"), + ("MIN", "MÍNIMO"), + ("MINA", "MÍNIMOA"), + ("MINIFS", "MÍNIMOSES"), + ("MINUTE", "MINUTO"), + ("MINVERSE", "MATRIZ.INVERSO"), + ("MIRR", "MTIR"), + ("MMULT", "MATRIZ.MULT"), + ("MODE", "MODO"), + ("MODE.MULT", "MODO.MULT"), + ("MODE.SNGL", "MODO.ÚNICO"), + ("MONTH", "MÊS"), + ("MROUND", "MARRED"), + ("NA", "NÃO.DISP"), + ("NEGBINOM.DIST", "DIST.BIN.NEG.N"), + ("NEGBINOMDIST", "DIST.BIN.NEG"), + ("NETWORKDAYS", "DIATRABALHOTOTAL"), + ("NETWORKDAYS.INTL", "DIATRABALHOTOTAL.INTL"), + ("NORM.DIST", "DIST.NORM.N"), + ("NORM.INV", "INV.NORM.N"), + ("NORM.S.DIST", "DIST.NORMP.N"), + ("NORM.S.INV", "INV.NORMP.N"), + ("NORMDIST", "DISTNORM"), + ("NORMINV", "INV.NORM"), + ("NORMSDIST", "DISTNORMP"), + ("NORMSINV", "INV.NORMP"), + ("NOT", "NÃO"), + ("NOW", "AGORA"), + ("NPV", "VPL"), + ("NUMBERVALUE", "VALORNUMÉRICO"), + ("OCT2BIN", "OCTABIN"), + ("OCT2DEC", "OCTADEC"), + ("OCT2HEX", "OCTAHEX"), + ("ODD", "ÍMPAR"), + ("OFFSET", "DESLOC"), + ("OR", "OU"), + ("PDURATION", "DURAÇÃOP"), + ("PERCENTILE", "PERCENTIL"), + // reviewed: vote wrongly gave EXC; MS title/syntax + ("PERCENTILE.EXC", "PERCENTIL.EXC"), + ("PERCENTILE.INC", "PERCENTIL.INC"), + ("PERCENTRANK", "ORDEM.PORCENTUAL"), + ("PERCENTRANK.EXC", "ORDEM.PORCENTUAL.EXC"), + ("PERCENTRANK.INC", "ORDEM.PORCENTUAL.INC"), + ("PERMUTATIONA", "PERMUTAS"), + ("PHONETIC", "FONÉTICA"), + // reviewed: blog | sources: https://ninjadoexcel.com.br/novidade-funcao-pivotar-do-excel/ + ("PIVOTBY", "PIVOTAR"), + ("PMT", "PGTO"), + ("POISSON.DIST", "DIST.POISSON"), + ("POWER", "POTÊNCIA"), + ("PPMT", "PPGTO"), + ("PRICE", "PREÇO"), + ("PRICEDISC", "PREÇODESC"), + ("PRICEMAT", "PREÇOVENC"), + ("PRODUCT", "MULT"), + ("PROPER", "PRI.MAIÚSCULA"), + ("PV", "VP"), + ("QUARTILE", "QUARTIL"), + ("QUARTILE.EXC", "QUARTIL.EXC"), + ("QUARTILE.INC", "QUARTIL.INC"), + ("QUOTIENT", "QUOCIENTE"), + ("RADIANS", "RADIANOS"), + ("RAND", "ALEATÓRIO"), + ("RANDARRAY", "MATRIZALEATÓRIA"), + ("RANDBETWEEN", "ALEATÓRIOENTRE"), + ("RANK", "ORDEM"), + ("RANK.AVG", "ORDEM.MÉD"), + ("RANK.EQ", "ORDEM.EQ"), + ("RATE", "TAXA"), + ("RECEIVED", "RECEBER"), + // reviewed: real formula in blog | sources: https://www.guiadoexcel.com.br/como-usar-procx-e-regex-excel/ + ("REGEXREPLACE", "REGEX.SUBSTITUIR"), + // reviewed: real formula in blog | sources: https://www.guiadoexcel.com.br/como-usar-procx-e-regex-excel/ + ("REGEXTEST", "REGEX.TESTAR"), + ("REPLACE", "MUDAR"), + // reviewed: B-variant of MUDAR; pattern ESQUERDAB/DIREITAB; no direct source + ("REPLACEB", "MUDARB"), + ("RIGHT", "DIREITA"), + ("RIGHTB", "DIREITAB"), + ("ROMAN", "ROMANO"), + ("ROUND", "ARRED"), + ("ROUNDDOWN", "ARREDONDAR.PARA.BAIXO"), + ("ROUNDUP", "ARREDONDAR.PARA.CIMA"), + ("ROW", "LIN"), + ("ROWS", "LINS"), + ("RRI", "TAXAJURO"), + ("RSQ", "RQUAD"), + ("SEARCH", "LOCALIZAR"), + ("SEARCHB", "LOCALIZARB"), + ("SECOND", "SEGUNDO"), + ("SEQUENCE", "SEQUÊNCIA"), + ("SERIESSUM", "SOMASEQÜÊNCIA"), + ("SHEET", "PLAN"), + ("SHEETS", "PLANS"), + ("SIGN", "SINAL"), + ("SIN", "SEN"), + ("SINH", "SENH"), + ("SKEW", "DISTORÇÃO"), + ("SKEW.P", "DISTORÇÃO.P"), + ("SLN", "DPD"), + ("SLOPE", "INCLINAÇÃO"), + ("SMALL", "MENOR"), + ("SORT", "CLASSIFICAR"), + ("SORTBY", "CLASSIFICARPOR"), + ("SQRT", "RAIZ"), + ("SQRTPI", "RAIZPI"), + ("STANDARDIZE", "PADRONIZAR"), + ("STDEV", "DESVPAD"), + ("STDEV.P", "DESVPAD.P"), + ("STDEV.S", "DESVPAD.A"), + ("STDEVA", "DESVPADA"), + ("STDEVP", "DESVPADP"), + ("STDEVPA", "DESVPADPA"), + ("STEYX", "EPADYX"), + ("SUBSTITUTE", "SUBSTITUIR"), + ("SUM", "SOMA"), + ("SUMIF", "SOMASE"), + ("SUMIFS", "SOMASES"), + ("SUMPRODUCT", "SOMARPRODUTO"), + ("SUMSQ", "SOMAQUAD"), + ("SUMX2MY2", "SOMAX2DY2"), + ("SUMX2PY2", "SOMAX2SY2"), + ("SUMXMY2", "SOMAXMY2"), + ("SWITCH", "PARÂMETRO"), + ("SYD", "SDA"), + ("T.DIST", "DIST.T"), + ("T.DIST.2T", "DIST.T.BC"), + ("T.DIST.RT", "DIST.T.CD"), + ("T.INV", "INV.T"), + ("T.INV.2T", "INV.T.BC"), + ("T.TEST", "TESTE.T"), + // reviewed: real formulas | sources: https://www.guiadoexcel.com.br/funcao-pegar-excel/ + ("TAKE", "PEGAR"), + ("TBILLEQ", "OTN"), + ("TBILLPRICE", "OTNVALOR"), + ("TBILLYIELD", "OTNLUCRO"), + ("TDIST", "DISTT"), + ("TEXT", "TEXTO"), + // reviewed: 23 blog pages + ("TEXTAFTER", "TEXTODEPOIS"), + // reviewed: 19 blog pages + ("TEXTBEFORE", "TEXTOANTES"), + ("TEXTJOIN", "UNIRTEXTO"), + // reviewed: MS syntax/examples; 23 blog pages; older TEXTODIVISÃO kept as alias | sources: https://ninjadoexcel.com.br/como-dividir-texto-no-excel-funcao-dividirtexto/ + ("TEXTSPLIT", "DIVIDIRTEXTO"), + ("TIME", "TEMPO"), + ("TIMEVALUE", "VALOR.TEMPO"), + ("TINV", "INVT"), + // reviewed: real formulas | sources: https://www.hashtagtreinamentos.com/funcoes-paracol-e-paralin-excel + ("TOCOL", "PARACOL"), + ("TODAY", "HOJE"), + // reviewed: real formulas | sources: https://www.hashtagtreinamentos.com/funcoes-paracol-e-paralin-excel + ("TOROW", "PARALIN"), + ("TRANSPOSE", "TRANSPOR"), + // reviewed: blog formulas + ("TREND", "TENDÊNCIA"), + ("TRIM", "ARRUMAR"), + ("TRIMMEAN", "MÉDIA.INTERNA"), + ("TRUE", "VERDADEIRO"), + ("TRUNC", "TRUNCAR"), + ("TTEST", "TESTET"), + ("TYPE", "TIPO"), + ("UNICHAR", "CARACTUNICODE"), + ("UNIQUE", "ÚNICO"), + ("UPPER", "MAIÚSCULA"), + ("VALUE", "VALOR"), + ("VALUETOTEXT", "VALORPARATEXTO"), + // reviewed: MS title/examples only (no blog source) + ("VAR", "VAREST"), + ("VAR.S", "VAR.A"), + ("VDB", "BDV"), + ("VLOOKUP", "PROCV"), + ("VSTACK", "EMPILHARV"), + ("WEBSERVICE", "SERVIÇOWEB"), + ("WEEKDAY", "DIA.DA.SEMANA"), + ("WEEKNUM", "NÚMSEMANA"), + ("WEIBULL.DIST", "DIST.WEIBULL"), + ("WORKDAY", "DIATRABALHO"), + ("WORKDAY.INTL", "DIATRABALHO.INTL"), + ("WRAPCOLS", "QUEBRARCOLS"), + // reviewed: blog; sibling WRAPCOLS=QUEBRARCOLS + ("WRAPROWS", "QUEBRARLINS"), + ("XIRR", "XTIR"), + ("XLOOKUP", "PROCX"), + ("XMATCH", "CORRESPX"), + ("XNPV", "XVPL"), + ("YEAR", "ANO"), + ("YEARFRAC", "FRAÇÃOANO"), + ("YIELD", "LUCRO"), + ("YIELDDISC", "LUCRODESC"), + ("YIELDMAT", "LUCROVENC"), + ("Z.TEST", "TESTE.Z"), + ("ZTEST", "TESTEZ"), + ], +}; diff --git a/crates/formula/src/input/ru.rs b/crates/formula/src/input/ru.rs new file mode 100644 index 0000000000..6773413207 --- /dev/null +++ b/crates/formula/src/input/ru.rs @@ -0,0 +1,870 @@ +//! Русский (Россия): the formula words Russian Excel shows (function names, booleans, errors, +//! structured-reference specifiers) and its formula separators. +//! +//! Function names come from Microsoft Support's ru-ru Excel function pages (retrieved +//! 2026-10-10; the pages are machine-translated): per page, a vote among the title, the meta +//! description, the syntax paragraph (aligned with the en-US page) and `=NAME(` examples, and an +//! entry needs two agreeing signals. Entries marked `reviewed` were decided by hand from the page +//! evidence and independent function-name references. Functions not listed keep their English name. +//! +//! Unless an entry says otherwise, `reviewed` means: At least two independent function translation references agree; conflicting machine-translated spellings rejected. +//! +//! Sources for the other words: +//! - bool: https://support.microsoft.com/ru-ru/excel/functions/address-function (a1 argument: ИСТИНА / ЛОЖЬ) +//! - errors: https://support.microsoft.com/ru-ru/excel/functions/error-type-function +//! - errors.#SPILL!: https://support.microsoft.com/ru-ru/excel/how-to-correct-a-spill-error +//! - table_items: https://support.microsoft.com/ru-ru/excel/using-structured-references-with-excel-tables +//! - errors.#CALC!: https://www.planetaexcel.ru/techniques/25/9457/ | https://coddy.tech/docs/ru/excel/filter (empty FILTER result -> #ВЫЧИСЛ!, English #CALC!) +//! - errors.#REF!: https://excel-academy.ru/faq/kak-ispravit-oshibki-ssyilka-i-imya-v-excel/ | https://coddy.tech/docs/ru/excel/ref-error | https://support.microsoft.com/ru-ru/excel/functions/sum-function (ERROR.TYPE table truncates spelling to #ССЫЛ!, corrected using independent formula examples) + +use super::Dialect; +use gridcraft_core::CellError; + +pub(super) const DIALECT: Dialect = Dialect { + list: ';', + decimal: ',', + array_column: ';', + array_row: ':', + bool_true: "ИСТИНА", + bool_false: "ЛОЖЬ", + errors: &[ + (CellError::Null, "#ПУСТО!"), + (CellError::Div0, "#ДЕЛ/0!"), + (CellError::Value, "#ЗНАЧ!"), + (CellError::Ref, "#ССЫЛКА!"), + (CellError::Name, "#ИМЯ?"), + (CellError::Num, "#ЧИСЛО!"), + (CellError::NA, "#Н/Д"), + (CellError::GettingData, "#ОЖИДАНИЕ_ДАННЫХ"), + (CellError::Spill, "#ПЕРЕНОС!"), + (CellError::Calc, "#ВЫЧИСЛ!"), + ], + table_items: ["#Все", "#Данные", "#Заголовки", "#Итого", "#Эта строка"], + functions: &[ + ("ACCRINT", "НАКОПДОХОД"), + ("ACCRINTM", "НАКОПДОХОДПОГАШ"), + ("ADDRESS", "АДРЕС"), + ("AGGREGATE", "АГРЕГАТ"), + ("AMORDEGRC", "АМОРУМ"), + ("AMORLINC", "АМОРУВ"), + ("AND", "И"), + ("ARABIC", "АРАБСКОЕ"), + ("AREAS", "ОБЛАСТИ"), + ("ARRAYTOTEXT", "МАССИВВТЕКСТ"), + ("AVEDEV", "СРОТКЛ"), + ("AVERAGE", "СРЗНАЧ"), + ("AVERAGEA", "СРЗНАЧА"), + ("AVERAGEIF", "СРЗНАЧЕСЛИ"), + ("AVERAGEIFS", "СРЗНАЧЕСЛИМН"), + ("BASE", "ОСНОВАНИЕ"), + ("BESSELI", "БЕССЕЛЬ.I"), + ("BESSELJ", "БЕССЕЛЬ.J"), + ("BESSELK", "БЕССЕЛЬ.K"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ + ("BESSELY", "БЕССЕЛЬ.Y"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ + ("BETA.DIST", "БЕТА.РАСП"), + ("BETA.INV", "БЕТА.ОБР"), + ("BETADIST", "БЕТАРАСП"), + ("BETAINV", "БЕТАОБР"), + ("BIN2DEC", "ДВ.В.ДЕС"), + ("BIN2HEX", "ДВ.В.ШЕСТН"), + ("BIN2OCT", "ДВ.В.ВОСЬМ"), + ("BINOM.DIST", "БИНОМ.РАСП"), + ("BINOM.DIST.RANGE", "БИНОМ.РАСП.ДИАП"), + ("BINOM.INV", "БИНОМ.ОБР"), + ("BINOMDIST", "БИНОМРАСП"), + ("BITAND", "БИТ.И"), + ("BITLSHIFT", "БИТ.СДВИГЛ"), + ("BITOR", "БИТ.ИЛИ"), + ("BITRSHIFT", "БИТ.СДВИГП"), + ("BITXOR", "БИТ.ИСКЛИЛИ"), + ("CEILING", "ОКРВВЕРХ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("CEILING.MATH", "ОКРВВЕРХ.МАТ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("CEILING.PRECISE", "ОКРВВЕРХ.ТОЧН"), + ("CELL", "ЯЧЕЙКА"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("CHAR", "СИМВОЛ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ + ("CHIDIST", "ХИ2РАСП"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ + ("CHIINV", "ХИ2ОБР"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ + ("CHISQ.DIST", "ХИ2.РАСП"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ + ("CHISQ.DIST.RT", "ХИ2.РАСП.ПХ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ + ("CHISQ.INV", "ХИ2.ОБР"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ + ("CHISQ.INV.RT", "ХИ2.ОБР.ПХ"), + ("CHISQ.TEST", "ХИ2.ТЕСТ"), + ("CHITEST", "ХИ2ТЕСТ"), + ("CHOOSE", "ВЫБОР"), + ("CHOOSECOLS", "ВЫБОРСТОЛБЦ"), + ("CLEAN", "ПЕЧСИМВ"), + ("CODE", "КОДСИМВ"), + ("COLUMN", "СТОЛБЕЦ"), + ("COLUMNS", "ЧИСЛСТОЛБ"), + ("COMBIN", "ЧИСЛКОМБ"), + ("COMBINA", "ЧИСЛКОМБА"), + ("COMPLEX", "КОМПЛЕКСН"), + // reviewed: Title and metadata agree; syntax/examples incorrectly copied from CONCATENATE; common function table corroborates СЦЕП | sources: https://support.microsoft.com/ru-ru/excel/functions/concat-function | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ + ("CONCAT", "СЦЕП"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("CONCATENATE", "СЦЕПИТЬ"), + ("CONFIDENCE", "ДОВЕРИТ"), + ("CONFIDENCE.NORM", "ДОВЕРИТ.НОРМ"), + ("CONFIDENCE.T", "ДОВЕРИТ.СТЬЮДЕНТ"), + ("CONVERT", "ПРЕОБР"), + ("CORREL", "КОРРЕЛ"), + ("COUNT", "СЧЁТ"), + ("COUNTA", "СЧЁТЗ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("COUNTBLANK", "СЧИТАТЬПУСТОТЫ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("COUNTIF", "СЧЁТЕСЛИ"), + ("COUNTIFS", "СЧЁТЕСЛИМН"), + ("COUPDAYBS", "ДНЕЙКУПОНДО"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("COUPDAYS", "ДНЕЙКУПОН"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("COUPDAYSNC", "ДНЕЙКУПОНПОСЛЕ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("COUPNCD", "ДАТАКУПОНПОСЛЕ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("COUPNUM", "ЧИСЛКУПОН"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("COUPPCD", "ДАТАКУПОНДО"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("COVAR", "КОВАР"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("COVARIANCE.P", "КОВАРИАЦИЯ.Г"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("COVARIANCE.S", "КОВАРИАЦИЯ.В"), + ("CRITBINOM", "КРИТБИНОМ"), + ("CUBEKPIMEMBER", "КУБЭЛЕМЕНТКИП"), + ("CUBEMEMBER", "КУБЭЛЕМЕНТ"), + // reviewed: Microsoft title, metadata and examples agree with Functiontranslator; syntax incorrectly says КУБПОРЭЛЕМЕНТ (CUBERANKEDMEMBER) | sources: https://support.microsoft.com/ru-ru/excel/functions/cubememberproperty-function | https://functiontranslator.com/ru/ + ("CUBEMEMBERPROPERTY", "КУБСВОЙСТВОЭЛЕМЕНТА"), + ("CUBERANKEDMEMBER", "КУБПОРЭЛЕМЕНТ"), + ("CUBESET", "КУБМНОЖ"), + ("CUBESETCOUNT", "КУБЧИСЛОЭЛМНОЖ"), + ("CUBEVALUE", "КУБЗНАЧЕНИЕ"), + ("CUMIPMT", "ОБЩПЛАТ"), + ("CUMPRINC", "ОБЩДОХОД"), + ("DATE", "ДАТА"), + ("DATEDIF", "РАЗНДАТ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("DATEVALUE", "ДАТАЗНАЧ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("DAVERAGE", "ДСРЗНАЧ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("DAY", "ДЕНЬ"), + // reviewed | sources: https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("DAYS", "ДНИ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("DAYS360", "ДНЕЙ360"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("DB", "ФУО"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("DCOUNT", "БСЧЁТ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("DCOUNTA", "БСЧЁТА"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("DDB", "ДДОБ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("DEC2BIN", "ДЕС.В.ДВ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("DEC2HEX", "ДЕС.В.ШЕСТН"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("DEC2OCT", "ДЕС.В.ВОСЬМ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("DECIMAL", "ДЕС"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("DEGREES", "ГРАДУСЫ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("DELTA", "ДЕЛЬТА"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("DEVSQ", "КВАДРОТКЛ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("DGET", "БИЗВЛЕЧЬ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("DISC", "СКИДКА"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("DMAX", "ДМАКС"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("DMIN", "ДМИН"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("DOLLAR", "РУБЛЬ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("DOLLARDE", "РУБЛЬ.ДЕС"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("DOLLARFR", "РУБЛЬ.ДРОБЬ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("DPRODUCT", "БДПРОИЗВЕД"), + // reviewed: Microsoft title and meta give the Russian name; the syntax and examples were left untranslated (English name) | sources: https://support.microsoft.com/ru-ru/excel/functions/drop-function + ("DROP", "СБРОСИТЬ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("DSTDEV", "ДСТАНДОТКЛ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("DSTDEVP", "ДСТАНДОТКЛП"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("DSUM", "БДСУММ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("DURATION", "ДЛИТ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("DVAR", "БДДИСП"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("DVARP", "БДДИСПП"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("EDATE", "ДАТАМЕС"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("EFFECT", "ЭФФЕКТ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("ENCODEURL", "КОДИР.URL"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("EOMONTH", "КОНМЕСЯЦА"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ + ("ERF", "ФОШ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ + ("ERF.PRECISE", "ФОШ.ТОЧН"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ + ("ERFC", "ДФОШ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ + ("ERFC.PRECISE", "ДФОШ.ТОЧН"), + ("ERROR.TYPE", "ТИП.ОШИБКИ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("EVEN", "ЧЁТН"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("EXACT", "СОВПАД"), + ("EXPAND", "РАЗВЕРНУТЬ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("EXPON.DIST", "ЭКСП.РАСП"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("EXPONDIST", "ЭКСПРАСП"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ + ("F.DIST", "F.РАСП"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ + ("F.DIST.RT", "F.РАСП.ПХ"), + ("F.INV", "F.ОБР"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ + ("F.INV.RT", "F.ОБР.ПХ"), + ("F.TEST", "F.ТЕСТ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("FACT", "ФАКТР"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("FACTDOUBLE", "ДВФАКТР"), + ("FALSE", "ЛОЖЬ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ + ("FDIST", "FРАСП"), + // reviewed: Microsoft calc-error page and independent dynamic-array article show literal ФИЛЬТР formulas | sources: https://support.microsoft.com/ru-ru/excel/how-to-correct-a-calc-error | https://www.planetaexcel.ru/techniques/25/9457/ + ("FILTER", "ФИЛЬТР"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("FILTERXML", "ФИЛЬТР.XML"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("FIND", "НАЙТИ"), + // reviewed: Two independent function tables agree (the Microsoft page could not be fetched) | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ + ("FINDB", "НАЙТИБ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ + ("FINV", "FРАСПОБР"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ + ("FISHER", "ФИШЕР"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ + ("FISHERINV", "ФИШЕРОБР"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("FIXED", "ФИКСИРОВАННЫЙ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("FLOOR", "ОКРВНИЗ"), + // reviewed | sources: https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("FLOOR.MATH", "ОКРВНИЗ.МАТ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("FLOOR.PRECISE", "ОКРВНИЗ.ТОЧН"), + ("FORECAST", "ПРЕДСКАЗ"), + // reviewed | sources: https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("FORECAST.LINEAR", "ПРЕДСКАЗ.ЛИНЕЙН"), + // reviewed | sources: https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("FORMULATEXT", "Ф.ТЕКСТ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("FREQUENCY", "ЧАСТОТА"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ + ("FTEST", "ФТЕСТ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("FV", "БС"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("FVSCHEDULE", "БЗРАСПИС"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("GAMMA", "ГАММА"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("GAMMA.DIST", "ГАММА.РАСП"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("GAMMA.INV", "ГАММА.ОБР"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("GAMMADIST", "ГАММАРАСП"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("GAMMAINV", "ГАММАОБР"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("GAMMALN", "ГАММАНЛОГ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("GAMMALN.PRECISE", "ГАММАНЛОГ.ТОЧН"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ + ("GAUSS", "ГАУСС"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("GCD", "НОД"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("GEOMEAN", "СРГЕОМ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ + ("GESTEP", "ПОРОГ"), + ("GROUPBY", "ГРУПППО"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("GROWTH", "РОСТ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("HARMEAN", "СРГАРМ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("HEX2BIN", "ШЕСТН.В.ДВ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("HEX2DEC", "ШЕСТН.В.ДЕС"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("HEX2OCT", "ШЕСТН.В.ВОСЬМ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("HLOOKUP", "ГПР"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("HOUR", "ЧАС"), + ("HSTACK", "ГСТОЛБИК"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("HYPERLINK", "ГИПЕРССЫЛКА"), + ("HYPGEOM.DIST", "ГИПЕРГЕОМ.РАСП"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ + ("HYPGEOMDIST", "ГИПЕРГЕОМЕТ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("IF", "ЕСЛИ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("IFERROR", "ЕСЛИОШИБКА"), + // reviewed: Microsoft title, meta and syntax agree, with Excel Academy and the excelworld title; two dictionaries and the excelworld syntax line spell ЕСЛИНД (kept as an alias) | sources: https://support.microsoft.com/ru-ru/excel/functions/ifna-function | https://excel-academy.ru/tools/excel-functions/ | http://www.excelworld.ru/publ/funcs/boolean/ifna/11-1-0-118 + ("IFNA", "ЕСНД"), + // reviewed: Microsoft meta and examples, a worked formula on mister-office and a function table agree; the page title УСЛОВИЯ is a machine translation (kept as an alias) | sources: https://support.microsoft.com/ru-ru/excel/functions/ifs-function (meta, examples) | https://mister-office.com/funktsii-excel/ifs-eslimn.html (=E8*ЕСЛИМН(...)) | https://functiontranslator.com/ru/ + ("IFS", "ЕСЛИМН"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("IMABS", "МНИМ.ABS"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("IMAGINARY", "МНИМ.ЧАСТЬ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("IMARGUMENT", "МНИМ.АРГУМЕНТ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("IMCONJUGATE", "МНИМ.СОПРЯЖ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("IMCOS", "МНИМ.COS"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("IMCOSH", "МНИМ.COSH"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("IMCOT", "МНИМ.COT"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("IMCSC", "МНИМ.CSC"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("IMCSCH", "МНИМ.CSCH"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("IMDIV", "МНИМ.ДЕЛ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("IMEXP", "МНИМ.EXP"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("IMLN", "МНИМ.LN"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("IMLOG10", "МНИМ.LOG10"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("IMLOG2", "МНИМ.LOG2"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("IMPOWER", "МНИМ.СТЕПЕНЬ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("IMPRODUCT", "МНИМ.ПРОИЗВЕД"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("IMREAL", "МНИМ.ВЕЩ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("IMSEC", "МНИМ.SEC"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("IMSECH", "МНИМ.SECH"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("IMSIN", "МНИМ.SIN"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("IMSINH", "МНИМ.SINH"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("IMSQRT", "МНИМ.КОРЕНЬ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("IMSUB", "МНИМ.РАЗН"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("IMSUM", "МНИМ.СУММ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("IMTAN", "МНИМ.TAN"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("INDEX", "ИНДЕКС"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("INDIRECT", "ДВССЫЛ"), + ("INFO", "ИНФОРМ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("INT", "ЦЕЛОЕ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("INTERCEPT", "ОТРЕЗОК"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("INTRATE", "ИНОРМА"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("IPMT", "ПРПЛТ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("IRR", "ВСД"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("ISBLANK", "ЕПУСТО"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("ISERR", "ЕОШ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("ISERROR", "ЕОШИБКА"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("ISEVEN", "ЕЧЁТН"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("ISFORMULA", "ЕФОРМУЛА"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("ISLOGICAL", "ЕЛОГИЧ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("ISNA", "ЕНД"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("ISNONTEXT", "ЕНЕТЕКСТ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("ISNUMBER", "ЕЧИСЛО"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ + ("ISO.CEILING", "ISO.ОКРВВЕРХ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("ISODD", "ЕНЕЧЁТ"), + // reviewed | sources: https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("ISOWEEKNUM", "НОМНЕДЕЛИ.ISO"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("ISPMT", "ПРОЦПЛАТ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("ISREF", "ЕССЫЛКА"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("ISTEXT", "ЕТЕКСТ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ + ("KURT", "ЭКСЦЕСС"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("LARGE", "НАИБОЛЬШИЙ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("LCM", "НОК"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("LEFT", "ЛЕВСИМВ"), + // reviewed: Microsoft page names it ("Функция ЛЕВБ является нерекомендуемой"); two function tables agree | sources: https://support.microsoft.com/ru-ru/excel/functions/left-function | https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ + ("LEFTB", "ЛЕВБ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("LEN", "ДЛСТР"), + // reviewed: Microsoft page names it ("Функция ДЛИНБ является нерекомендуемой"); two function tables agree | sources: https://support.microsoft.com/ru-ru/excel/functions/len-function | https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ + ("LENB", "ДЛИНБ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("LINEST", "ЛИНЕЙН"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ + ("LOGEST", "ЛГРФПРИБЛ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ + ("LOGINV", "ЛОГНОРМОБР"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ + ("LOGNORM.DIST", "ЛОГНОРМ.РАСП"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ + ("LOGNORM.INV", "ЛОГНОРМ.ОБР"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ + ("LOGNORMDIST", "ЛОГНОРМРАСП"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("LOOKUP", "ПРОСМОТР"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("LOWER", "СТРОЧН"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("MATCH", "ПОИСКПОЗ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("MAX", "МАКС"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("MAXA", "МАКСА"), + // reviewed | sources: https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("MAXIFS", "МАКСЕСЛИ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("MDETERM", "МОПРЕД"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("MDURATION", "МДЛИТ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("MEDIAN", "МЕДИАНА"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("MID", "ПСТР"), + // reviewed: Microsoft page names it ("Функция ПСТРБ является нерекомендуемой"); two function tables agree | sources: https://support.microsoft.com/ru-ru/excel/functions/mid-function | https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ + ("MIDB", "ПСТРБ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("MIN", "МИН"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("MINA", "МИНА"), + // reviewed | sources: https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("MINIFS", "МИНЕСЛИ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("MINUTE", "МИНУТЫ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("MINVERSE", "МОБР"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("MIRR", "МВСД"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("MMULT", "МУМНОЖ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("MOD", "ОСТАТ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("MODE", "МОДА"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("MODE.MULT", "МОДА.НСК"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("MODE.SNGL", "МОДА.ОДН"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("MONTH", "МЕСЯЦ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("MROUND", "ОКРУГЛТ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("MULTINOMIAL", "МУЛЬТИНОМ"), + // reviewed | sources: https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("MUNIT", "МЕДИН"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("N", "Ч"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("NA", "НД"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ + ("NEGBINOM.DIST", "ОТРБИНОМ.РАСП"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ + ("NEGBINOMDIST", "ОТРБИНОМРАСП"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("NETWORKDAYS", "ЧИСТРАБДНИ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("NETWORKDAYS.INTL", "ЧИСТРАБДНИ.МЕЖД"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("NOMINAL", "НОМИНАЛ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("NORM.DIST", "НОРМ.РАСП"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("NORM.INV", "НОРМ.ОБР"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("NORM.S.DIST", "НОРМ.СТ.РАСП"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("NORM.S.INV", "НОРМ.СТ.ОБР"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ + ("NORMDIST", "НОРМРАСП"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ + ("NORMINV", "НОРМОБР"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("NORMSDIST", "НОРМСТРАСП"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("NORMSINV", "НОРМСТОБР"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("NOT", "НЕ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("NOW", "ТДАТА"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("NPER", "КПЕР"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("NPV", "ЧПС"), + // reviewed | sources: https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("NUMBERVALUE", "ЧЗНАЧ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("OCT2BIN", "ВОСЬМ.В.ДВ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("OCT2DEC", "ВОСЬМ.В.ДЕС"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("OCT2HEX", "ВОСЬМ.В.ШЕСТН"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("ODD", "НЕЧЁТ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("OFFSET", "СМЕЩ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("OR", "ИЛИ"), + // reviewed | sources: https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("PDURATION", "ПДЛИТ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("PERCENTILE", "ПЕРСЕНТИЛЬ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("PERCENTILE.EXC", "ПРОЦЕНТИЛЬ.ИСКЛ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("PERCENTILE.INC", "ПРОЦЕНТИЛЬ.ВКЛ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("PERCENTRANK", "ПРОЦЕНТРАНГ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("PERCENTRANK.EXC", "ПРОЦЕНТРАНГ.ИСКЛ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("PERCENTRANK.INC", "ПРОЦЕНТРАНГ.ВКЛ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("PERMUT", "ПЕРЕСТ"), + // reviewed | sources: https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("PERMUTATIONA", "ПЕРЕСТА"), + // reviewed | sources: https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("PHI", "ФИ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("PI", "ПИ"), + ("PIVOTBY", "СВОДПО"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("PMT", "ПЛТ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ + ("POISSON", "ПУАССОН"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ + ("POISSON.DIST", "ПУАССОН.РАСП"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("POWER", "СТЕПЕНЬ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("PPMT", "ОСПЛТ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("PRICE", "ЦЕНА"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("PRICEDISC", "ЦЕНАСКИДКА"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("PRICEMAT", "ЦЕНАПОГАШ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("PROB", "ВЕРОЯТНОСТЬ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("PRODUCT", "ПРОИЗВЕД"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("PROPER", "ПРОПНАЧ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("PV", "ПС"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("QUARTILE", "КВАРТИЛЬ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("QUARTILE.EXC", "КВАРТИЛЬ.ИСКЛ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("QUARTILE.INC", "КВАРТИЛЬ.ВКЛ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("QUOTIENT", "ЧАСТНОЕ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("RADIANS", "РАДИАНЫ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("RAND", "СЛЧИС"), + ("RANDARRAY", "СЛУЧМАССИВ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("RANDBETWEEN", "СЛУЧМЕЖДУ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("RANK", "РАНГ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("RANK.AVG", "РАНГ.СР"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("RANK.EQ", "РАНГ.РВ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("RATE", "СТАВКА"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("RECEIVED", "ПОЛУЧЕНО"), + ("REGEXEXTRACT", "РЕГИЗВЛЕЧЬ"), + ("REGEXREPLACE", "РЕГЗАМЕНИТЬ"), + ("REGEXTEST", "РЕГТЕСТ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("REPLACE", "ЗАМЕНИТЬ"), + // reviewed: Microsoft page names it ("Функция ЗАМЕНИТЬБ является нерекомендуемой"); two function tables agree | sources: https://support.microsoft.com/ru-ru/excel/functions/replace-function | https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ + ("REPLACEB", "ЗАМЕНИТЬБ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("REPT", "ПОВТОР"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("RIGHT", "ПРАВСИМВ"), + // reviewed: Microsoft page names it ("Функция ПРАВБ является нерекомендуемой"); two function tables agree | sources: https://support.microsoft.com/ru-ru/excel/functions/right-function | https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ + ("RIGHTB", "ПРАВБ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("ROMAN", "РИМСКОЕ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("ROUND", "ОКРУГЛ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("ROUNDDOWN", "ОКРУГЛВНИЗ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("ROUNDUP", "ОКРУГЛВВЕРХ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("ROW", "СТРОКА"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("ROWS", "ЧСТРОК"), + // reviewed | sources: https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("RRI", "ЭКВ.СТАВКА"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ + ("RSQ", "КВПИРСОН"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("SEARCH", "ПОИСК"), + // reviewed: Microsoft page names it ("Функция ПОИСКБ является нерекомендуемой"); two function tables agree | sources: https://support.microsoft.com/ru-ru/excel/functions/search-function | https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ + ("SEARCHB", "ПОИСКБ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("SECOND", "СЕКУНДЫ"), + ("SEQUENCE", "ПОСЛЕДОВ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ + ("SERIESSUM", "РЯД.СУММ"), + // reviewed | sources: https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("SHEET", "ЛИСТ"), + // reviewed | sources: https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("SHEETS", "ЛИСТЫ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("SIGN", "ЗНАК"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("SKEW", "СКОС"), + // reviewed | sources: https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("SKEW.P", "СКОС.Г"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("SLN", "АПЛ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("SLOPE", "НАКЛОН"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("SMALL", "НАИМЕНЬШИЙ"), + // reviewed: Independent article explicitly pairs English and Russian names and shows function syntax | sources: https://www.planetaexcel.ru/techniques/25/9457/ + ("SORT", "СОРТ"), + ("SORTBY", "СОРТПО"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("SQRT", "КОРЕНЬ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("SQRTPI", "КОРЕНЬПИ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("STANDARDIZE", "НОРМАЛИЗАЦИЯ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("STDEV", "СТАНДОТКЛОН"), + // reviewed | sources: https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("STDEV.P", "СТАНДОТКЛОН.Г"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("STDEV.S", "СТАНДОТКЛОН.В"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("STDEVA", "СТАНДОТКЛОНА"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("STDEVP", "СТАНДОТКЛОНП"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("STDEVPA", "СТАНДОТКЛОНПА"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ + ("STEYX", "СТОШYX"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("SUBSTITUTE", "ПОДСТАВИТЬ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("SUBTOTAL", "ПРОМЕЖУТОЧНЫЕ.ИТОГИ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("SUM", "СУММ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("SUMIF", "СУММЕСЛИ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("SUMIFS", "СУММЕСЛИМН"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("SUMPRODUCT", "СУММПРОИЗВ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("SUMSQ", "СУММКВ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("SUMX2MY2", "СУММРАЗНКВ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("SUMX2PY2", "СУММСУММКВ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("SUMXMY2", "СУММКВРАЗН"), + // reviewed | sources: https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("SWITCH", "ПЕРЕКЛЮЧ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("SYD", "АСЧ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("T", "Т"), + // reviewed: Independent detailed syntax and Microsoft syntax distinguish T.DIST from obsolete TDIST; both broad translation tables incorrectly conflate the pair | sources: https://www.formulafind.com/ru/функции/статистические/student-rasp-function | https://support.microsoft.com/ru-ru/excel/functions/t-dist-function + ("T.DIST", "СТЬЮДЕНТ.РАСП"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ + ("T.DIST.2T", "СТЬЮДЕНТ.РАСП.2Х"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ + ("T.DIST.RT", "СТЬЮДЕНТ.РАСП.ПХ"), + ("T.INV", "СТЬЮДЕНТ.ОБР"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ + ("T.INV.2T", "СТЬЮДЕНТ.ОБР.2Х"), + ("T.TEST", "СТЬЮДЕНТ.ТЕСТ"), + // reviewed: Microsoft title and meta give the Russian name; the syntax and examples were left untranslated (English name) | sources: https://support.microsoft.com/ru-ru/excel/functions/take-function + ("TAKE", "ВЗЯТЬ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("TBILLEQ", "РАВНОКЧЕК"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("TBILLPRICE", "ЦЕНАКЧЕК"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("TBILLYIELD", "ДОХОДКЧЕК"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ + ("TDIST", "СТЬЮДРАСП"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("TEXT", "ТЕКСТ"), + ("TEXTAFTER", "ТЕКСТПОСЛЕ"), + ("TEXTBEFORE", "ТЕКСТДО"), + // reviewed | sources: https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("TEXTJOIN", "ОБЪЕДИНИТЬ"), + // reviewed: Microsoft syntax and examples give the Russian name; the title and meta were left untranslated (English name) | sources: https://support.microsoft.com/ru-ru/excel/functions/textsplit-function + ("TEXTSPLIT", "ТЕКСТРАЗД"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("TIME", "ВРЕМЯ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("TIMEVALUE", "ВРЕМЗНАЧ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ + ("TINV", "СТЬЮДРАСПОБР"), + ("TOCOL", "ПОСТОЛБЦ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("TODAY", "СЕГОДНЯ"), + ("TOROW", "ПОСТРОК"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("TRANSPOSE", "ТРАНСП"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ + ("TREND", "ТЕНДЕНЦИЯ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("TRIM", "СЖПРОБЕЛЫ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("TRIMMEAN", "УРЕЗСРЕДНЕЕ"), + ("TRUE", "ИСТИНА"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("TRUNC", "ОТБР"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ + ("TTEST", "ТТЕСТ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("TYPE", "ТИП"), + // reviewed | sources: https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("UNICHAR", "ЮНИСИМВ"), + // reviewed: Independent article explicitly pairs English and Russian names and shows function syntax | sources: https://www.planetaexcel.ru/techniques/25/9457/ + ("UNIQUE", "УНИК"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("UPPER", "ПРОПИСН"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("VALUE", "ЗНАЧЕН"), + // reviewed: Microsoft title, meta, syntax and examples all agree; the function tables only list the English name | sources: https://support.microsoft.com/ru-ru/excel/functions/valuetotext-function + ("VALUETOTEXT", "ЗНАЧЕНИЕВТЕКСТ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("VAR", "ДИСП"), + // reviewed | sources: https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("VAR.P", "ДИСП.Г"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("VAR.S", "ДИСП.В"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("VARA", "ДИСПА"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("VARP", "ДИСПР"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("VARPA", "ДИСПРА"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("VDB", "ПУО"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("VLOOKUP", "ВПР"), + ("VSTACK", "ВСТОЛБИК"), + // reviewed | sources: https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("WEBSERVICE", "ВЕБСЛУЖБА"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("WEEKDAY", "ДЕНЬНЕД"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("WEEKNUM", "НОМНЕДЕЛИ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("WEIBULL", "ВЕЙБУЛЛ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("WEIBULL.DIST", "ВЕЙБУЛЛ.РАСП"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("WORKDAY", "РАБДЕНЬ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("WORKDAY.INTL", "РАБДЕНЬ.МЕЖД"), + // reviewed: Microsoft syntax and examples give the Russian name; the title and meta were left untranslated (English name) | sources: https://support.microsoft.com/ru-ru/excel/functions/wrapcols-function + ("WRAPCOLS", "СВЕРНСТОЛБЦ"), + // reviewed: Microsoft syntax and examples give the Russian name; the title and meta were left untranslated (English name) | sources: https://support.microsoft.com/ru-ru/excel/functions/wraprows-function + ("WRAPROWS", "СВЕРНСТРОК"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("XIRR", "ЧИСТВНДОХ"), + ("XLOOKUP", "ПРОСМОТРX"), + // reviewed: Microsoft title, meta, syntax and examples all agree (Latin X, like ПРОСМОТРX); the function tables only list the English name | sources: https://support.microsoft.com/ru-ru/excel/functions/xmatch-function + ("XMATCH", "ПОИСКПОЗX"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("XNPV", "ЧИСТНЗ"), + // reviewed | sources: https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("XOR", "ИСКЛИЛИ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://easy-excel.com/excel-in-other-languages/excel-functions-in-russian/ | https://excel-academy.ru/tools/excel-functions/ + ("YEAR", "ГОД"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("YEARFRAC", "ДОЛЯГОДА"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("YIELD", "ДОХОД"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("YIELDDISC", "ДОХОДСКИДКА"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ | https://excel-academy.ru/tools/excel-functions/ + ("YIELDMAT", "ДОХОДПОГАШ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ + ("Z.TEST", "Z.ТЕСТ"), + // reviewed | sources: https://www.perfectxl.com/academy/functions/translations/english-russian/ | https://functiontranslator.com/ru/ + ("ZTEST", "ZТЕСТ"), + ], +}; diff --git a/crates/formula/src/lexer.rs b/crates/formula/src/lexer.rs index 4c41d3609d..f11f089463 100644 --- a/crates/formula/src/lexer.rs +++ b/crates/formula/src/lexer.rs @@ -160,12 +160,17 @@ pub fn tokenize_locale(src: &str, locale: crate::FormulaLocale) -> Result= t.len() && rest.get(..t.len()).is_some_and(|p| p.eq_ignore_ascii_case(t)) { - found = Some(e); + found = Some((e, t.chars().count())); break; } } - let Some(e) = found else { return Err(LexError { msg: "unknown error literal".into(), pos: start }) }; - let len = e.as_str().chars().count(); + // The language's own spelling (`#NV`, `#Н/Д`); the longer match wins. + if let Some((e, len)) = locale.error_prefix(rest) + && found.is_none_or(|(_, english)| len > english) + { + found = Some((e, len)); + } + let Some((e, len)) = found else { return Err(LexError { msg: "unknown error literal".into(), pos: start }) }; // `#REF!A1` style is not valid; `#REF!` is an error literal. push(&mut out, Tok::Error(e), i + len); i += len; @@ -192,7 +197,8 @@ pub fn tokenize_locale(src: &str, locale: crate::FormulaLocale) -> Result j += 1, } } - let inner = src.get(pos_of(i + 1)..pos_of(j.saturating_sub(1))).unwrap_or("").to_string(); + let inner = src.get(pos_of(i + 1)..pos_of(j.saturating_sub(1))).unwrap_or(""); + let inner = if locale == crate::FormulaLocale::En { inner.to_string() } else { locale.struct_to_canonical(inner) }; push(&mut out, Tok::Struct(inner), j); i = j; } @@ -213,8 +219,11 @@ pub fn tokenize_locale(src: &str, locale: crate::FormulaLocale) -> Result 0 && locale.array_row_separator() == ':'; + let is_row_ref = !rows_by_colon && { let mut k = j; if ch(k) == Some('.') && ch(k + 1) == Some(':') { k += 1; @@ -290,7 +299,10 @@ pub fn tokenize_locale(src: &str, locale: crate::FormulaLocale) -> Result { - if ch(i + 1) == Some('.') && starts_range_end(ch(i + 2)) { + if array_depth > 0 && locale.array_row_separator() == ':' { + push(&mut out, Tok::Semicolon, i + 1); + i += 1; + } else if ch(i + 1) == Some('.') && starts_range_end(ch(i + 2)) { push(&mut out, Tok::Trim(TrimMode::Trailing), i + 2); i += 2; } else { diff --git a/crates/formula/src/parser.rs b/crates/formula/src/parser.rs index 3ea97085e6..459af1dc8e 100644 --- a/crates/formula/src/parser.rs +++ b/crates/formula/src/parser.rs @@ -27,7 +27,7 @@ pub(crate) fn parse_syntax(src: &str, locale: crate::FormulaLocale) -> Result Ok(e), @@ -67,7 +67,7 @@ struct Parser { toks: Vec, i: usize, depth: usize, - input: bool, + locale: crate::FormulaLocale, } fn word_is_cols(w: &str) -> Option<(u32, bool)> { @@ -384,7 +384,9 @@ 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) => self + .locale + .boolean(&w) .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/localized_input.rs b/crates/formula/tests/localized_input.rs new file mode 100644 index 0000000000..002a90755c --- /dev/null +++ b/crates/formula/tests/localized_input.rs @@ -0,0 +1,138 @@ +//! German, Brazilian Portuguese, Russian and the full Spanish table: entry and display syntax. +use gridcraft_core::CellError; +use gridcraft_formula::FormulaLocale::{self, De, Es, PtBr, Ru}; +use gridcraft_formula::{parse, parse_input, print, print_input}; + +const LOCALIZED: [FormulaLocale; 4] = [Es, De, PtBr, Ru]; + +fn canonical(src: &str, locale: FormulaLocale) -> String { + print(&parse_input(src, locale, |_| false).unwrap_or_else(|e| panic!("{locale:?} `{src}`: {e}"))) +} + +fn shown(canonical: &str, locale: FormulaLocale) -> String { + print_input(&parse(canonical).unwrap(), locale, |_| false).unwrap() +} + +#[test] +fn each_language_spells_formulas_like_its_excel() { + for (locale, local, english) in [ + (De, "SUMME(A1;2,5)", "SUM(A1,2.5)"), + (De, "WENN(WAHR;SVERWEIS(A2;D:E;2;FALSCH);#NV)", "IF(TRUE,VLOOKUP(A2,D:E,2,FALSE),#N/A)"), + (De, "SUMME({1.2;3,5.4})", "SUM({1,2;3.5,4})"), + (De, "ZÄHLENWENN(Tabelle1[[#Kopfzeilen];[Spalte1]];\">1,5\")", "COUNTIF(Tabelle1[[#Headers],[Spalte1]],\">1,5\")"), + (De, "WENNFEHLER(1/0;#WERT!)", "IFERROR(1/0,#VALUE!)"), + (PtBr, "SE(VERDADEIRO;SOMA(1,5;2);#N/D)", "IF(TRUE,SUM(1.5,2),#N/A)"), + (PtBr, "PROCV(A2;Tabela1[#Tudo];2;FALSO)", "VLOOKUP(A2,Tabela1[#All],2,FALSE)"), + (PtBr, "SOMA({1\\2;3\\4})", "SUM({1,2;3,4})"), + (Ru, "СУММ({1;2:3;4})", "SUM({1,2;3,4})"), + (Ru, "СУММ(Таблица1[[#Итого];[Сумма]])", "SUM(Таблица1[[#Totals],[Сумма]])"), + (Es, "CONTAR.SI.CONJUNTO(A1:A3;\">1\")", "COUNTIFS(A1:A3,\">1\")"), + (Es, "SI.ERROR(1/0;#¡VALOR!)", "IFERROR(1/0,#VALUE!)"), + ] { + assert_eq!(canonical(local, locale), english, "{locale:?} entry"); + assert_eq!(shown(english, locale), local, "{locale:?} display"); + } +} + +#[test] +fn every_language_round_trips_canonical_formulas() { + for formula in [ + "SUM(1.5,-2.25,A1:B2 B2:C3)", + r#"IF(AND(TRUE,NOT(FALSE)),"a,b;c",#DIV/0!)"#, + r#"{1.5,TRUE;#N/A,"x;y"}"#, + "SUM((A1,B1),Sheet2!A:A)", + "Table1[[#This Row],[a]]+SUM(Table1[#Totals])+Table1[[#Headers],[a]:[b]]", + "LET(x,1,LAMBDA(y,x+y)(2))", + "XLOOKUP(1,A:A,B:B,#N/A)+IFNA(#GETTING_DATA,#SPILL!)", + "SUM(1:3)+ROWS($2:$2)", + "CEILING.MATH(2.5)+T.TEST(A1:A3,B1:B3,2,1)", + "BYROW(A1:B2,SUM)+GROUPBY(A1:A3,B1:B3,AVERAGE)", + ] { + let expected = print(&parse(formula).unwrap()); + for locale in FormulaLocale::ALL { + let local = shown(formula, locale); + assert_eq!(canonical(&local, locale), expected, "{locale:?}: {formula} shown as {local}"); + } + } +} + +#[test] +fn english_names_and_any_letter_case_are_accepted() { + assert_eq!(canonical("SUM(1;2)+SUMME(3)", De), "SUM(1,2)+SUM(3)"); + assert_eq!(canonical("zählenwenn(A1:A3;1)", De), "COUNTIF(A1:A3,1)"); + assert_eq!(canonical("сумм(1;2)+#н/д", Ru), "SUM(1,2)+#N/A"); + assert_eq!(canonical("{истина:ложь}", Ru), "{TRUE;FALSE}"); + // A local name that is another function's English name means the local function, as in + // Spanish Excel: DECIMAL is FIXED there, and the base conversion is CONV.DECIMAL. + assert_eq!(canonical("DECIMAL(1,5;1)", Es), "FIXED(1.5,1)"); + assert_eq!(canonical("CONV.DECIMAL(\"FF\";16)", Es), "DECIMAL(\"FF\",16)"); + // English separators are not the language's syntax. + assert!(parse_input("SUMME(1.5;2)", De, |_| false).is_err()); + assert!(parse_input("SUM(1,2)", Ru, |_| false).is_ok_and(|e| print(&e) == "SUM(1.2)")); +} + +#[test] +fn names_shadow_local_function_names_in_any_script() { + assert_eq!(canonical("LET(zählenwenn;LAMBDA(x;x*2);ZÄHLENWENN(3))", De), "LET(zählenwenn,LAMBDA(x,x*2),ZÄHLENWENN(3))"); + let known = |name: &str| name == "СУММ"; + assert_eq!(print(&parse_input("СУММ(1)", Ru, known).unwrap()), "СУММ(1)"); + assert_eq!(print_input(&parse("SUM(1)").unwrap(), Ru, known).unwrap(), "SUM(1)"); +} + +#[test] +fn functions_passed_by_name_are_spelled_in_the_language() { + // GROUPBY(…,SUM) and BYROW(…,SUM) take a worksheet function by name, saved as `_xleta.SUM`. + assert_eq!(canonical("NACHZEILE(A1:B3;SUMME)", De), "BYROW(A1:B3,SUM)"); + assert_eq!(canonical("AGRUPARPOR(A2:A9;B2:B9;soma)", PtBr), "GROUPBY(A2:A9,B2:B9,SUM)"); + assert_eq!(shown("_xlfn.GROUPBY(A2:A9,B2:B9,_xleta.SUM)", PtBr), "AGRUPARPOR(A2:A9;B2:B9;SOMA)"); + // A defined name with the local spelling is still the name. + let known = |name: &str| name == "SUMME"; + assert_eq!(print(&parse_input("NACHZEILE(A1:B3;SUMME)", De, known).unwrap()), "BYROW(A1:B3,SUMME)"); + assert_eq!(print_input(&parse("BYROW(A1:B3,SUM)").unwrap(), De, known).unwrap(), "NACHZEILE(A1:B3;SUM)"); +} + +#[test] +fn language_tables_are_sorted_unique_and_unambiguous() { + for locale in LOCALIZED { + let table = locale.translations(); + assert!(table.len() > 400, "{locale:?}: {} names", table.len()); + assert!(table.windows(2).all(|w| w[0].0 < w[1].0), "{locale:?}: not sorted by canonical name"); + let mut locals: Vec = table.iter().map(|(_, local)| local.to_uppercase()).collect(); + locals.sort(); + let all = locals.len(); + locals.dedup(); + assert_eq!(locals.len(), all, "{locale:?}: two functions share a local name"); + for (en, local) in table { + assert_ne!(en, local, "{locale:?}: {en} is listed though it keeps its English name"); + assert_eq!(locale.function_name(en), *local); + assert_eq!(locale.canonical_name(local), *en); + // The TRUE() and FALSE() functions are spelled like the booleans; nothing else may be. + let upper = local.to_uppercase(); + let boolean = upper == locale.boolean_name(true) || upper == locale.boolean_name(false); + assert_eq!(boolean, matches!(*en, "TRUE" | "FALSE"), "{locale:?}: {en} = {local}"); + } + for e in CellError::ALL { + assert_eq!(canonical(locale.error_name(e), locale), e.as_str(), "{locale:?}"); + } + } +} + +#[test] +fn malformed_localized_input_is_an_error() { + for (locale, bad) in [ + (De, "SUMME({1.}"), + (De, "Tabelle1[[#Kopfzeilen];"), + (De, "#WERT"), + (Ru, "{ИСТИНА:"), + (Ru, "СУММ(1:"), + (PtBr, "SOMA(1,"), + (PtBr, "{SOMA(1)}"), + (Es, "#¡VALOR"), + ] { + assert!(parse_input(bad, locale, |_| false).is_err(), "{locale:?}: {bad}"); + } + for locale in LOCALIZED { + let chain = format!("{}+", locale.boolean_name(true)).repeat(300) + "1"; + assert!(parse_input(&chain, locale, |_| false).is_err(), "{locale:?}"); + } +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index fb899c7026..d16a9a6bfe 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1753,16 +1753,21 @@ fn insert_function(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confir }); let locale = app.ui.language.formula_locale(); let list = gridcraft_engine::cmd::formulas::function_list(); - let q = d.search.to_ascii_lowercase(); - let hits: Vec<&Json> = list + // Unicode lower case: local names and searches are not ASCII (`zählenwenn`, `сумм`). + let q = d.search.to_lowercase(); + let mut hits: Vec<&Json> = list .iter() .filter(|f| { q.is_empty() - || f["name"].as_str().is_some_and(|n| locale.function_name(n).to_ascii_lowercase().contains(&q)) - || f["description"].as_str().is_some_and(|n| n.to_ascii_lowercase().contains(&q)) + || f["name"].as_str().is_some_and(|n| locale.function_name(n).to_lowercase().contains(&q)) + || f["description"].as_str().is_some_and(|n| n.to_lowercase().contains(&q)) }) - .take(300) .collect(); + if locale != gridcraft_engine::formula::FormulaLocale::En { + // The list is sorted by English name; list it by the name shown. + hits.sort_by_cached_key(|&f| locale.function_name(f["name"].as_str().unwrap_or(""))); + } + hits.truncate(300); egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| { for (i, f) in hits.iter().enumerate() { let name = locale.function_name(f["name"].as_str().unwrap_or("")); diff --git a/crates/ui-egui/src/editor.rs b/crates/ui-egui/src/editor.rs index 4aada29533..1b57a484a1 100644 --- a/crates/ui-egui/src/editor.rs +++ b/crates/ui-egui/src/editor.rs @@ -114,14 +114,18 @@ impl EditState { return; } 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(); + let word: String = before.chars().rev().take_while(|c| is_name_char(*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(); + // Unicode upper case: local function names (`ZÄHLENWENN`, `СУММ`) are not ASCII. + let u = word.to_uppercase(); // 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(); + // Sorted by the name shown, since a local name sits where its English name sorts. + let mut hits: Vec<&String> = names.iter().filter(|n| n.starts_with(&u)).collect(); + hits.sort_unstable(); + hits.dedup(); + self.autocomplete = hits.into_iter().take(12).cloned().collect(); if self.autocomplete.len() == 1 && self.autocomplete[0] == u { self.autocomplete.clear(); } @@ -133,7 +137,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| is_name_char(*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}"); @@ -170,18 +174,11 @@ impl EditState { '{' | '[' => depth -= 1, '(' => { if depth == 0 { - let name: String = chars[..i] - .iter() - .rev() - .take_while(|c| c.is_ascii_alphanumeric() || **c == '.' || **c == '_') - .collect::() - .chars() - .rev() - .collect(); + let name: String = chars[..i].iter().rev().take_while(|c| is_name_char(**c)).collect::().chars().rev().collect(); if name.is_empty() { return None; } - return Some((name.to_ascii_uppercase(), arg)); + return Some((name.to_uppercase(), arg)); } depth -= 1; } @@ -193,6 +190,11 @@ impl EditState { } } +/// A character of a function name as typed: letters of any script, digits, `.` and `_`. +fn is_name_char(c: char) -> bool { + c.is_alphanumeric() || c == '.' || c == '_' +} + /// References in formula text with their byte spans and colour index (same reference text ⇒ /// same colour), for colouring the editor and outlining ranges on the grid. pub fn formula_refs(text: &str) -> Vec<(usize, usize, RangeRef, Option, usize)> { @@ -321,6 +323,20 @@ mod tests { assert_eq!(e.current_function(), Some(("SUM".into(), 1))); } + #[test] + fn autocomplete_and_hints_take_local_names_in_any_script() { + // Local names arrive in English order (ZÄHLENWENNS is COUNTIFS); the list shows them sorted. + let names: Vec = ["ZÄHLENWENNS", "ZÄHLENWENN", "ZEIT", "СУММ"].iter().map(|s| s.to_string()).collect(); + let mut e = EditState::new(0, CellRef::new(0, 0), "=zähl".into(), true, false); + e.update_autocomplete(&names); + assert_eq!(e.autocomplete, ["ZÄHLENWENN", "ZÄHLENWENNS"]); + assert!(e.accept_autocomplete()); + assert_eq!(e.text, "=ZÄHLENWENN("); + let mut e = EditState::new(0, CellRef::new(0, 0), "=ЕСЛИ(A1>0;сумм(B1;".into(), true, false); + e.locale = gridcraft_engine::formula::FormulaLocale::Ru; + assert_eq!(e.current_function(), Some(("СУММ".into(), 1))); + } + #[test] fn ref_spans() { let r = formula_refs("=A1+B2:C3*a1+Sheet2!D4"); diff --git a/crates/ui-egui/src/formula_locale.rs b/crates/ui-egui/src/formula_locale.rs index 6e833b95db..0490451a71 100644 --- a/crates/ui-egui/src/formula_locale.rs +++ b/crates/ui-egui/src/formula_locale.rs @@ -86,8 +86,8 @@ fn hide_udf_prefix(text: &str, locale: FormulaLocale) -> String { out } -// Descriptions/signatures for the supported Spanish function table; untranslated functions -// retain their English help. Parameter punctuation follows the same locale as entry. +// Descriptions/signatures for common Spanish functions; other functions keep their English help +// under their local name. Parameter punctuation follows the same locale as entry. const SPANISH_HELP: &[(&str, &str, &str)] = &[ ("SUM", "SUMA(número1; [número2]; ...)", "Suma los números."), ("AVERAGE", "PROMEDIO(número1; [número2]; ...)", "Devuelve el promedio de los números."), @@ -108,7 +108,11 @@ pub(crate) fn signature(name: &str, locale: FormulaLocale) -> Option { { return Some((*signature).into()); } - crate::formula_bar::function_signature(canonical).map(|s| s.replace(',', &locale.list_separator().to_string())) + let signature = crate::formula_bar::function_signature(canonical)?.replace(',', &locale.list_separator().to_string()); + Some(match signature.strip_prefix(canonical) { + Some(params) if locale != FormulaLocale::En => format!("{}{params}", locale.function_name(canonical)), + _ => signature, + }) } pub(crate) fn description(name: &str, locale: FormulaLocale) -> Option { diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 061698df9b..69e8703857 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -108,8 +108,18 @@ impl Language { } } + /// The formula language this interface language enters and shows. Japanese, Chinese and + /// Korean Excel use English function names; Czech, French and Serbian have no function table + /// yet. pub fn formula_locale(self) -> gridcraft_engine::formula::FormulaLocale { - if self == Self::Es { gridcraft_engine::formula::FormulaLocale::Es } else { gridcraft_engine::formula::FormulaLocale::En } + use gridcraft_engine::formula::FormulaLocale; + match self { + Self::Es => FormulaLocale::Es, + Self::De => FormulaLocale::De, + Self::PtBr => FormulaLocale::PtBr, + Self::Ru => FormulaLocale::Ru, + Self::En | Self::Zh | Self::Ja | Self::Ko | Self::Cs | Self::Fr | Self::Sr => FormulaLocale::En, + } } /// Translates an interface label; untranslated labels fall back to English. diff --git a/crates/ui-egui/tests/formula_locale.rs b/crates/ui-egui/tests/formula_locale.rs index 855869377c..16ffb837fc 100644 --- a/crates/ui-egui/tests/formula_locale.rs +++ b/crates/ui-egui/tests/formula_locale.rs @@ -172,20 +172,40 @@ fn canonical_engine_templates_enter_the_selected_formula_language() { } #[test] -fn portuguese_interface_retains_its_canonical_formula_fallback() { +fn portuguese_interface_enters_and_shows_portuguese_formulas() { let mut app = app(); app.run("app.language.set", json!({"code":"pt-BR"})).unwrap(); assert_eq!(app.ui.language, Language::PtBr); assert_eq!(app.ui.language.tr("Data"), "Dados"); - assert!(enter(&mut app, "=SUM(1.5,2.25)")); + assert!(enter(&mut app, "=SOMA(1,5;2,25)")); + assert_eq!(formula(&app), "SUM(1.5,2.25)"); app.begin_edit(None, true); - assert_eq!(app.editor.as_ref().unwrap().text, "=SUM(1.5,2.25)"); - assert!(!enter(&mut app, "=SUMA(1;2)")); + assert_eq!(app.editor.as_ref().unwrap().text, "=SOMA(1,5;2,25)"); + app.cancel_edit(); + // English separators are not Portuguese syntax. + assert!(!enter(&mut app, "=SUM(1.5,2.25)")); let restored: UiState = serde_json::from_str(&serde_json::to_string(&app.ui).unwrap()).unwrap(); assert_eq!(restored.language, Language::PtBr); assert_eq!(Language::Es.tr("Page Layout|Orientation"), "Orientación"); } +#[test] +fn german_and_russian_interfaces_enter_and_show_their_formulas() { + for (code, local, canonical, value) in [ + ("de", "=WENN(WAHR;SUMME({1.2;3,5.4});#NV)", "IF(TRUE,SUM({1,2;3.5,4}),#N/A)", 10.5), + ("ru", "=ЕСЛИ(ИСТИНА;СУММ({1;2:3;4});#Н/Д)", "IF(TRUE,SUM({1,2;3,4}),#N/A)", 10.0), + ] { + let mut app = app(); + app.run("app.language.set", json!({"code": code})).unwrap(); + assert!(enter(&mut app, local), "{code}"); + assert_eq!(formula(&app), canonical, "{code}"); + assert_eq!(app.session.active().unwrap().wb.active().unwrap().value(CellRef::new(0, 0)), Value::Number(value), "{code}"); + app.begin_edit(None, true); + assert_eq!(app.editor.as_ref().unwrap().text, local, "{code}"); + app.cancel_edit(); + } +} + #[test] fn czech_interface_retains_its_canonical_formula_fallback() { // The Czech table translates interface labels only: formulas keep canonical English names, diff --git a/docs/decisions.md b/docs/decisions.md index 7b0a99a384..c15b4a67f0 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -3,13 +3,16 @@ ## Formula localization uses the existing interface preference PR #98 builds on the persisted `UiState.language` introduced by #31 and extended by #104. -Español selects the Spanish function table and list/decimal separators; there is no competing -locale setting. Formula syntax without a supplied translation table falls back to English. -The editor translates to canonical English before engine commands, and translates back only -for display. API, clipboard and XLSX syntax remain stable. Existing names override aliases. - -The revision is based directly on main after #31/#104 and Portuguese interface support (#82) -merged. Those locales retain their existing formula fallback; this change adds only Spanish. +Español, Deutsch, Português (Brasil) and Русский select their function table and separators; +there is no competing locale setting. Formula syntax without a translation table (English, +Japanese, Chinese, Korean, and for now Czech, French and Serbian) stays English. The editor +translates to canonical English before engine commands, and translates back only for display. +API, clipboard and XLSX syntax remain stable. Existing names override aliases. + +Each formula language is data (`crates/formula/src/input/.rs`: names, booleans, +errors, table specifiers, separators) behind one `Dialect` table, so a language is added by a +data file and one `FormulaLocale` variant, without new parsing code. Every entry carries its +source; Microsoft's localized pages are machine-translated, so names need two agreeing signals. ## Preserve array evaluation when exporting formulas (#64) diff --git a/docs/formula-input.md b/docs/formula-input.md index e07132c8d9..6e60a151b8 100644 --- a/docs/formula-input.md +++ b/docs/formula-input.md @@ -1,34 +1,52 @@ # Formula language -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. +View → Interface language is the existing, persisted language preference, and it also picks the +formula language, as in Excel: function names follow the display language. On first run the +preference follows the system language. -| Spanish | Canonical English | +| Interface language | Formula language | | --- | --- | -| `SUMA` | `SUM` | -| `PROMEDIO` | `AVERAGE` | -| `SI` | `IF` | -| `CONTAR` | `COUNT` | -| `SUMAR.SI` | `SUMIF` | -| `CONTAR.SI` | `COUNTIF` | -| `BUSCARV` | `VLOOKUP` | -| `HOY` | `TODAY` | -| `VERDADERO`, `FALSO` | `TRUE`, `FALSE` (literals or function calls) | - -Names are case-insensitive. Spanish formulas use decimal commas (`1,5`), semicolon arguments -and reference unions, and backslashes between array columns (`{1\2;3\4}`). For example, -`=SI(VERDADERO;SUMA(1,5;2,25);0)` evaluates to 3.75. English mode retains comma arguments, -decimal points and comma array columns; it does not silently translate Spanish aliases. - -The cell editor, formula bar, Show Formulas, autocomplete, the Formula ribbon, Insert Function, and argument hints -use the selected formula language. Common Spanish function help is translated; functions -without a Spanish entry retain their English names/help. General interface labels and errors -fall back to English where Spanish translations are not provided. An edit captures its language -when it starts so switching the preference cannot reinterpret unfinished input. +| Español | Spanish | +| Deutsch | German | +| Português (Brasil) | Brazilian Portuguese | +| Русский | Russian | +| English, 日本語, 中文, 한국어 | English (Japanese, Chinese and Korean Excel use English function names) | +| Čeština, Français | English for now: these languages have no function table yet | + +## What changes per language + +| | English | Spanish | German | Portuguese (Brazil) | Russian | +| --- | --- | --- | --- | --- | --- | +| Example | `SUM`, `IF`, `VLOOKUP` | `SUMA`, `SI`, `BUSCARV` | `SUMME`, `WENN`, `SVERWEIS` | `SOMA`, `SE`, `PROCV` | `СУММ`, `ЕСЛИ`, `ВПР` | +| Function names translated | — | 424 | 403 | 412 | 459 | +| Booleans | `TRUE`, `FALSE` | `VERDADERO`, `FALSO` | `WAHR`, `FALSCH` | `VERDADEIRO`, `FALSO` | `ИСТИНА`, `ЛОЖЬ` | +| Errors (e.g.) | `#VALUE!`, `#N/A` | `#¡VALOR!`, `#N/D` | `#WERT!`, `#NV` | `#VALOR!`, `#N/D` | `#ЗНАЧ!`, `#Н/Д` | +| Arguments and unions | `,` | `;` | `;` | `;` | `;` | +| Decimal separator | `.` | `,` | `,` | `,` | `,` | +| Array columns / rows | `,` / `;` | `\` / `;` | `.` / `;` | `\` / `;` | `;` / `:` | +| Table specifiers (e.g.) | `#Headers`, `#This Row` | `#Encabezados`, `#Esta fila` | `#Kopfzeilen`, `#Diese Zeile` | `#Cabeçalhos`, `#Esta Linha` | `#Заголовки`, `#Эта строка` | + +For example, `=SI(VERDADERO;SUMA(1,5;2,25);0)` (Spanish), `=WENN(WAHR;SUMME({1.2;3,5.4});#NV)` +(German) and `=ЕСЛИ(ИСТИНА;СУММ({1;2:3;4});#Н/Д)` (Russian) are stored as +`IF(TRUE,SUM(1.5,2.25),0)`, `IF(TRUE,SUM({1,2;3.5,4}),#N/A)` and `IF(TRUE,SUM({1,2;3,4}),#N/A)`. + +Names, booleans and errors are case-insensitive in any script (`zählenwenn`, `сумм`). Functions +without a local name keep their English name, and canonical English names, booleans and errors +are also accepted with the language's separators. English mode retains comma arguments, decimal +points and comma array columns; it does not silently translate local names. A function passed by +name (`BYROW(A1:C3;SOMA)`, `GROUPBY(…;SUMME)`) uses the same local name as its call. + +## Where it applies + +The cell editor, formula bar, Show Formulas, autocomplete, the Formula ribbon, Insert Function +(searched and sorted by the local name), and argument hints use the selected formula language. +Common Spanish function help is translated; other help stays English, shown under the local +function name with the language's separators. General interface labels and errors fall back to +English where translations are not provided. An edit captures its language when it starts so +switching the preference cannot reinterpret unfinished input. Workbook/sheet names, table names, LET bindings and LAMBDA parameters take precedence over -aliases. Text, sheet names and table column names are unchanged. When a localized spelling +local names. Text, sheet names and table column names are unchanged. When a localized spelling would collide with a name, displayed formulas retain the canonical spelling to preserve meaning. Stored formulas, XLSX import/export, engine commands, control/API calls, CLI evaluation and @@ -36,3 +54,13 @@ 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. + +## Where the names come from + +Each table lives in `crates/formula/src/input/.rs` with its sources. Function names +come from Microsoft Support's localized Excel function pages, which are machine-translated and +sometimes wrong, so an entry needs two agreeing signals on its page (title, description, syntax +paragraph aligned with the English page, examples); entries marked `reviewed` were decided by +hand against independent function-name references. Booleans, errors and table specifiers cite +their sources per item. The tables have not yet been checked against a running localized Excel +or by native speakers; corrections are one-line edits with a source. diff --git a/docs/function-parity.md b/docs/function-parity.md index 760ffd6ac3..76b00688d1 100644 --- a/docs/function-parity.md +++ b/docs/function-parity.md @@ -1,6 +1,6 @@ # Function parity: worksheet functions and formulas -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (USDOLLAR added as a compatibility function; MIRR, COUNTIF and INDIRECT edge cases match Excel 365) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** minor (formulas in es, de, pt-BR and ru) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Excel's worksheet functions, category by category, against GridCraft's function library. Part of [target-app-parity.md](target-app-parity.md); gaps feed [gaps.md](gaps.md). @@ -82,7 +82,7 @@ PIVOTBY, REGEXTEST…) are all present. | Iterative calculation for circular references | done: with Enable Iterative Calculation on (`formulas.calculationOptions` `{iterative, maxIterations, maxChange}`, saved as ``), loops are evaluated again from their previous values (blank = 0) until no cell on a loop changes by Maximum Change or Maximum Iterations is reached; off, cycles give `#CIRC!`. Differences: no Options dialog yet (command only); GridCraft recalculates on open, so a loop that never settles (`=A1+1`) advances again each time the file is opened | | 15-significant-digit comparison semantics | **missing** (#57: `=0.1+0.2=0.3` is FALSE) | | External workbook references (`[Book.xlsx]Sheet1!A1`) | parsed; links dropped on open, no update | -| Localized function names and `;` separators | **missing** (#45); en-US only | +| Localized function names and `;` separators | partial (#45): Spanish, German, Brazilian Portuguese and Russian at the editor (names, booleans, errors, table specifiers, separators); files stay English ([formula-input.md](formula-input.md)); French, Czech and Serbian formulas stay English | | Error values | the seven classic errors plus `#SPILL!`, `#CALC!`, `#GETTING_DATA` done; `#FIELD!`, `#BLOCKED!`, `#CONNECT!`, `#BUSY!`, `#UNKNOWN!`, `#PYTHON!` missing (minor: they come from data types, cloud and Python features) | ## Depth (estimated) @@ -108,6 +108,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Localized function names and `;` separators: partial, formulas follow the interface language in es, de, pt-BR and ru | | 2026-10-12 | minor | USDOLLAR added as a Compatibility function (not on Microsoft's list, so the counts don't change); MIRR with no positive flow is -1, COUNTIF's bare `"<="` / `">="` skip empty strings, INDIRECT with `a1` = FALSE gives `#REF!` for A1-style text (#738) | | 2026-10-11 | minor | TEXT / number formats fixed against Excel-cached results: fraction literals and zero fractions, `0` denominators zero padded, `?` pads the thousands separator, booleans use the text section (226 mismatches → 0 in 352 TEXT cells) | | 2026-10-11 | minor | Iterative calculation: circular references iterate up to Maximum Iterations / Maximum Change when enabled | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 09197ff6c9..6e8948d5e6 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Format Cells dialog looks its labels up in the catalog) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (formulas in es, de, pt-BR and ru) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Per-language status against Excel, which ships its UI in dozens of languages (Windows language packs cover 100+), with localized function names, argument separators, number and date formats, @@ -21,10 +21,13 @@ right-to-left sheets and full complex-script support. Part of - **About 2,200 user-visible strings** in all (heuristic source scan: ~1,700 UI and command strings plus ~500 function descriptions), so each shipped language covers **~12%** (measured count / estimated total). -- **en-US only formulas and formats.** Function names, `,` argument separator, `.` decimal - separator, en-US number and date formats (`crates/numfmt` says "en-US only"). Users ask for - localized function names and `;` separators (#45). German with localized numbers and formulas - is in PR #163 (open). +- **Formulas follow the interface language in Spanish, German, Brazilian Portuguese and + Russian** (#45): function names (403–459 per language), booleans, errors, table specifiers and + separators, at the editor boundary only; files and APIs keep canonical English + ([formula-input.md](formula-input.md)). The names come from machine-translated Microsoft pages + (two agreeing signals per entry, sources in `crates/formula/src/input/`) and have no native + review. French, Czech and Serbian formulas stay English. Number and date formats are en-US only + (`crates/numfmt` says "en-US only"); PR #163 (German formulas and numbers) is open. - **Script support.** System-font fallback for CJK, Thai and Hebrew landed in #38 (closes #32; #142 is still open pending confirmation). There is no complex-text shaping: Arabic and Devanagari don't join or reorder, and there is no bidirectional layout. **Sheet Right-to-Left** @@ -67,9 +70,9 @@ language (none so far) and of localized function names. 1. Extend the catalog from the ribbon to every user-visible string (dialogs, menus, task panes, status and error messages, command labels, function descriptions), with a missing-key check in `cargo xtask ci`. 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. Locale-aware formulas: function tables and separators exist for es, de, pt-BR and ru at the + formula-bar boundary (files keep canonical names); still missing: tables for French and the + other languages, locale number and date formats, list separators in CSV. 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. @@ -80,6 +83,7 @@ language (none so far) and of localized function names. |---|---|---| | 2026-10-12 | minor | Format Cells looks up its tabs, labels, options and note through `Language::tr` (Fill tab keyed `Format Cells\|Fill`); dialog titles and OK/Cancel too. Labels already in a table (Number, Currency, Fill…) show translated; the rest fall back to English until the tables add them | | 2026-10-12 | minor | Brazilian Portuguese catalog (`i18n/pt.rs`) grows from 377 to 923 entries: every key the other catalogs translate, including the dialog strings #669 starts looking up (~42%) | +| 2026-10-11 | minor | Formulas follow the interface language in es, de, pt-BR and ru (names, booleans, errors, table specifiers, separators); infrastructure item 2 partly done | | 2026-10-11 | minor | German ribbon catalog (`i18n/de.rs`, 266 labels, ~12%); `de` locale tags (de-DE, de-AT, de-CH…) resolve to German. PR #163 (localized German formulas/numbers) still open | | 2026-10-11 | minor | Sheet Right-to-Left mirrors the grid (#146) | | 2026-10-11 | minor | Czech (cs) ribbon table: 266 labels (~12%), menus only | From 301903278824309404e9e2debb421901452854b8 Mon Sep 17 00:00:00 2001 From: Herr-Enklo <168219178+Herr-Enklo@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:15:58 +0200 Subject: [PATCH 2/7] Formulas: German table as Excel 365 de-DE shows it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every German word in crates/formula/src/input/de.rs was observed in Excel 365 (German interface, de-DE regional format) on 2026-10-13 through its automation interface: each of GridCraft's 514 functions, plus GETPIVOTDATA, IMAGE, TRANSLATE and DETECTLANGUAGE, was entered in canonical form and read back as German Excel shows it. That confirmed 402 of #443's names, corrected MAP (it stays MAP; #443 had ZUORDNEN) and added the 17 it was missing (PROGNOSE.ETS*, UNREGER.KURS and the other ODD* functions, PROZENTVON, ABSCHNBEREICH, the REGEX functions, PIVOTDATENZUORDNEN, BILD, ÜBERSETZEN, SPRACHEERKENNEN). 99 functions keep their English name. The observation also settled the error values: German Excel shows #CALC! (not #KALK! as Microsoft's help pages say) for =LAMBDA(x;x) and an empty FILTER, and #ÜBERLAUF! for a blocked spill. [#Diese Zeile] is accepted when typed and shown as @, including for a column range, so the parser and printer now read and write [@[A]:[B]] as Excel does (files keep [[#This Row],[A]:[B]]). crates/formula/tests/german_observed.rs holds the 518 observed pairs plus separators, booleans, errors, arrays and table specifiers, and checks both directions: canonical -> German equals Excel's text, and Excel's text reads back as the formula. A fuzz test feeds random German fragments to the parser and printer. The calc test that every table names a known function allows the four Excel functions GridCraft doesn't compute yet, and fails once one of them is implemented. Co-Authored-By: Claude Opus 5.5 --- crates/calc/src/tests.rs | 9 +- crates/formula/src/input/de.rs | 80 ++- crates/formula/src/parser.rs | 10 +- crates/formula/src/printer.rs | 4 + crates/formula/src/tests.rs | 13 + crates/formula/tests/german_observed.rs | 681 ++++++++++++++++++++++++ 6 files changed, 751 insertions(+), 46 deletions(-) create mode 100644 crates/formula/tests/german_observed.rs diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index bdc0218f9b..b108e5ca70 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -1270,8 +1270,15 @@ fn offset_extreme_arguments_return_ref() { #[test] fn formula_language_tables_only_name_functions_the_engine_knows() { // A typo'd canonical name in a language table would turn a valid local name into #NAME?. + // Excel functions GridCraft doesn't compute yet may be listed (a file's GETPIVOTDATA shows as + // PIVOTDATENZUORDNEN in German), once they really are unknown here. + const NOT_YET: [&str; 4] = ["DETECTLANGUAGE", "GETPIVOTDATA", "IMAGE", "TRANSLATE"]; + for name in NOT_YET { + assert!(!crate::eval::is_known_function(name), "{name} is implemented now: drop it from NOT_YET"); + } for locale in gridcraft_formula::FormulaLocale::ALL { - let unknown: Vec<&str> = locale.translations().iter().map(|(en, _)| *en).filter(|en| !crate::eval::is_known_function(en)).collect(); + let unknown: Vec<&str> = + locale.translations().iter().map(|(en, _)| *en).filter(|en| !crate::eval::is_known_function(en) && !NOT_YET.contains(en)).collect(); assert!(unknown.is_empty(), "{locale:?}: {unknown:?}"); } } diff --git a/crates/formula/src/input/de.rs b/crates/formula/src/input/de.rs index bdf4727b21..f0fb799b53 100644 --- a/crates/formula/src/input/de.rs +++ b/crates/formula/src/input/de.rs @@ -1,18 +1,25 @@ //! Deutsch (Deutschland): the formula words German Excel shows (function names, booleans, errors, //! structured-reference specifiers) and its formula separators. //! -//! Function names come from Microsoft Support's de-de Excel function pages (retrieved -//! 2026-10-10; the pages are machine-translated): per page, a vote among the title, the meta -//! description, the syntax paragraph (aligned with the en-US page) and `=NAME(` examples, and an -//! entry needs two agreeing signals. Entries marked `reviewed` were decided by hand from the page -//! evidence and independent function-name references. Functions not listed keep their English name. +//! Observed black-box in Excel 365 (German interface, de-DE regional format) on 2026-10-13 through +//! its automation interface, in synthetic workbooks that were never saved: every function +//! GridCraft knows (and GETPIVOTDATA, IMAGE, TRANSLATE and DETECTLANGUAGE, which it doesn't yet) +//! was entered in canonical form (`Range.Formula2`) and read back as German Excel shows it +//! (`Range.Formula2Local`). Never copied from a string table or a help page. The 99 functions not +//! listed kept their English name there, among them ABS, ASC, BAHTTEXT, the BESSEL functions, +//! CODE, COS, FILTER, INDEX, LAMBDA, LET, MAP, MAX, MIN, REDUCE, SCAN, TEXT and USDOLLAR. //! -//! Sources for the other words: -//! - bool: https://support.microsoft.com/de-de/excel/functions/cell-function (WAHR/FALSCH) | https://support.microsoft.com/de-de/excel/is-functions -//! - table_items: https://it-service-ruhr.de/excel-strukturierte-verweise/ ([#Alle] [#Kopfzeilen] [#Daten] [#Ergebnisse] [#Diese Zeile]) | https://fasteasy.at/tippshop/index.php?mp=1&id=1&id2=17&id3=1728 (#Alle #Daten #Kopfzeilen #Ergebnisse, "[#Diese Zeile]") -//! - errors.#WERT!..#NV,#DATEN_ABRUFEN: https://support.microsoft.com/de-de/excel/functions/error-type-function (ERROR.TYPE table) | https://support.microsoft.com/de-de/excel/functions/cubemember-function ("#DATEN_ABRUFEN…") -//! - errors.#SPILL!: https://support.microsoft.com/de-de/excel/how-to-correct-a-spill-error (title "#ÜBERLAUF!-Fehler") -//! - errors.#CALC!: https://support.microsoft.com/de-de/excel/functions/lambda-function ("#KALK!-Fehlermeldung") | https://excelhero.de/funktionen/excel-filter-funktion/ | https://www.tabellenexperte.de/die-neue-filter-funktion-mit-jokersuche/ | https://www.tippscout.de/excel-daten-filtern-ganz-einfach.html +//! Other words, observed the same way: +//! - separators: `=SUMME(1;2)`, `=1,5+2,25`, `={1.2.3}` (columns), `={1;2;3}` (rows), +//! `={1.2;3.4}`; union `=SUMME(A1:A2;B1:B2)`. +//! - bool: `WAHR`, `FALSCH` (also typed into cells; `TRUE` typed there stays text). +//! - errors: #NULL!, #DIV/0!, #WERT!, #BEZUG!, #NAME?, #ZAHL!, #NV and #DATEN_ABRUFEN as formula +//! literals and cell values; #ÜBERLAUF! is what a blocked spill shows and #CALC! what `=LAMBDA(x;x)` +//! and an empty `=FILTER(…)` show (German Excel keeps #CALC! in English; neither is accepted as a +//! typed literal there). +//! - table_items: `Tbl[[#Kopfzeilen];[H1]]`, `[#Alle]`, `[#Daten]`, `[#Ergebnisse]`, `;` between +//! bracketed items; `[#Diese Zeile]` is accepted when typed and shown in the `@` short form +//! (`Tbl[@H1]`, `Tbl[@[H1]:[H2]]`, `Tbl[@]`), as canonical text already spells it. use super::Dialect; use gridcraft_core::CellError; @@ -34,7 +41,7 @@ pub(super) const DIALECT: Dialect = Dialect { (CellError::NA, "#NV"), (CellError::GettingData, "#DATEN_ABRUFEN"), (CellError::Spill, "#ÜBERLAUF!"), - (CellError::Calc, "#KALK!"), + (CellError::Calc, "#CALC!"), ], table_items: ["#Alle", "#Daten", "#Kopfzeilen", "#Ergebnisse", "#Diese Zeile"], functions: &[ @@ -76,9 +83,7 @@ pub(super) const DIALECT: Dialect = Dialect { ("BITOR", "BITODER"), ("BITRSHIFT", "BITRVERSCHIEB"), ("BITXOR", "BITXODER"), - // reviewed: sidebar link on MS de-de function pages (page body left BYCOL); pairs with BYROW=NACHZEILE | sources: https://support.microsoft.com/de-de/excel/functions/bycol-function ("BYCOL", "NACHSPALTE"), - // reviewed: sidebar link on MS de-de function pages (page body left BYROW) | sources: https://support.microsoft.com/de-de/excel/functions/byrow-function ("BYROW", "NACHZEILE"), ("CEILING", "OBERGRENZE"), ("CEILING.MATH", "OBERGRENZE.MATHEMATIK"), @@ -93,7 +98,6 @@ pub(super) const DIALECT: Dialect = Dialect { ("CHISQ.TEST", "CHIQU.TEST"), ("CHOOSE", "WAHL"), ("CHOOSECOLS", "SPALTENWAHL"), - // reviewed: sidebar link; pairs with CHOOSECOLS=SPALTENWAHL (agreeing vote); page example uses MT variant ZEILENAUSWAHL | sources: https://support.microsoft.com/de-de/excel/functions/chooserows-function ("CHOOSEROWS", "ZEILENWAHL"), ("CLEAN", "SÄUBERN"), ("COLUMN", "SPALTE"), @@ -112,7 +116,6 @@ pub(super) const DIALECT: Dialect = Dialect { ("COTH", "COTHYP"), ("COUNT", "ANZAHL"), ("COUNTA", "ANZAHL2"), - // reviewed: page title ANZAHLLEEREZELLEN; meta/syntax ANZAHLLEERE/ANZAHLLEER are MT truncations; en-US COUNTBLANK page | sources: https://support.microsoft.com/de-de/excel/functions/countblank-function ("COUNTBLANK", "ANZAHLLEEREZELLEN"), ("COUNTIF", "ZÄHLENWENN"), ("COUNTIFS", "ZÄHLENWENNS"), @@ -152,12 +155,12 @@ pub(super) const DIALECT: Dialect = Dialect { ("DEC2OCT", "DEZINOKT"), ("DECIMAL", "DEZIMAL"), ("DEGREES", "GRAD"), + ("DETECTLANGUAGE", "SPRACHEERKENNEN"), ("DEVSQ", "SUMQUADABW"), ("DGET", "DBAUSZUG"), ("DISC", "DISAGIO"), ("DMAX", "DBMAX"), ("DMIN", "DBMIN"), - // reviewed: page title "DM"; German Excel DOLLAR is DM (meta/syntax left English) | sources: https://support.microsoft.com/de-de/excel/functions/dollar-function ("DOLLAR", "DM"), ("DOLLARDE", "NOTIERUNGDEZ"), ("DOLLARFR", "NOTIERUNGBRU"), @@ -182,7 +185,6 @@ pub(super) const DIALECT: Dialect = Dialect { ("EXPAND", "ERWEITERN"), ("EXPON.DIST", "EXPON.VERT"), ("EXPONDIST", "EXPONVERT"), - // reviewed: page title F.VERT + sidebar; F.DIST.RT=F.VERT.RE agrees | sources: https://support.microsoft.com/de-de/excel/functions/f-dist-function ("F.DIST", "F.VERT"), ("F.DIST.RT", "F.VERT.RE"), ("F.INV.RT", "F.INV.RE"), @@ -191,17 +193,17 @@ pub(super) const DIALECT: Dialect = Dialect { ("FALSE", "FALSCH"), ("FDIST", "FVERT"), ("FILTERXML", "XMLFILTERN"), - // reviewed: SEARCH page: "…Groß- und Kleinschreibung… können Sie FINDEN verwenden" (FIND page retired) | sources: https://support.microsoft.com/de-de/excel/functions/search-function ("FIND", "FINDEN"), - // reviewed: B-variant pattern of FINDEN (LINKSB, TEILB, RECHTSB, LÄNGEB, SUCHENB documented); name itself not on a cached page [inferred] | sources: https://support.microsoft.com/de-de/excel/functions/c7912941-af2a-4bdf-a553-d0d89b0a0628 ("FINDB", "FINDENB"), ("FIXED", "FEST"), ("FLOOR", "UNTERGRENZE"), ("FLOOR.MATH", "UNTERGRENZE.MATHEMATIK"), ("FLOOR.PRECISE", "UNTERGRENZE.GENAU"), - // reviewed: page: "PROGNOSE.LINEAR(x;Y_Werte;X_Werte) oder SCHÄTZER(x;Y_Werte;X_Werte)"; PROGNOSE replaced SCHÄTZER only as .LINEAR | sources: https://support.microsoft.com/de-de/excel/forecast-and-forecast-linear-functions ("FORECAST", "SCHÄTZER"), - // reviewed: same page syntax and example =PROGNOSE.LINEAR(30,A2:A6,B2:B6) | sources: https://support.microsoft.com/de-de/excel/forecast-and-forecast-linear-functions + ("FORECAST.ETS", "PROGNOSE.ETS"), + ("FORECAST.ETS.CONFINT", "PROGNOSE.ETS.KONFINT"), + ("FORECAST.ETS.SEASONALITY", "PROGNOSE.ETS.SAISONALITÄT"), + ("FORECAST.ETS.STAT", "PROGNOSE.ETS.STAT"), ("FORECAST.LINEAR", "PROGNOSE.LINEAR"), ("FORMULATEXT", "FORMELTEXT"), ("FREQUENCY", "HÄUFIGKEIT"), @@ -213,6 +215,7 @@ pub(super) const DIALECT: Dialect = Dialect { ("GCD", "GGT"), ("GEOMEAN", "GEOMITTEL"), ("GESTEP", "GGANZZAHL"), + ("GETPIVOTDATA", "PIVOTDATENZUORDNEN"), ("GROUPBY", "GRUPPIERENNACH"), ("GROWTH", "VARIATION"), ("HARMEAN", "HARMITTEL"), @@ -228,6 +231,7 @@ pub(super) const DIALECT: Dialect = Dialect { ("IFERROR", "WENNFEHLER"), ("IFNA", "WENNNV"), ("IFS", "WENNS"), + ("IMAGE", "BILD"), ("IMAGINARY", "IMAGINÄRTEIL"), ("IMCONJUGATE", "IMKONJUGIERTE"), ("IMCOSH", "IMCOSHYP"), @@ -246,31 +250,20 @@ pub(super) const DIALECT: Dialect = Dialect { ("INTRATE", "ZINSSATZ"), ("IPMT", "ZINSZ"), ("IRR", "IKV"), - // reviewed: IS-functions page syntax list and examples =ISTLEER(A2) | sources: https://support.microsoft.com/de-de/excel/is-functions ("ISBLANK", "ISTLEER"), - // reviewed: IS-functions page syntax list, example =ISTFEHL(A6) | sources: https://support.microsoft.com/de-de/excel/is-functions ("ISERR", "ISTFEHL"), - // reviewed: IS-functions page syntax list, example =WENN(ISTFEHLER(A1)… | sources: https://support.microsoft.com/de-de/excel/is-functions ("ISERROR", "ISTFEHLER"), ("ISEVEN", "ISTGERADE"), ("ISFORMULA", "ISTFORMEL"), - // reviewed: IS-functions page syntax list, example =ISTLOG(WAHR) | sources: https://support.microsoft.com/de-de/excel/is-functions ("ISLOGICAL", "ISTLOG"), - // reviewed: IS-functions page syntax list, example =ISTNV(A4) | sources: https://support.microsoft.com/de-de/excel/is-functions ("ISNA", "ISTNV"), - // reviewed: IS-functions page syntax list ISTKTEXT(Wert) and description row | sources: https://support.microsoft.com/de-de/excel/is-functions ("ISNONTEXT", "ISTKTEXT"), - // reviewed: IS-functions page syntax list, example =ISTZAHL(4) | sources: https://support.microsoft.com/de-de/excel/is-functions ("ISNUMBER", "ISTZAHL"), ("ISO.CEILING", "ISO.OBERGRENZE"), - // reviewed: live MS page https://support.microsoft.com/de-de/excel/functions/isodd-function: title/syntax/examples ISTUNGERADE (the IS-functions page shows MT-garbled "ISUNGERADE") ("ISODD", "ISTUNGERADE"), - // reviewed: ISOMITTED page syntax and example | sources: https://support.microsoft.com/de-de/excel/functions/isomitted-function ("ISOMITTED", "WURDEAUSGELASSEN"), ("ISOWEEKNUM", "ISOKALENDERWOCHE"), - // reviewed: IS-functions page syntax list, example =ISTBEZUG(G8) | sources: https://support.microsoft.com/de-de/excel/is-functions ("ISREF", "ISTBEZUG"), - // reviewed: IS-functions page syntax list, example =ISTTEXT(A3) | sources: https://support.microsoft.com/de-de/excel/is-functions ("ISTEXT", "ISTTEXT"), ("LARGE", "KGRÖSSTE"), ("LCM", "KGV"), @@ -284,10 +277,7 @@ pub(super) const DIALECT: Dialect = Dialect { ("LOGNORMDIST", "LOGNORMVERT"), ("LOOKUP", "VERWEIS"), ("LOWER", "KLEIN"), - // reviewed: sidebar link on MS de-de function pages (page body left MAKEARRAY) | sources: https://support.microsoft.com/de-de/excel/functions/makearray-function ("MAKEARRAY", "MATRIXERSTELLEN"), - // reviewed: MAP page syntax "=ZUORDNEN (Array1; lambda_or_array<#>)" and example; sidebar says KARTE (MT of the title) – rejected | sources: https://support.microsoft.com/de-de/excel/functions/map-function - ("MAP", "ZUORDNEN"), ("MATCH", "VERGLEICH"), ("MAXIFS", "MAXWENNS"), ("MDETERM", "MDET"), @@ -323,11 +313,16 @@ pub(super) const DIALECT: Dialect = Dialect { ("OCT2DEC", "OKTINDEZ"), ("OCT2HEX", "OKTINHEX"), ("ODD", "UNGERADE"), + ("ODDFPRICE", "UNREGER.KURS"), + ("ODDFYIELD", "UNREGER.REND"), + ("ODDLPRICE", "UNREGLE.KURS"), + ("ODDLYIELD", "UNREGLE.REND"), ("OFFSET", "BEREICH.VERSCHIEBEN"), ("OR", "ODER"), ("PERCENTILE", "QUANTIL"), ("PERCENTILE.EXC", "QUANTIL.EXKL"), ("PERCENTILE.INC", "QUANTIL.INKL"), + ("PERCENTOF", "PROZENTVON"), ("PERCENTRANK", "QUANTILSRANG"), ("PERCENTRANK.EXC", "QUANTILSRANG.EXKL"), ("PERCENTRANK.INC", "QUANTILSRANG.INKL"), @@ -356,8 +351,10 @@ pub(super) const DIALECT: Dialect = Dialect { ("RANK.EQ", "RANG.GLEICH"), ("RATE", "ZINS"), ("RECEIVED", "AUSZAHLUNG"), + ("REGEXEXTRACT", "REGEXEXTRAHIEREN"), + ("REGEXREPLACE", "REGEXERSETZEN"), + ("REGEXTEST", "REGEXTESTEN"), ("REPLACE", "ERSETZEN"), - // reviewed: B-variant pattern of ERSETZEN; not named on a cached page [inferred] | sources: https://support.microsoft.com/de-de/excel/functions/replace-function ("REPLACEB", "ERSETZENB"), ("REPT", "WIEDERHOLEN"), ("RIGHT", "RECHTS"), @@ -407,7 +404,6 @@ pub(super) const DIALECT: Dialect = Dialect { ("SUMX2MY2", "SUMMEX2MY2"), ("SUMX2PY2", "SUMMEX2PY2"), ("SUMXMY2", "SUMMEXMY2"), - // reviewed: SWITCH page meta + examples + sidebar | sources: https://support.microsoft.com/de-de/excel/functions/switch-function ("SWITCH", "ERSTERWERT"), ("SYD", "DIA"), ("T.DIST", "T.VERT"), @@ -420,21 +416,20 @@ pub(super) const DIALECT: Dialect = Dialect { ("TBILLPRICE", "TBILLKURS"), ("TBILLYIELD", "TBILLRENDITE"), ("TDIST", "TVERT"), - // reviewed: TEXTAFTER page title + meta (TEXTNACH); syntax paragraph left English | sources: https://support.microsoft.com/de-de/excel/functions/textafter-function ("TEXTAFTER", "TEXTNACH"), - // reviewed: page syntax + examples + sidebar (title/meta left English) | sources: https://support.microsoft.com/de-de/excel/functions/textbefore-function ("TEXTBEFORE", "TEXTVOR"), ("TEXTJOIN", "TEXTVERKETTEN"), - // reviewed: page syntax/examples and MS TEXTSPLIT page example =TEXTTEILEN(A1,{",","."}) | sources: https://support.microsoft.com/de-de/excel/functions/textsplit-function | https://support.microsoft.com/de-de/office/textsplit-funktion-b1ca414e-4c21-4ca0-b1b7-bdecace8a6e7 ("TEXTSPLIT", "TEXTTEILEN"), ("TIME", "ZEIT"), ("TIMEVALUE", "ZEITWERT"), ("TOCOL", "ZUSPALTE"), ("TODAY", "HEUTE"), ("TOROW", "ZUZEILE"), + ("TRANSLATE", "ÜBERSETZEN"), ("TRANSPOSE", "MTRANS"), ("TRIM", "GLÄTTEN"), ("TRIMMEAN", "GESTUTZTMITTEL"), + ("TRIMRANGE", "ABSCHNBEREICH"), ("TRUE", "WAHR"), ("TRUNC", "KÜRZEN"), ("TYPE", "TYP"), @@ -442,7 +437,6 @@ pub(super) const DIALECT: Dialect = Dialect { ("UNIQUE", "EINDEUTIG"), ("UPPER", "GROSS"), ("VALUE", "WERT"), - // reviewed: page syntax "WERTZUTEXT(Wert;[Format])" + sidebar; pairs with ARRAYTOTEXT=MATRIXZUTEXT | sources: https://support.microsoft.com/de-de/excel/functions/valuetotext-function ("VALUETOTEXT", "WERTZUTEXT"), ("VAR", "VARIANZ"), ("VARA", "VARIANZA"), @@ -456,9 +450,7 @@ pub(super) const DIALECT: Dialect = Dialect { ("WEIBULL.DIST", "WEIBULL.VERT"), ("WORKDAY", "ARBEITSTAG"), ("WORKDAY.INTL", "ARBEITSTAG.INTL"), - // reviewed: sidebar link (page body left WRAPCOLS) | sources: https://support.microsoft.com/de-de/excel/functions/wrapcols-function ("WRAPCOLS", "SPALTENUMBRUCH"), - // reviewed: page example =ZEILENUMBRUCH + sidebar | sources: https://support.microsoft.com/de-de/excel/functions/wraprows-function ("WRAPROWS", "ZEILENUMBRUCH"), ("XIRR", "XINTZINSFUSS"), ("XLOOKUP", "XVERWEIS"), diff --git a/crates/formula/src/parser.rs b/crates/formula/src/parser.rs index 459af1dc8e..bdb968bda1 100644 --- a/crates/formula/src/parser.rs +++ b/crates/formula/src/parser.rs @@ -473,8 +473,16 @@ pub fn parse_struct(table: &str, body: &str) -> Result { if !body.starts_with('[') { // Simple: [Col], [@Col], [#Headers], [@] if let Some(rest) = body.strip_prefix('@') { - specifiers.push(StructItem::ThisRow); let rest = rest.trim(); + if rest.starts_with('[') && rest.contains("]:") { + // `[@[Col1]:[Col3]]`: this row of a column range. + let inner = parse_struct(table, rest)?; + if !inner.specifiers.is_empty() { + return Err(unexpected(rest)); + } + return Ok(StructRef { specifiers: vec![StructItem::ThisRow], ..inner }); + } + specifiers.push(StructItem::ThisRow); let rest = rest.strip_prefix('[').and_then(|r| r.strip_suffix(']')).unwrap_or(rest); if !rest.is_empty() { cols.push(column(rest)?); diff --git a/crates/formula/src/printer.rs b/crates/formula/src/printer.rs index 3975a7c615..598183ff73 100644 --- a/crates/formula/src/printer.rs +++ b/crates/formula/src/printer.rs @@ -133,6 +133,10 @@ fn write_struct_ref(s: &StructRef, at_shorthand: bool) -> String { let _ = write!(out, "[@[{}]]", esc(c)); } } + // Excel shows this row of a column range as `[@[Col1]:[Col3]]`. + (Some(a), Some(b)) if this_row => { + let _ = write!(out, "[@[{}]:[{}]]", esc(a), esc(b)); + } _ => { let mut parts: Vec = s.specifiers.iter().map(|i| format!("[{}]", item(i))).collect(); match (&s.col_start, &s.col_end) { diff --git a/crates/formula/src/tests.rs b/crates/formula/src/tests.rs index 103a26bd1c..61fc028961 100644 --- a/crates/formula/src/tests.rs +++ b/crates/formula/src/tests.rs @@ -272,11 +272,24 @@ fn unbracketed_this_row_column() { "Sales[[#All],[A]]junk", "Sales[[A],[B],[C]]", "Sales[@[A]x]", + "Sales[@[#Data],[A]:[B]]", + "Sales[@[A]:[B]x]", ] { assert!(parse(bad).is_err(), "{bad} should fail"); } } +#[test] +fn this_row_of_a_column_range() { + // Excel shows `[[#This Row],[A]:[B]]` as `[@[A]:[B]]` (observed) and files keep the long form. + let Expr::Struct(s) = parse("Sales[@[Unit Price]:[Qty]]").unwrap() else { panic!() }; + assert_eq!(s.specifiers, [StructItem::ThisRow]); + assert_eq!((s.col_start.as_deref(), s.col_end.as_deref()), (Some("Unit Price"), Some("Qty"))); + assert_eq!(rt("Sales[[#This Row],[Unit Price]:[Qty]]"), "Sales[@[Unit Price]:[Qty]]"); + assert_eq!(rt("SUM(Sales[@[A]:[B]])"), "SUM(Sales[@[A]:[B]])"); + assert_eq!(crate::printer::struct_ref_file(&s), "Sales[[#This Row],[Unit Price]:[Qty]]"); +} + #[test] fn struct_column_keeps_edge_spaces() { // The spaces are part of the column name: `Table1[Qty ]` names `Qty `, never `Qty`. diff --git a/crates/formula/tests/german_observed.rs b/crates/formula/tests/german_observed.rs new file mode 100644 index 0000000000..2f957082f1 --- /dev/null +++ b/crates/formula/tests/german_observed.rs @@ -0,0 +1,681 @@ +//! German formulas exactly as Excel 365 (German interface, de-DE regional format) shows them. +//! +//! Each pair is a formula entered in canonical form and the text German Excel showed for it, +//! observed black-box through its automation interface on 2026-10-13 (`Range.Formula2` in, +//! `Range.Formula2Local` out), in synthetic workbooks that were never saved. Every function +//! GridCraft knows is here, with GETPIVOTDATA, IMAGE, TRANSLATE and DETECTLANGUAGE, which Excel +//! knows and GridCraft doesn't yet. +use gridcraft_formula::FormulaLocale::{self, De}; +use gridcraft_formula::{parse, parse_input, print, print_input}; + +/// `(canonical, German)`, one call per function. +const FUNCTIONS: &[(&str, &str)] = &[ + ("ABS(1)", "ABS(1)"), + ("ACCRINT(1,1,1,1,1,1)", "AUFGELZINS(1;1;1;1;1;1)"), + ("ACCRINTM(1,1,1,1)", "AUFGELZINSF(1;1;1;1)"), + ("ACOS(1)", "ARCCOS(1)"), + ("ACOSH(1)", "ARCCOSHYP(1)"), + ("ACOT(1)", "ARCCOT(1)"), + ("ACOTH(1)", "ARCCOTHYP(1)"), + ("ADDRESS(1,1)", "ADRESSE(1;1)"), + ("AGGREGATE(1,1,1)", "AGGREGAT(1;1;1)"), + ("AMORDEGRC(1,1,1,1,1,1)", "AMORDEGRK(1;1;1;1;1;1)"), + ("AMORLINC(1,1,1,1,1,1)", "AMORLINEARK(1;1;1;1;1;1)"), + ("AND(1)", "UND(1)"), + ("ARABIC(1)", "ARABISCH(1)"), + ("AREAS(A1)", "BEREICHE(A1)"), + ("ARRAYTOTEXT(1)", "MATRIXZUTEXT(1)"), + ("ASC(1)", "ASC(1)"), + ("ASIN(1)", "ARCSIN(1)"), + ("ASINH(1)", "ARCSINHYP(1)"), + ("ATAN(1)", "ARCTAN(1)"), + ("ATAN2(1,1)", "ARCTAN2(1;1)"), + ("ATANH(1)", "ARCTANHYP(1)"), + ("AVEDEV(1)", "MITTELABW(1)"), + ("AVERAGE(1)", "MITTELWERT(1)"), + ("AVERAGEA(1)", "MITTELWERTA(1)"), + ("AVERAGEIF(A1:A2,1)", "MITTELWERTWENN(A1:A2;1)"), + ("AVERAGEIFS(A1:A2,A1:A2,1)", "MITTELWERTWENNS(A1:A2;A1:A2;1)"), + ("BAHTTEXT(1)", "BAHTTEXT(1)"), + ("BASE(1,1)", "BASIS(1;1)"), + ("BESSELI(1,1)", "BESSELI(1;1)"), + ("BESSELJ(1,1)", "BESSELJ(1;1)"), + ("BESSELK(1,1)", "BESSELK(1;1)"), + ("BESSELY(1,1)", "BESSELY(1;1)"), + ("BETA.DIST(1,1,1,1)", "BETA.VERT(1;1;1;1)"), + ("BETA.INV(1,1,1)", "BETA.INV(1;1;1)"), + ("BETADIST(1,1,1)", "BETAVERT(1;1;1)"), + ("BETAINV(1,1,1)", "BETAINV(1;1;1)"), + ("BIN2DEC(1)", "BININDEZ(1)"), + ("BIN2HEX(1)", "BININHEX(1)"), + ("BIN2OCT(1)", "BININOKT(1)"), + ("BINOM.DIST(1,1,1,1)", "BINOM.VERT(1;1;1;1)"), + ("BINOM.DIST.RANGE(1,1,1)", "BINOM.VERT.BEREICH(1;1;1)"), + ("BINOM.INV(1,1,1)", "BINOM.INV(1;1;1)"), + ("BINOMDIST(1,1,1,1)", "BINOMVERT(1;1;1;1)"), + ("BITAND(1,1)", "BITUND(1;1)"), + ("BITLSHIFT(1,1)", "BITLVERSCHIEB(1;1)"), + ("BITOR(1,1)", "BITODER(1;1)"), + ("BITRSHIFT(1,1)", "BITRVERSCHIEB(1;1)"), + ("BITXOR(1,1)", "BITXODER(1;1)"), + ("BYCOL(1,1)", "NACHSPALTE(1;1)"), + ("BYROW(1,1)", "NACHZEILE(1;1)"), + ("CEILING(1,1)", "OBERGRENZE(1;1)"), + ("CEILING.MATH(1)", "OBERGRENZE.MATHEMATIK(1)"), + ("CEILING.PRECISE(1)", "OBERGRENZE.GENAU(1)"), + ("CELL(1)", "ZELLE(1)"), + ("CHAR(1)", "ZEICHEN(1)"), + ("CHIDIST(1,1)", "CHIVERT(1;1)"), + ("CHIINV(1,1)", "CHIINV(1;1)"), + ("CHISQ.DIST(1,1,1)", "CHIQU.VERT(1;1;1)"), + ("CHISQ.DIST.RT(1,1)", "CHIQU.VERT.RE(1;1)"), + ("CHISQ.INV(1,1)", "CHIQU.INV(1;1)"), + ("CHISQ.INV.RT(1,1)", "CHIQU.INV.RE(1;1)"), + ("CHISQ.TEST(1,1)", "CHIQU.TEST(1;1)"), + ("CHITEST(1,1)", "CHITEST(1;1)"), + ("CHOOSE(1,1)", "WAHL(1;1)"), + ("CHOOSECOLS(1,1)", "SPALTENWAHL(1;1)"), + ("CHOOSEROWS(1,1)", "ZEILENWAHL(1;1)"), + ("CLEAN(1)", "SÄUBERN(1)"), + ("CODE(1)", "CODE(1)"), + ("COLUMN()", "SPALTE()"), + ("COLUMNS(1)", "SPALTEN(1)"), + ("COMBIN(1,1)", "KOMBINATIONEN(1;1)"), + ("COMBINA(1,1)", "KOMBINATIONEN2(1;1)"), + ("COMPLEX(1,1)", "KOMPLEXE(1;1)"), + ("CONCAT(1)", "TEXTKETTE(1)"), + ("CONCATENATE(1)", "VERKETTEN(1)"), + ("CONFIDENCE(1,1,1)", "KONFIDENZ(1;1;1)"), + ("CONFIDENCE.NORM(1,1,1)", "KONFIDENZ.NORM(1;1;1)"), + ("CONFIDENCE.T(1,1,1)", "KONFIDENZ.T(1;1;1)"), + ("CONVERT(1,1,1)", "UMWANDELN(1;1;1)"), + ("CORREL(1,1)", "KORREL(1;1)"), + ("COS(1)", "COS(1)"), + ("COSH(1)", "COSHYP(1)"), + ("COT(1)", "COT(1)"), + ("COTH(1)", "COTHYP(1)"), + ("COUNT(1)", "ANZAHL(1)"), + ("COUNTA(1)", "ANZAHL2(1)"), + ("COUNTBLANK(A1)", "ANZAHLLEEREZELLEN(A1)"), + ("COUNTIF(A1:A2,1)", "ZÄHLENWENN(A1:A2;1)"), + ("COUNTIFS(A1:A2,1)", "ZÄHLENWENNS(A1:A2;1)"), + ("COUPDAYBS(1,1,1)", "ZINSTERMTAGVA(1;1;1)"), + ("COUPDAYS(1,1,1)", "ZINSTERMTAGE(1;1;1)"), + ("COUPDAYSNC(1,1,1)", "ZINSTERMTAGNZ(1;1;1)"), + ("COUPNCD(1,1,1)", "ZINSTERMNZ(1;1;1)"), + ("COUPNUM(1,1,1)", "ZINSTERMZAHL(1;1;1)"), + ("COUPPCD(1,1,1)", "ZINSTERMVZ(1;1;1)"), + ("COVAR(1,1)", "KOVAR(1;1)"), + ("COVARIANCE.P(1,1)", "KOVARIANZ.P(1;1)"), + ("COVARIANCE.S(1,1)", "KOVARIANZ.S(1;1)"), + ("CRITBINOM(1,1,1)", "KRITBINOM(1;1;1)"), + ("CSC(1)", "COSEC(1)"), + ("CSCH(1)", "COSECHYP(1)"), + ("CUBEKPIMEMBER(1,1,1)", "CUBEKPIELEMENT(1;1;1)"), + ("CUBEMEMBER(1,1)", "CUBEELEMENT(1;1)"), + ("CUBEMEMBERPROPERTY(1,1,1)", "CUBEELEMENTEIGENSCHAFT(1;1;1)"), + ("CUBERANKEDMEMBER(1,1,1)", "CUBERANGELEMENT(1;1;1)"), + ("CUBESET(1,1)", "CUBEMENGE(1;1)"), + ("CUBESETCOUNT(1)", "CUBEMENGENANZAHL(1)"), + ("CUBEVALUE(1)", "CUBEWERT(1)"), + ("CUMIPMT(1,1,1,1,1,1)", "KUMZINSZ(1;1;1;1;1;1)"), + ("CUMPRINC(1,1,1,1,1,1)", "KUMKAPITAL(1;1;1;1;1;1)"), + ("DATE(1,1,1)", "DATUM(1;1;1)"), + ("DATEDIF(1,1,1)", "DATEDIF(1;1;1)"), + ("DATEVALUE(1)", "DATWERT(1)"), + ("DAVERAGE(A1,1,1)", "DBMITTELWERT(A1;1;1)"), + ("DAY(1)", "TAG(1)"), + ("DAYS(1,1)", "TAGE(1;1)"), + ("DAYS360(1,1)", "TAGE360(1;1)"), + ("DB(1,1,1,1)", "GDA2(1;1;1;1)"), + ("DBCS(1)", "DBCS(1)"), + ("DCOUNT(A1,1,1)", "DBANZAHL(A1;1;1)"), + ("DCOUNTA(A1,1,1)", "DBANZAHL2(A1;1;1)"), + ("DDB(1,1,1,1)", "GDA(1;1;1;1)"), + ("DEC2BIN(1)", "DEZINBIN(1)"), + ("DEC2HEX(1)", "DEZINHEX(1)"), + ("DEC2OCT(1)", "DEZINOKT(1)"), + ("DECIMAL(1,1)", "DEZIMAL(1;1)"), + ("DEGREES(1)", "GRAD(1)"), + ("DELTA(1)", "DELTA(1)"), + ("DETECTLANGUAGE(\"a\")", "SPRACHEERKENNEN(\"a\")"), + ("DEVSQ(1)", "SUMQUADABW(1)"), + ("DGET(A1,1,1)", "DBAUSZUG(A1;1;1)"), + ("DISC(1,1,1,1)", "DISAGIO(1;1;1;1)"), + ("DMAX(A1,1,1)", "DBMAX(A1;1;1)"), + ("DMIN(A1,1,1)", "DBMIN(A1;1;1)"), + ("DOLLAR(1)", "DM(1)"), + ("DOLLARDE(1,1)", "NOTIERUNGDEZ(1;1)"), + ("DOLLARFR(1,1)", "NOTIERUNGBRU(1;1)"), + ("DPRODUCT(A1,1,1)", "DBPRODUKT(A1;1;1)"), + ("DROP(1,1)", "WEGLASSEN(1;1)"), + ("DSTDEV(A1,1,1)", "DBSTDABW(A1;1;1)"), + ("DSTDEVP(A1,1,1)", "DBSTDABWN(A1;1;1)"), + ("DSUM(A1,1,1)", "DBSUMME(A1;1;1)"), + ("DURATION(1,1,1,1,1)", "DURATION(1;1;1;1;1)"), + ("DVAR(A1,1,1)", "DBVARIANZ(A1;1;1)"), + ("DVARP(A1,1,1)", "DBVARIANZEN(A1;1;1)"), + ("EDATE(1,1)", "EDATUM(1;1)"), + ("EFFECT(1,1)", "EFFEKTIV(1;1)"), + ("ENCODEURL(1)", "URLCODIEREN(1)"), + ("EOMONTH(1,1)", "MONATSENDE(1;1)"), + ("ERF(1)", "GAUSSFEHLER(1)"), + ("ERF.PRECISE(1)", "GAUSSF.GENAU(1)"), + ("ERFC(1)", "GAUSSFKOMPL(1)"), + ("ERFC.PRECISE(1)", "GAUSSFKOMPL.GENAU(1)"), + ("ERROR.TYPE(1)", "FEHLER.TYP(1)"), + ("EUROCONVERT(1,1,1)", "EUROCONVERT(1;1;1)"), + ("EVEN(1)", "GERADE(1)"), + ("EXACT(1,1)", "IDENTISCH(1;1)"), + ("EXP(1)", "EXP(1)"), + ("EXPAND(1,1)", "ERWEITERN(1;1)"), + ("EXPON.DIST(1,1,1)", "EXPON.VERT(1;1;1)"), + ("EXPONDIST(1,1,1)", "EXPONVERT(1;1;1)"), + ("F.DIST(1,1,1,1)", "F.VERT(1;1;1;1)"), + ("F.DIST.RT(1,1,1)", "F.VERT.RE(1;1;1)"), + ("F.INV(1,1,1)", "F.INV(1;1;1)"), + ("F.INV.RT(1,1,1)", "F.INV.RE(1;1;1)"), + ("F.TEST(1,1)", "F.TEST(1;1)"), + ("FACT(1)", "FAKULTÄT(1)"), + ("FACTDOUBLE(1)", "ZWEIFAKULTÄT(1)"), + ("FALSE()", "FALSCH()"), + ("FDIST(1,1,1)", "FVERT(1;1;1)"), + ("FILTER(1,1)", "FILTER(1;1)"), + ("FILTERXML(\"a\",\"b\")", "XMLFILTERN(\"a\";\"b\")"), + ("FIND(1,1)", "FINDEN(1;1)"), + ("FINDB(1,1)", "FINDENB(1;1)"), + ("FINV(1,1,1)", "FINV(1;1;1)"), + ("FISHER(1)", "FISHER(1)"), + ("FISHERINV(1)", "FISHERINV(1)"), + ("FIXED(1)", "FEST(1)"), + ("FLOOR(1,1)", "UNTERGRENZE(1;1)"), + ("FLOOR.MATH(1)", "UNTERGRENZE.MATHEMATIK(1)"), + ("FLOOR.PRECISE(1)", "UNTERGRENZE.GENAU(1)"), + ("FORECAST(1,1,1)", "SCHÄTZER(1;1;1)"), + ("FORECAST.ETS(1,1,1)", "PROGNOSE.ETS(1;1;1)"), + ("FORECAST.ETS.CONFINT(1,1,1)", "PROGNOSE.ETS.KONFINT(1;1;1)"), + ("FORECAST.ETS.SEASONALITY(1,1)", "PROGNOSE.ETS.SAISONALITÄT(1;1)"), + ("FORECAST.ETS.STAT(1,1,1)", "PROGNOSE.ETS.STAT(1;1;1)"), + ("FORECAST.LINEAR(1,1,1)", "PROGNOSE.LINEAR(1;1;1)"), + ("FORMULATEXT(A1)", "FORMELTEXT(A1)"), + ("FREQUENCY(1,1)", "HÄUFIGKEIT(1;1)"), + ("FTEST(1,1)", "FTEST(1;1)"), + ("FV(1,1,1)", "ZW(1;1;1)"), + ("FVSCHEDULE(1,1)", "ZW2(1;1)"), + ("GAMMA(1)", "GAMMA(1)"), + ("GAMMA.DIST(1,1,1,1)", "GAMMA.VERT(1;1;1;1)"), + ("GAMMA.INV(1,1,1)", "GAMMA.INV(1;1;1)"), + ("GAMMADIST(1,1,1,1)", "GAMMAVERT(1;1;1;1)"), + ("GAMMAINV(1,1,1)", "GAMMAINV(1;1;1)"), + ("GAMMALN(1)", "GAMMALN(1)"), + ("GAMMALN.PRECISE(1)", "GAMMALN.GENAU(1)"), + ("GAUSS(1)", "GAUSS(1)"), + ("GCD(1)", "GGT(1)"), + ("GEOMEAN(1)", "GEOMITTEL(1)"), + ("GESTEP(1)", "GGANZZAHL(1)"), + ("GETPIVOTDATA(\"x\",A1)", "PIVOTDATENZUORDNEN(\"x\";A1)"), + ("GROUPBY(1,1,1)", "GRUPPIERENNACH(1;1;1)"), + ("GROWTH(1)", "VARIATION(1)"), + ("HARMEAN(1)", "HARMITTEL(1)"), + ("HEX2BIN(1)", "HEXINBIN(1)"), + ("HEX2DEC(1)", "HEXINDEZ(1)"), + ("HEX2OCT(1)", "HEXINOKT(1)"), + ("HLOOKUP(1,1,1)", "WVERWEIS(1;1;1)"), + ("HOUR(1)", "STUNDE(1)"), + ("HSTACK(1)", "HSTAPELN(1)"), + ("HYPERLINK(1)", "HYPERLINK(1)"), + ("HYPGEOM.DIST(1,1,1,1,1)", "HYPGEOM.VERT(1;1;1;1;1)"), + ("HYPGEOMDIST(1,1,1,1)", "HYPGEOMVERT(1;1;1;1)"), + ("IF(1,1)", "WENN(1;1)"), + ("IFERROR(1,1)", "WENNFEHLER(1;1)"), + ("IFNA(1,1)", "WENNNV(1;1)"), + ("IFS(1,1)", "WENNS(1;1)"), + ("IMABS(1)", "IMABS(1)"), + ("IMAGE(\"a\")", "BILD(\"a\")"), + ("IMAGINARY(1)", "IMAGINÄRTEIL(1)"), + ("IMARGUMENT(1)", "IMARGUMENT(1)"), + ("IMCONJUGATE(1)", "IMKONJUGIERTE(1)"), + ("IMCOS(1)", "IMCOS(1)"), + ("IMCOSH(1)", "IMCOSHYP(1)"), + ("IMCOT(1)", "IMCOT(1)"), + ("IMCSC(1)", "IMCOSEC(1)"), + ("IMCSCH(1)", "IMCOSECHYP(1)"), + ("IMDIV(1,1)", "IMDIV(1;1)"), + ("IMEXP(1)", "IMEXP(1)"), + ("IMLN(1)", "IMLN(1)"), + ("IMLOG10(1)", "IMLOG10(1)"), + ("IMLOG2(1)", "IMLOG2(1)"), + ("IMPOWER(1,1)", "IMAPOTENZ(1;1)"), + ("IMPRODUCT(1)", "IMPRODUKT(1)"), + ("IMREAL(1)", "IMREALTEIL(1)"), + ("IMSEC(1)", "IMSEC(1)"), + ("IMSECH(1)", "IMSECHYP(1)"), + ("IMSIN(1)", "IMSIN(1)"), + ("IMSINH(1)", "IMSINHYP(1)"), + ("IMSQRT(1)", "IMWURZEL(1)"), + ("IMSUB(1,1)", "IMSUB(1;1)"), + ("IMSUM(1)", "IMSUMME(1)"), + ("IMTAN(1)", "IMTAN(1)"), + ("INDEX(1,1)", "INDEX(1;1)"), + ("INDIRECT(1)", "INDIREKT(1)"), + ("INFO(1)", "INFO(1)"), + ("INT(1)", "GANZZAHL(1)"), + ("INTERCEPT(1,1)", "ACHSENABSCHNITT(1;1)"), + ("INTRATE(1,1,1,1)", "ZINSSATZ(1;1;1;1)"), + ("IPMT(1,1,1,1)", "ZINSZ(1;1;1;1)"), + ("IRR(1)", "IKV(1)"), + ("ISBLANK(1)", "ISTLEER(1)"), + ("ISERR(1)", "ISTFEHL(1)"), + ("ISERROR(1)", "ISTFEHLER(1)"), + ("ISEVEN(1)", "ISTGERADE(1)"), + ("ISFORMULA(A1)", "ISTFORMEL(A1)"), + ("ISLOGICAL(1)", "ISTLOG(1)"), + ("ISNA(1)", "ISTNV(1)"), + ("ISNONTEXT(1)", "ISTKTEXT(1)"), + ("ISNUMBER(1)", "ISTZAHL(1)"), + ("ISO.CEILING(1)", "ISO.OBERGRENZE(1)"), + ("ISODD(1)", "ISTUNGERADE(1)"), + ("ISOMITTED(1)", "WURDEAUSGELASSEN(1)"), + ("ISOWEEKNUM(1)", "ISOKALENDERWOCHE(1)"), + ("ISPMT(1,1,1,1)", "ISPMT(1;1;1;1)"), + ("ISREF(1)", "ISTBEZUG(1)"), + ("ISTEXT(1)", "ISTTEXT(1)"), + ("JIS(1)", "JIS(1)"), + ("KURT(1)", "KURT(1)"), + ("LAMBDA(x,x)", "LAMBDA(x;x)"), + ("LARGE(1,1)", "KGRÖSSTE(1;1)"), + ("LCM(1)", "KGV(1)"), + ("LEFT(1)", "LINKS(1)"), + ("LEFTB(1)", "LINKSB(1)"), + ("LEN(1)", "LÄNGE(1)"), + ("LENB(1)", "LÄNGEB(1)"), + ("LET(x,1,x)", "LET(x;1;x)"), + ("LINEST(1)", "RGP(1)"), + ("LN(1)", "LN(1)"), + ("LOG(1)", "LOG(1)"), + ("LOG10(1)", "LOG10(1)"), + ("LOGEST(1)", "RKP(1)"), + ("LOGINV(1,1,1)", "LOGINV(1;1;1)"), + ("LOGNORM.DIST(1,1,1,1)", "LOGNORM.VERT(1;1;1;1)"), + ("LOGNORM.INV(1,1,1)", "LOGNORM.INV(1;1;1)"), + ("LOGNORMDIST(1,1,1)", "LOGNORMVERT(1;1;1)"), + ("LOOKUP(1,1)", "VERWEIS(1;1)"), + ("LOWER(1)", "KLEIN(1)"), + ("MAKEARRAY(1,1,1)", "MATRIXERSTELLEN(1;1;1)"), + ("MAP(1,1)", "MAP(1;1)"), + ("MATCH(1,1)", "VERGLEICH(1;1)"), + ("MAX(1)", "MAX(1)"), + ("MAXA(1)", "MAXA(1)"), + ("MAXIFS(A1:A2,A1:A2,1)", "MAXWENNS(A1:A2;A1:A2;1)"), + ("MDETERM(1)", "MDET(1)"), + ("MDURATION(1,1,1,1,1)", "MDURATION(1;1;1;1;1)"), + ("MEDIAN(1)", "MEDIAN(1)"), + ("MID(1,1,1)", "TEIL(1;1;1)"), + ("MIDB(1,1,1)", "TEILB(1;1;1)"), + ("MIN(1)", "MIN(1)"), + ("MINA(1)", "MINA(1)"), + ("MINIFS(A1:A2,A1:A2,1)", "MINWENNS(A1:A2;A1:A2;1)"), + ("MINUTE(1)", "MINUTE(1)"), + ("MINVERSE(1)", "MINV(1)"), + ("MIRR(1,1,1)", "QIKV(1;1;1)"), + ("MMULT(1,1)", "MMULT(1;1)"), + ("MOD(1,1)", "REST(1;1)"), + ("MODE(1)", "MODALWERT(1)"), + ("MODE.MULT(1)", "MODUS.VIELF(1)"), + ("MODE.SNGL(1)", "MODUS.EINF(1)"), + ("MONTH(1)", "MONAT(1)"), + ("MROUND(1,1)", "VRUNDEN(1;1)"), + ("MULTINOMIAL(1)", "POLYNOMIAL(1)"), + ("MUNIT(1)", "MEINHEIT(1)"), + ("N(1)", "N(1)"), + ("NA()", "NV()"), + ("NEGBINOM.DIST(1,1,1,1)", "NEGBINOM.VERT(1;1;1;1)"), + ("NEGBINOMDIST(1,1,1)", "NEGBINOMVERT(1;1;1)"), + ("NETWORKDAYS(1,1)", "NETTOARBEITSTAGE(1;1)"), + ("NETWORKDAYS.INTL(1,1)", "NETTOARBEITSTAGE.INTL(1;1)"), + ("NOMINAL(1,1)", "NOMINAL(1;1)"), + ("NORM.DIST(1,1,1,1)", "NORM.VERT(1;1;1;1)"), + ("NORM.INV(1,1,1)", "NORM.INV(1;1;1)"), + ("NORM.S.DIST(1,1)", "NORM.S.VERT(1;1)"), + ("NORM.S.INV(1)", "NORM.S.INV(1)"), + ("NORMDIST(1,1,1,1)", "NORMVERT(1;1;1;1)"), + ("NORMINV(1,1,1)", "NORMINV(1;1;1)"), + ("NORMSDIST(1)", "STANDNORMVERT(1)"), + ("NORMSINV(1)", "STANDNORMINV(1)"), + ("NOT(1)", "NICHT(1)"), + ("NOW()", "JETZT()"), + ("NPER(1,1,1)", "ZZR(1;1;1)"), + ("NPV(1,1)", "NBW(1;1)"), + ("NUMBERVALUE(1)", "ZAHLENWERT(1)"), + ("OCT2BIN(1)", "OKTINBIN(1)"), + ("OCT2DEC(1)", "OKTINDEZ(1)"), + ("OCT2HEX(1)", "OKTINHEX(1)"), + ("ODD(1)", "UNGERADE(1)"), + ("ODDFPRICE(1,1,1,1,1,1,1,1)", "UNREGER.KURS(1;1;1;1;1;1;1;1)"), + ("ODDFYIELD(1,1,1,1,1,1,1,1)", "UNREGER.REND(1;1;1;1;1;1;1;1)"), + ("ODDLPRICE(1,1,1,1,1,1,1)", "UNREGLE.KURS(1;1;1;1;1;1;1)"), + ("ODDLYIELD(1,1,1,1,1,1,1)", "UNREGLE.REND(1;1;1;1;1;1;1)"), + ("OFFSET(A1,1,1)", "BEREICH.VERSCHIEBEN(A1;1;1)"), + ("OR(1)", "ODER(1)"), + ("PDURATION(1,1,1)", "PDURATION(1;1;1)"), + ("PEARSON(1,1)", "PEARSON(1;1)"), + ("PERCENTILE(1,1)", "QUANTIL(1;1)"), + ("PERCENTILE.EXC(1,1)", "QUANTIL.EXKL(1;1)"), + ("PERCENTILE.INC(1,1)", "QUANTIL.INKL(1;1)"), + ("PERCENTOF(1,1)", "PROZENTVON(1;1)"), + ("PERCENTRANK(1,1)", "QUANTILSRANG(1;1)"), + ("PERCENTRANK.EXC(1,1)", "QUANTILSRANG.EXKL(1;1)"), + ("PERCENTRANK.INC(1,1)", "QUANTILSRANG.INKL(1;1)"), + ("PERMUT(1,1)", "VARIATIONEN(1;1)"), + ("PERMUTATIONA(1,1)", "VARIATIONEN2(1;1)"), + ("PHI(1)", "PHI(1)"), + ("PHONETIC(A1)", "PHONETIC(A1)"), + ("PI()", "PI()"), + ("PIVOTBY(1,1,1,1)", "PIVOTMIT(1;1;1;1)"), + ("PMT(1,1,1)", "RMZ(1;1;1)"), + ("POISSON(1,1,1)", "POISSON(1;1;1)"), + ("POISSON.DIST(1,1,1)", "POISSON.VERT(1;1;1)"), + ("POWER(1,1)", "POTENZ(1;1)"), + ("PPMT(1,1,1,1)", "KAPZ(1;1;1;1)"), + ("PRICE(1,1,1,1,1,1)", "KURS(1;1;1;1;1;1)"), + ("PRICEDISC(1,1,1,1)", "KURSDISAGIO(1;1;1;1)"), + ("PRICEMAT(1,1,1,1,1)", "KURSFÄLLIG(1;1;1;1;1)"), + ("PROB(1,1,1)", "WAHRSCHBEREICH(1;1;1)"), + ("PRODUCT(1)", "PRODUKT(1)"), + ("PROPER(1)", "GROSS2(1)"), + ("PV(1,1,1)", "BW(1;1;1)"), + ("QUARTILE(1,1)", "QUARTILE(1;1)"), + ("QUARTILE.EXC(1,1)", "QUARTILE.EXKL(1;1)"), + ("QUARTILE.INC(1,1)", "QUARTILE.INKL(1;1)"), + ("QUOTIENT(1,1)", "QUOTIENT(1;1)"), + ("RADIANS(1)", "BOGENMASS(1)"), + ("RAND()", "ZUFALLSZAHL()"), + ("RANDARRAY()", "ZUFALLSMATRIX()"), + ("RANDBETWEEN(1,1)", "ZUFALLSBEREICH(1;1)"), + ("RANK(1,A1:A2)", "RANG(1;A1:A2)"), + ("RANK.AVG(1,A1:A2)", "RANG.MITTELW(1;A1:A2)"), + ("RANK.EQ(1,A1:A2)", "RANG.GLEICH(1;A1:A2)"), + ("RATE(1,1,1)", "ZINS(1;1;1)"), + ("RECEIVED(1,1,1,1)", "AUSZAHLUNG(1;1;1;1)"), + ("REDUCE(1,1,1)", "REDUCE(1;1;1)"), + ("REGEXEXTRACT(1,1)", "REGEXEXTRAHIEREN(1;1)"), + ("REGEXREPLACE(1,1,1)", "REGEXERSETZEN(1;1;1)"), + ("REGEXTEST(1,1)", "REGEXTESTEN(1;1)"), + ("REPLACE(1,1,1,1)", "ERSETZEN(1;1;1;1)"), + ("REPLACEB(1,1,1,1)", "ERSETZENB(1;1;1;1)"), + ("REPT(1,1)", "WIEDERHOLEN(1;1)"), + ("RIGHT(1)", "RECHTS(1)"), + ("RIGHTB(1)", "RECHTSB(1)"), + ("ROMAN(1)", "RÖMISCH(1)"), + ("ROUND(1,1)", "RUNDEN(1;1)"), + ("ROUNDDOWN(1,1)", "ABRUNDEN(1;1)"), + ("ROUNDUP(1,1)", "AUFRUNDEN(1;1)"), + ("ROW()", "ZEILE()"), + ("ROWS(1)", "ZEILEN(1)"), + ("RRI(1,1,1)", "ZSATZINVEST(1;1;1)"), + ("RSQ(1,1)", "BESTIMMTHEITSMASS(1;1)"), + ("SCAN(1,1,1)", "SCAN(1;1;1)"), + ("SEARCH(1,1)", "SUCHEN(1;1)"), + ("SEARCHB(1,1)", "SUCHENB(1;1)"), + ("SEC(1)", "SEC(1)"), + ("SECH(1)", "SECHYP(1)"), + ("SECOND(1)", "SEKUNDE(1)"), + ("SEQUENCE(1)", "SEQUENZ(1)"), + ("SERIESSUM(1,1,1,1)", "POTENZREIHE(1;1;1;1)"), + ("SHEET()", "BLATT()"), + ("SHEETS()", "BLÄTTER()"), + ("SIGN(1)", "VORZEICHEN(1)"), + ("SIN(1)", "SIN(1)"), + ("SINH(1)", "SINHYP(1)"), + ("SKEW(1)", "SCHIEFE(1)"), + ("SKEW.P(1)", "SCHIEFE.P(1)"), + ("SLN(1,1,1)", "LIA(1;1;1)"), + ("SLOPE(1,1)", "STEIGUNG(1;1)"), + ("SMALL(1,1)", "KKLEINSTE(1;1)"), + ("SORT(1)", "SORTIEREN(1)"), + ("SORTBY(1,1)", "SORTIERENNACH(1;1)"), + ("SQRT(1)", "WURZEL(1)"), + ("SQRTPI(1)", "WURZELPI(1)"), + ("STANDARDIZE(1,1,1)", "STANDARDISIERUNG(1;1;1)"), + ("STDEV(1)", "STABW(1)"), + ("STDEV.P(1)", "STABW.N(1)"), + ("STDEV.S(1)", "STABW.S(1)"), + ("STDEVA(1)", "STABWA(1)"), + ("STDEVP(1)", "STABWN(1)"), + ("STDEVPA(1)", "STABWNA(1)"), + ("STEYX(1,1)", "STFEHLERYX(1;1)"), + ("SUBSTITUTE(1,1,1)", "WECHSELN(1;1;1)"), + ("SUBTOTAL(1,A1:A2)", "TEILERGEBNIS(1;A1:A2)"), + ("SUM(1)", "SUMME(1)"), + ("SUMIF(A1:A2,1)", "SUMMEWENN(A1:A2;1)"), + ("SUMIFS(A1:A2,A1:A2,1)", "SUMMEWENNS(A1:A2;A1:A2;1)"), + ("SUMPRODUCT(1)", "SUMMENPRODUKT(1)"), + ("SUMSQ(1)", "QUADRATESUMME(1)"), + ("SUMX2MY2(1,1)", "SUMMEX2MY2(1;1)"), + ("SUMX2PY2(1,1)", "SUMMEX2PY2(1;1)"), + ("SUMXMY2(1,1)", "SUMMEXMY2(1;1)"), + ("SWITCH(1,1,1)", "ERSTERWERT(1;1;1)"), + ("SYD(1,1,1,1)", "DIA(1;1;1;1)"), + ("T(1)", "T(1)"), + ("T.DIST(1,1,1)", "T.VERT(1;1;1)"), + ("T.DIST.2T(1,1)", "T.VERT.2S(1;1)"), + ("T.DIST.RT(1,1)", "T.VERT.RE(1;1)"), + ("T.INV(1,1)", "T.INV(1;1)"), + ("T.INV.2T(1,1)", "T.INV.2S(1;1)"), + ("T.TEST(1,1,1,1)", "T.TEST(1;1;1;1)"), + ("TAKE(1,1)", "ÜBERNEHMEN(1;1)"), + ("TAN(1)", "TAN(1)"), + ("TANH(1)", "TANHYP(1)"), + ("TBILLEQ(1,1,1)", "TBILLÄQUIV(1;1;1)"), + ("TBILLPRICE(1,1,1)", "TBILLKURS(1;1;1)"), + ("TBILLYIELD(1,1,1)", "TBILLRENDITE(1;1;1)"), + ("TDIST(1,1,1)", "TVERT(1;1;1)"), + ("TEXT(1,1)", "TEXT(1;1)"), + ("TEXTAFTER(1,1)", "TEXTNACH(1;1)"), + ("TEXTBEFORE(1,1)", "TEXTVOR(1;1)"), + ("TEXTJOIN(1,1,1)", "TEXTVERKETTEN(1;1;1)"), + ("TEXTSPLIT(1,1)", "TEXTTEILEN(1;1)"), + ("TIME(1,1,1)", "ZEIT(1;1;1)"), + ("TIMEVALUE(1)", "ZEITWERT(1)"), + ("TINV(1,1)", "TINV(1;1)"), + ("TOCOL(1)", "ZUSPALTE(1)"), + ("TODAY()", "HEUTE()"), + ("TOROW(1)", "ZUZEILE(1)"), + ("TRANSLATE(\"a\")", "ÜBERSETZEN(\"a\")"), + ("TRANSPOSE(1)", "MTRANS(1)"), + ("TREND(1)", "TREND(1)"), + ("TRIM(1)", "GLÄTTEN(1)"), + ("TRIMMEAN(1,1)", "GESTUTZTMITTEL(1;1)"), + ("TRIMRANGE(1)", "ABSCHNBEREICH(1)"), + ("TRUE()", "WAHR()"), + ("TRUNC(1)", "KÜRZEN(1)"), + ("TTEST(1,1,1,1)", "TTEST(1;1;1;1)"), + ("TYPE(1)", "TYP(1)"), + ("UNICHAR(1)", "UNIZEICHEN(1)"), + ("UNICODE(1)", "UNICODE(1)"), + ("UNIQUE(1)", "EINDEUTIG(1)"), + ("UPPER(1)", "GROSS(1)"), + ("VALUE(1)", "WERT(1)"), + ("VALUETOTEXT(1)", "WERTZUTEXT(1)"), + ("VAR(1)", "VARIANZ(1)"), + ("VAR.P(1)", "VAR.P(1)"), + ("VAR.S(1)", "VAR.S(1)"), + ("VARA(1)", "VARIANZA(1)"), + ("VARP(1)", "VARIANZEN(1)"), + ("VARPA(1)", "VARIANZENA(1)"), + ("VDB(1,1,1,1,1)", "VDB(1;1;1;1;1)"), + ("VLOOKUP(1,1,1)", "SVERWEIS(1;1;1)"), + ("VSTACK(1)", "VSTAPELN(1)"), + ("WEBSERVICE(\"a\")", "WEBDIENST(\"a\")"), + ("WEEKDAY(1)", "WOCHENTAG(1)"), + ("WEEKNUM(1)", "KALENDERWOCHE(1)"), + ("WEIBULL(1,1,1,1)", "WEIBULL(1;1;1;1)"), + ("WEIBULL.DIST(1,1,1,1)", "WEIBULL.VERT(1;1;1;1)"), + ("WORKDAY(1,1)", "ARBEITSTAG(1;1)"), + ("WORKDAY.INTL(1,1)", "ARBEITSTAG.INTL(1;1)"), + ("WRAPCOLS(1,1)", "SPALTENUMBRUCH(1;1)"), + ("WRAPROWS(1,1)", "ZEILENUMBRUCH(1;1)"), + ("XIRR(1,1)", "XINTZINSFUSS(1;1)"), + ("XLOOKUP(1,1,1)", "XVERWEIS(1;1;1)"), + ("XMATCH(1,1)", "XVERGLEICH(1;1)"), + ("XNPV(1,1,1)", "XKAPITALWERT(1;1;1)"), + ("XOR(1)", "XODER(1)"), + ("YEAR(1)", "JAHR(1)"), + ("YEARFRAC(1,1)", "BRTEILJAHRE(1;1)"), + ("YIELD(1,1,1,1,1,1)", "RENDITE(1;1;1;1;1;1)"), + ("YIELDDISC(1,1,1,1)", "RENDITEDIS(1;1;1;1)"), + ("YIELDMAT(1,1,1,1,1)", "RENDITEFÄLL(1;1;1;1;1)"), + ("Z.TEST(1,1)", "G.TEST(1;1)"), + ("ZTEST(1,1)", "GTEST(1;1)"), + ("USDOLLAR(1,2)", "USDOLLAR(1;2)"), +]; + +/// Separators, booleans, errors, arrays, references and table specifiers, observed the same way. +const SYNTAX: &[(&str, &str)] = &[ + ("TRUE", "WAHR"), + ("FALSE", "FALSCH"), + ("1.5+2.25", "1,5+2,25"), + ("SUM(1,2)", "SUMME(1;2)"), + ("{1,2,3}", "{1.2.3}"), + ("{1;2;3}", "{1;2;3}"), + ("{1,2;3,4}", "{1.2;3.4}"), + ("#NULL!", "#NULL!"), + ("#DIV/0!", "#DIV/0!"), + ("#VALUE!", "#WERT!"), + ("#REF!", "#BEZUG!"), + ("#NAME?", "#NAME?"), + ("#NUM!", "#ZAHL!"), + ("#N/A", "#NV"), + ("#GETTING_DATA", "#DATEN_ABRUFEN"), + ("SUM(A1:A2,B1:B2)", "SUMME(A1:A2;B1:B2)"), + ("SUM(A1:B2 B1:C2)", "SUMME(A1:B2 B1:C2)"), + ("ROW()", "ZEILE()"), + ("50%", "50%"), + ("\"a,b;c\"", "\"a,b;c\""), + ("ROWS(Tbl[#All])", "ZEILEN(Tbl[#Alle])"), + ("ROWS(Tbl[#Data])", "ZEILEN(Tbl[#Daten])"), + ("ROWS(Tbl[#Headers])", "ZEILEN(Tbl[#Kopfzeilen])"), + ("ROWS(Tbl[#Totals])", "ZEILEN(Tbl[#Ergebnisse])"), + ("Tbl[[#This Row],[H1]]", "Tbl[@H1]"), + ("ROWS(Tbl[#This Row])", "ZEILEN(Tbl[@])"), + ("SUM(Tbl[[#This Row],[H1]:[H2]])", "SUMME(Tbl[@[H1]:[H2]])"), + ("SUM(Tbl[H1])", "SUMME(Tbl[H1])"), + ("ROWS(Tbl[[#Headers],[H1]])", "ZEILEN(Tbl[[#Kopfzeilen];[H1]])"), + ("ROWS(Tbl[[#Data],[#Totals]])", "ZEILEN(Tbl[[#Daten];[#Ergebnisse]])"), + ("ROWS(Tbl[[#All],[H1]:[H2]])", "ZEILEN(Tbl[[#Alle];[H1]:[H2]])"), + ("ROWS(Tbl[[#Headers],[#Data]])", "ZEILEN(Tbl[[#Kopfzeilen];[#Daten]])"), + ("SUM(Tbl[[H1]:[H2]])", "SUMME(Tbl[[H1]:[H2]])"), + ("_xlfn.XLOOKUP(1,{1},{1})", "XVERWEIS(1;{1};{1})"), + ("BYROW({1,2},SUM)", "NACHZEILE({1.2};SUMME)"), + ("LET(x,1,x)", "LET(x;1;x)"), + ("SUM(1.5,{1,2;3,4})", "SUMME(1,5;{1.2;3.4})"), + ("IF(TRUE,1,#N/A)", "WENN(WAHR;1;#NV)"), + ("FILTER({1;2},{FALSE;FALSE})", "FILTER({1;2};{FALSCH;FALSCH})"), + ("ERROR.TYPE(FILTER({1;2},{FALSE;FALSE}))", "FEHLER.TYP(FILTER({1;2};{FALSCH;FALSCH}))"), + ("LAMBDA(x,x)", "LAMBDA(x;x)"), + ("INDIRECT(\"Z1S1\",FALSE)", "INDIREKT(\"Z1S1\";FALSCH)"), +]; + +fn canonical(german: &str) -> String { + print(&parse_input(german, De, |_| false).unwrap_or_else(|e| panic!("`{german}`: {e}"))) +} + +fn german(canonical: &str) -> String { + print_input(&parse(canonical).unwrap_or_else(|e| panic!("`{canonical}`: {e}")), De, |_| false).unwrap() +} + +#[test] +fn every_function_is_spelled_as_german_excel_shows_it() { + let mut wrong = Vec::new(); + for (entered, shown) in FUNCTIONS.iter().chain(SYNTAX) { + let ours = german(entered); + if ours != *shown { + wrong.push(format!("{entered}: shown {ours} (Excel: {shown})")); + } + } + assert!(wrong.is_empty(), "{} differ from Excel:\n{}", wrong.len(), wrong.join("\n")); +} + +#[test] +fn what_german_excel_shows_reads_back_as_the_formula() { + for (entered, shown) in FUNCTIONS.iter().chain(SYNTAX) { + assert_eq!(canonical(shown), print(&parse(entered).unwrap()), "{shown}"); + } +} + +#[test] +fn the_observation_covers_every_function_the_table_lists() { + // Every table entry was observed; a name added to the table without an observation fails here. + for (en, local) in De.translations() { + let call = FUNCTIONS.iter().find(|(entered, _)| entered.strip_prefix(en).is_some_and(|rest| rest.starts_with('('))); + let (_, shown) = call.unwrap_or_else(|| panic!("{en} was not observed")); + assert!(shown.starts_with(&format!("{local}(")), "{en}: table {local}, Excel {shown}"); + } + assert_eq!(FUNCTIONS.len(), 518); +} + +#[test] +fn typed_german_also_accepts_the_long_this_row_specifier_and_lower_case() { + // Observed: `[#Diese Zeile]` is accepted when typed, and shown as `@`. + assert_eq!(canonical("Tbl[[#Diese Zeile];[H1]]"), "Tbl[@H1]"); + assert_eq!(canonical("ZEILEN(Tbl[#Diese Zeile])"), "ROWS(Tbl[@])"); + assert_eq!(canonical("summe(tbl[[#kopfzeilen];[h1]])"), "SUM(tbl[[#Headers],[h1]])"); + assert_eq!(canonical("wenn(wahr;1;#nv)"), "IF(TRUE,1,#N/A)"); + // GridCraft also reads the canonical #SPILL! and #CALC!, and German Excel's #ÜBERLAUF!. + assert_eq!(canonical("ISTFEHLER(#ÜBERLAUF!)"), "ISERROR(#SPILL!)"); + assert_eq!(german("ISERROR(#SPILL!)+ISERROR(#CALC!)"), "ISTFEHLER(#ÜBERLAUF!)+ISTFEHLER(#CALC!)"); +} + +#[test] +fn german_input_never_panics() { + // German-looking fragments joined at random: the parser answers, it never panics. + let pieces: Vec<&str> = FUNCTIONS + .iter() + .map(|(_, shown)| *shown) + .chain([ + "(", + ")", + ";", + ",", + ".", + "{", + "}", + "[", + "]", + "#", + "#NV", + "#WERT", + "WAHR", + "\"", + "'", + "@", + ":", + "!", + "1,5", + "1.", + ".:", + "€", + "Ü", + "%", + " ", + "Tbl[[#Kopfzeilen];", + "[#Diese Zeile]", + ]) + .collect(); + let mut seed: u64 = 0x5eed_de_de; + let mut next = |n: usize| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + (seed % n.max(1) as u64) as usize + }; + for _ in 0..4000 { + let len = next(8) + 1; + let src: String = (0..len).map(|_| pieces[next(pieces.len())]).collect(); + let _ = parse_input(&src, De, |_| false); + if let Ok(expr) = parse(&src) { + let _ = print_input(&expr, De, |_| false); + } + let _ = FormulaLocale::De.canonical_name(&src); + } +} From 3a996b9fa8eadcd5a66f49a98a4f5d979107ef9e Mon Sep 17 00:00:00 2001 From: Herr-Enklo <168219178+Herr-Enklo@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:45:16 +0200 Subject: [PATCH 3/7] Numbers: regional formats for display and typed input (de-DE) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit numfmt was en-US only. A small NumberLocale (crates/core/src/locale.rs) now travels from the interface to where values are drawn or typed: a Region with its fixed conventions (date order and separator, month and weekday names, how built-in formats look) plus the decimal and thousands separators, which the user may override. de-DE was observed in Excel 365 with the German regional format on 2026-10-13 through its automation interface (typed entries read back with value and automatic format; values drawn through each format code); every other region stays en-US until observed. - numfmt: format_value_in draws `1.234,56`, `1234,56` (General), `13.10.2026` for id 14, `13. Okt 26`, `Okt 26`, `hh:mm` (ids 15-17, 20-22), German month and day names (`Mrz`, `Di`), `/` in date codes as the region's date separator, `[$-409]`/`[$-407]` tags choosing the names, and the system Long Date/Time tags. Codes stay canonical, TEXT() stays en-US, format_value is unchanged. - core: parse_input_in reads `1,5`, `1.234,5`, `-3,2%`, `1,5E+3`, `5 €`, `13.10.2026`, `13.10.26`, `13.10` (this year), `1.13` (January 2013), `13. Okt 2026`, `Okt 26`, `13.10.2026 14:30` as German Excel does, and keeps `13.10.`, `1.00`, `$5` as text. - engine: DisplayLocale (number locale + formula language) with format_in, value_text_in, cell_text_in, edit_text_in and formula_text_in; Session.display_locale (set by the interface, canonical for CLI/MCP) is what PDF/print and Save As CSV/TSV/Web Page use, so a print shows what the grid shows. cell.set and range.fill take an optional autoFormat: the interface parses German input itself and enters the canonical value with the format German typing picks. - CSV: in a session whose decimal separator is a comma, a new CSV is separated by `;` (Excel's list separator there), values are written as displayed (`1234,5;WAHR`), and opening prefers `;` and reads German numbers, dates, booleans and errors. A comma-separated file is read as en-US whoever opens it; workbooks never change. Co-Authored-By: Claude Opus 5.5 --- crates/core/src/lib.rs | 2 + crates/core/src/locale.rs | 208 ++++++++++++ crates/core/src/parse.rs | 462 ++++++++++++++++++++++++++- crates/engine/src/cmd/edit.rs | 38 ++- crates/engine/src/cmd/file.rs | 11 +- crates/engine/src/cmd/print.rs | 47 ++- crates/engine/src/display.rs | 113 ++++++- crates/engine/src/io.rs | 62 +++- crates/engine/src/lib.rs | 5 + crates/engine/tests/german_locale.rs | 179 +++++++++++ crates/numfmt/src/lib.rs | 49 ++- crates/numfmt/src/parse.rs | 33 +- crates/numfmt/src/render.rs | 85 +++-- crates/numfmt/src/tests.rs | 117 ++++++- crates/xlsx/src/csv.rs | 43 ++- crates/xlsx/src/lib.rs | 14 +- crates/xlsx/src/tests/csv_tests.rs | 4 + 17 files changed, 1378 insertions(+), 94 deletions(-) create mode 100644 crates/core/src/locale.rs create mode 100644 crates/engine/tests/german_locale.rs diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index d5adbc07ed..ff82378c62 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -5,9 +5,11 @@ pub mod addr; pub mod date; +pub mod locale; pub mod parse; pub mod value; pub use addr::{CellRef, MAX_COLS, MAX_ROWS, RangeRef, col_to_letters, letters_to_col}; pub use date::DateSystem; +pub use locale::NumberLocale; pub use value::{Array, CellError, Value, compare, compare_numbers, compare_text, number_to_text, sort_compare}; diff --git a/crates/core/src/locale.rs b/crates/core/src/locale.rs new file mode 100644 index 0000000000..e9d22820d4 --- /dev/null +++ b/crates/core/src/locale.rs @@ -0,0 +1,208 @@ +//! Regional number formats: how numbers and dates look, and how they are typed, in one region. +//! +//! Workbooks, formulas, files and commands keep one canonical (en-US) spelling. A +//! [`NumberLocale`] only applies where people read or type values: the cells on screen, the cell +//! editor, print, and delimited text written for people. It is the region's fixed conventions +//! ([`Region`]: date order, month and day names, the built-in formats Excel draws in the region's +//! own way) plus the decimal and thousands separators, which the user may override (Excel's +//! File › Options › Advanced › "Use system separators"). +//! +//! Regions are data. de-DE was observed black-box in Excel 365 (German interface, de-DE regional +//! format) on 2026-10-13 through its automation interface: typed entries read back with their +//! value and automatic format, and values shown through format codes. Other regions use en-US +//! until their conventions are observed. + +/// The order of day, month and year in a short date. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DateOrder { + /// `10/13/2026` (en-US). + Mdy, + /// `13.10.2026` (de-DE). + Dmy, +} + +/// The fixed conventions of one regional format. +#[derive(Debug, PartialEq, Eq)] +pub struct Region { + /// BCP 47 tag, e.g. `de-DE`. + pub tag: &'static str, + /// The separators the region uses ("Use system separators"). + pub decimal: char, + pub thousands: char, + pub date_order: DateOrder, + /// What `/` in a date format code shows, and what typed dates are separated by. + pub date_separator: char, + pub months: [&'static str; 12], + pub months_short: [&'static str; 12], + /// Sunday first. + pub weekdays: [&'static str; 7], + pub weekdays_short: [&'static str; 7], + /// More month spellings typed dates accept (`Mär`, `Sept`), besides the names above. + pub month_aliases: &'static [(&'static str, u32)], + /// Built-in date and time formats the region draws its own way: `(canonical code, code used + /// to draw it)`. Excel stores id 14 as `m/d/yyyy` and shows it as the system short date. + pub builtin_formats: &'static [(&'static str, &'static str)], + /// What the system long date and time tags (`[$-F800]`, `[$-F400]`, Excel's Long Date and + /// Time formats) draw; `None` draws the code after the tag. + pub long_date: Option<&'static str>, + pub long_time: Option<&'static str>, + /// A currency symbol typed after the number (`5 €`), with the formats Excel then applies + /// (whole, two decimals). + pub currency_after: Option<(&'static str, &'static str, &'static str)>, +} + +pub const EN_US: Region = Region { + tag: "en-US", + decimal: '.', + thousands: ',', + date_order: DateOrder::Mdy, + date_separator: '/', + months: ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"], + months_short: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"], + weekdays: ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"], + weekdays_short: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"], + month_aliases: &[], + builtin_formats: &[], + long_date: None, + long_time: None, + currency_after: None, +}; + +/// Deutsch (Deutschland), as observed in Excel 365 with the de-DE regional format. +pub const DE_DE: Region = Region { + tag: "de-DE", + decimal: ',', + thousands: '.', + date_order: DateOrder::Dmy, + date_separator: '.', + months: ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"], + months_short: ["Jan", "Feb", "Mrz", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"], + weekdays: ["Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag"], + weekdays_short: ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"], + month_aliases: &[("Mär", 3), ("Sept", 9)], + // Observed: 14 `TT.MM.JJJJ`, 15 `TT. MMM JJ`, 16 `TT. MMM`, 17 `MMM JJ`, 20 `hh:mm`, + // 21 `hh:mm:ss`, 22 `TT.MM.JJJJ hh:mm`; the AM/PM and elapsed-time ones are unchanged. + builtin_formats: &[ + ("m/d/yyyy", "dd/mm/yyyy"), + ("d-mmm-yy", "dd\\.\\ mmm\\ yy"), + ("d-mmm", "dd\\.\\ mmm"), + ("mmm-yy", "mmm\\ yy"), + ("h:mm", "hh:mm"), + ("h:mm:ss", "hh:mm:ss"), + ("m/d/yyyy h:mm", "dd/mm/yyyy hh:mm"), + ], + // Observed: `[$-F800]dddd, mmmm dd, yyyy` shows `Dienstag, 13. Oktober 2026`; `[$-F400]h:mm:ss + // AM/PM` shows `14:30:00`. + long_date: Some("dddd, d\\. mmmm yyyy"), + long_time: Some("hh:mm:ss"), + // Observed: `5 €` gets `#.##0 €;[Rot]-#.##0 €`, `5,50 €` gets `#.##0,00 €;[Rot]-#.##0,00 €`. + currency_after: Some(("€", "#,##0 \"€\";[Red]-#,##0 \"€\"", "#,##0.00 \"€\";[Red]-#,##0.00 \"€\"")), +}; + +/// How numbers and dates are shown and typed: a region's conventions and the separators in use. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct NumberLocale { + pub region: &'static Region, + pub decimal: char, + pub thousands: char, +} + +impl Default for NumberLocale { + fn default() -> Self { + NumberLocale::EN_US + } +} + +impl NumberLocale { + /// The canonical locale: what files, formulas and commands use. + pub const EN_US: NumberLocale = NumberLocale::of(&EN_US); + pub const DE_DE: NumberLocale = NumberLocale::of(&DE_DE); + + /// A region with its own separators. + pub const fn of(region: &'static Region) -> NumberLocale { + NumberLocale { region, decimal: region.decimal, thousands: region.thousands } + } + + /// The same region with other separators. An unusable pair ([`separators_usable`]) keeps + /// the region's. + pub fn with_separators(self, decimal: char, thousands: char) -> NumberLocale { + if separators_usable(decimal, thousands) { NumberLocale { decimal, thousands, ..self } } else { self } + } + + /// True when shown and typed values use the canonical spelling (nothing to translate). + pub fn is_canonical(&self) -> bool { + *self == NumberLocale::EN_US + } + + /// The code a built-in format is drawn with in this region (`m/d/yyyy` → `dd/mm/yyyy` in + /// de-DE); the code itself otherwise. + pub fn regional_code<'a>(&self, code: &'a str) -> &'a str { + let tag = |t: &str| code.get(..t.len()).is_some_and(|p| p.eq_ignore_ascii_case(t)); + if tag("[$-F800]") + && let Some(long) = self.region.long_date + { + return long; + } + if tag("[$-F400]") + && let Some(long) = self.region.long_time + { + return long; + } + self.region.builtin_formats.iter().find(|(canonical, _)| *canonical == code).map_or(code, |(_, shown)| shown) + } +} + +/// Whether a decimal and thousands separator pair can be used: different characters, neither a +/// digit, a letter or a character with another meaning in typed numbers and dates (signs, `%`, +/// parentheses, quotes, `/`, `:`, `#`, `$`, `;`, control characters). The thousands separator may +/// be a space, a no-break space or an apostrophe (`1'234.5`); the decimal separator may not. +pub fn separators_usable(decimal: char, thousands: char) -> bool { + let bad = |c: char| { + c.is_alphanumeric() || c.is_control() || matches!(c, '+' | '-' | '%' | '(' | ')' | '"' | '#' | '$' | '/' | ':' | '@' | '[' | ']' | ';') + }; + let space = |c: char| c.is_whitespace(); + decimal != thousands + && !bad(decimal) + && !space(decimal) + && !matches!(decimal, '\'' | '\u{2019}') + && !bad(thousands) + && (!space(thousands) || matches!(thousands, ' ' | '\u{a0}' | '\u{202f}')) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_en_us_is_canonical() { + assert!(NumberLocale::EN_US.is_canonical()); + assert!(NumberLocale::default().is_canonical()); + assert!(!NumberLocale::DE_DE.is_canonical()); + // en-US conventions with other separators are not canonical either. + assert!(!NumberLocale::EN_US.with_separators(',', '.').is_canonical()); + } + + #[test] + fn separators_are_checked() { + let de = NumberLocale::DE_DE; + assert_eq!(de.with_separators('.', ',').decimal, '.'); + assert_eq!(de.with_separators(',', ' ').thousands, ' '); + assert_eq!(de.with_separators(',', '\u{a0}').thousands, '\u{a0}'); + assert_eq!(de.with_separators('.', '\'').thousands, '\''); + for (d, t) in [(',', ','), ('1', '.'), (',', 'a'), (' ', '.'), ('-', '.'), (',', '%'), ('E', '.'), ('\'', '.'), (',', '\n'), (',', ';')] { + assert_eq!(de.with_separators(d, t), de, "{d:?} {t:?}"); + } + } + + #[test] + fn built_in_formats_follow_the_region() { + assert_eq!(NumberLocale::DE_DE.regional_code("m/d/yyyy"), "dd/mm/yyyy"); + assert_eq!(NumberLocale::DE_DE.regional_code("h:mm AM/PM"), "h:mm AM/PM"); + assert_eq!(NumberLocale::DE_DE.regional_code("0.00"), "0.00"); + assert_eq!(NumberLocale::EN_US.regional_code("m/d/yyyy"), "m/d/yyyy"); + // Excel's Long Date and Time formats carry the system tags. + assert_eq!(NumberLocale::DE_DE.regional_code("[$-F800]dddd, mmmm dd, yyyy"), "dddd, d\\. mmmm yyyy"); + assert_eq!(NumberLocale::DE_DE.regional_code("[$-f400]h:mm:ss AM/PM"), "hh:mm:ss"); + assert_eq!(NumberLocale::EN_US.regional_code("[$-F800]dddd, mmmm dd, yyyy"), "[$-F800]dddd, mmmm dd, yyyy"); + } +} diff --git a/crates/core/src/parse.rs b/crates/core/src/parse.rs index b465beaf9e..e26f3c4144 100644 --- a/crates/core/src/parse.rs +++ b/crates/core/src/parse.rs @@ -2,9 +2,11 @@ //! //! Recognises numbers with thousands separators, percentages, currency, scientific notation, //! fractions (`1 1/2`), dates (`3/4/2026`, `2026-03-04`, `4-Mar-2026`, `Mar 4, 2026`), times -//! (`10:30`, `10:30:15 PM`) and date-times. Locale: en-US. +//! (`10:30`, `10:30:15 PM`) and date-times. [`parse_input`] reads en-US, the canonical spelling; +//! [`parse_input_in`] reads a regional format ([`NumberLocale`]). use crate::date::{DateSystem, MONTHS, serial_from_ymd, time_fraction}; +use crate::locale::{DateOrder, NumberLocale}; use crate::value::{CellError, Value}; /// A parsed entry: its value and the number format Excel would apply automatically. @@ -39,6 +41,287 @@ pub fn parse_input(s: &str, sys: DateSystem) -> Parsed { Parsed { value: Value::text(s), format: None } } +/// Interprets typed cell input in a regional format: `1,5`, `1.234,5`, `-3,2%`, `13.10.2026`, +/// `13. Okt 2026`, `5 €` in de-DE. Booleans and error values are read in their canonical +/// spelling (the formula language's words are the caller's business). The automatic formats are +/// canonical codes, as [`parse_input`] gives them. The canonical locale reads as [`parse_input`]. +pub fn parse_input_in(s: &str, sys: DateSystem, loc: &NumberLocale) -> Parsed { + if loc.is_canonical() { + return parse_input(s, sys); + } + if s.is_empty() { + return Parsed { value: Value::Empty, format: None }; + } + if let Some(rest) = s.strip_prefix('\'') { + return Parsed { value: Value::text(rest), format: None }; + } + let t = s.trim(); + if t.eq_ignore_ascii_case("TRUE") { + return Parsed { value: Value::Bool(true), format: None }; + } + if t.eq_ignore_ascii_case("FALSE") { + return Parsed { value: Value::Bool(false), format: None }; + } + if let Some(e) = CellError::parse(t) { + return Parsed { value: Value::Error(e), format: None }; + } + if let Some((n, f)) = parse_numeric_in(t, sys, loc) { + return Parsed { value: Value::number(n), format: f }; + } + Parsed { value: Value::text(s), format: None } +} + +/// Typed text longer than this is never a number or a date (keeps the scans below short). +const MAX_NUMERIC_INPUT: usize = 400; + +fn parse_numeric_in(t: &str, sys: DateSystem, loc: &NumberLocale) -> Option<(f64, Option<&'static str>)> { + if t.is_empty() || t.len() > MAX_NUMERIC_INPUT { + return None; + } + // Parenthesised negatives: (1,5) = -1,5. + if let Some(inner) = t.strip_prefix('(').and_then(|x| x.strip_suffix(')')) { + return parse_numeric_in(inner.trim(), sys, loc).map(|(n, f)| (-n, f)); + } + if let Some(r) = parse_plain_in(t, loc) { + return Some(r); + } + if let Some(r) = parse_fraction(t) { + return Some((r, Some("# ?/?"))); + } + parse_datetime_in(t, sys, loc) +} + +/// Signs, a currency symbol, thousands separators, the decimal separator, percent and exponent, +/// with the locale's separators. The automatic formats are the canonical ones. +fn parse_plain_in(t: &str, loc: &NumberLocale) -> Option<(f64, Option<&'static str>)> { + let mut s = t; + let mut neg = false; + let mut currency = None; + let mut percent = false; + if let Some(r) = s.strip_prefix('-') { + neg = true; + s = r.trim_start(); + } else if let Some(r) = s.strip_prefix('+') { + s = r.trim_start(); + } + let region = loc.region; + if let Some((symbol, whole, decimals)) = region.currency_after + && let Some(r) = s.strip_suffix(symbol) + { + currency = Some((whole, decimals)); + s = r.trim_end(); + } else if let Some((symbol, ..)) = region.currency_after + && let Some(r) = s.strip_prefix(symbol) + { + // Observed in de-DE: `€5` is the number 5, without a currency format. + s = r.trim_start(); + } else if region.tag == crate::locale::EN_US.tag + && let Some(r) = s.strip_prefix('$') + { + currency = Some(("\"$\"#,##0", "\"$\"#,##0.00")); + s = r.trim_start(); + } + if currency.is_none() + && let Some(r) = s.strip_suffix('%') + { + percent = true; + s = r.trim_end(); + } + if s.is_empty() || !s.starts_with(|c: char| c.is_ascii_digit() || c == loc.decimal) { + return None; + } + // Mantissa and exponent. + let (mantissa, exp) = match s.find(['e', 'E']) { + Some(i) => (s.get(..i)?, Some(s.get(i + 1..)?)), + None => (s, None), + }; + if let Some(e) = exp { + let digits = e.strip_prefix(['+', '-']).unwrap_or(e); + if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + } + let (int, frac) = match mantissa.split_once(loc.decimal) { + Some((a, b)) => (a, Some(b)), + None => (mantissa, None), + }; + if frac.is_some_and(|f| !f.bytes().all(|b| b.is_ascii_digit())) { + return None; + } + let grouped = int.contains(loc.thousands); + let mut digits = String::with_capacity(s.len()); + if grouped { + // Thousands separators sit between groups of three, before the decimal separator. + let mut groups = int.split(loc.thousands); + let first = groups.next()?; + if first.is_empty() || first.len() > 3 || !first.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + digits.push_str(first); + for g in groups { + if g.len() != 3 || !g.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + digits.push_str(g); + } + } else { + if !int.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + digits.push_str(int); + } + if digits.is_empty() && frac.is_none_or(str::is_empty) { + return None; + } + let has_decimals = frac.is_some(); + if let Some(f) = frac { + digits.push('.'); + digits.push_str(f); + } + if digits.starts_with('.') { + digits.insert(0, '0'); + } + if digits.ends_with('.') { + digits.push('0'); + } + if let Some(e) = exp { + digits.push('e'); + digits.push_str(e); + } + let mut n: f64 = digits.parse().ok()?; + if !n.is_finite() { + return None; + } + if percent { + n /= 100.0; + } + if neg { + n = -n; + } + let fmt = if let Some((whole, decimals)) = currency { + Some(if has_decimals { decimals } else { whole }) + } else if percent { + Some(if has_decimals { "0.00%" } else { "0%" }) + } else if exp.is_some() { + Some("0.00E+00") + } else if grouped { + Some(if has_decimals { "#,##0.00" } else { "#,##0" }) + } else { + None + }; + Some((n, fmt)) +} + +/// A month name of the region (full, short or an alias such as `Mär`), case-insensitively; a +/// trailing dot (`Okt.`) is allowed. +fn month_from_name_in(s: &str, loc: &NumberLocale) -> Option { + let s = s.strip_suffix('.').unwrap_or(s).trim(); + if s.is_empty() { + return None; + } + let eq = |a: &str| a.chars().flat_map(char::to_lowercase).eq(s.chars().flat_map(char::to_lowercase)); + let region = loc.region; + (region.months.iter().position(|m| eq(m))) + .or_else(|| region.months_short.iter().position(|m| eq(m))) + .map(|i| i as u32 + 1) + .or_else(|| region.month_aliases.iter().find(|(a, _)| eq(a)).map(|(_, m)| *m)) +} + +/// Excel's two-digit years: 00–29 are 2000–2029, 30–99 are 1930–1999. +fn expand_year(text: &str, y: i64) -> i64 { + if text.len() <= 2 { if y < 30 { 2000 + y } else { 1900 + y } } else { y } +} + +/// A date typed in the locale's order (`13.10.2026`, `13.10.26`, `13/10/2026`, `2026-10-13`, +/// `13.10` this year, `1.13` January 2013, `13. Okt 2026`, `Okt 26`, `13. Okt`). Observed in +/// de-DE: a trailing separator with no year (`13.10.`) is text, ISO dates get the short date format. +fn parse_date_in(t: &str, sys: DateSystem, loc: &NumberLocale) -> Option<(f64, &'static str)> { + let t = t.trim(); + let num = |s: &str| if !s.is_empty() && s.len() <= 5 && s.bytes().all(|b| b.is_ascii_digit()) { s.parse::().ok() } else { None }; + if t.chars().any(char::is_alphabetic) { + // Month names: `13. Okt 2026`, `13-Okt-26`, `Okt 26`, `13. Okt`. + let words: Vec<&str> = t.split([' ', '.', '-', '/', ',']).filter(|w| !w.is_empty()).collect(); + return match words.as_slice() { + [d, m, y] => { + let (d, m, y) = (num(d)?, month_from_name_in(m, loc)?, (num(y)?, *y)); + valid_ymd(sys, expand_year(y.1, y.0), m as i64, d).map(|s| (s, "d-mmm-yy")) + } + [a, b] => { + if let (Some(d), Some(m)) = (num(a), month_from_name_in(b, loc)) { + valid_ymd(sys, current_year(), m as i64, d).map(|s| (s, "d-mmm")) + } else { + let (m, y) = (month_from_name_in(a, loc)?, num(b)?); + valid_ymd(sys, expand_year(b, y), m as i64, 1).map(|s| (s, "mmm-yy")) + } + } + _ => None, + }; + } + let parts: Vec<&str> = t.split([loc.region.date_separator, '/', '-', '.']).map(str::trim).collect(); + match parts.as_slice() { + [a, b, c] => { + if a.len() == 4 { + let (y, m, d) = (num(a)?, num(b)?, num(c)?); + return valid_ymd(sys, y, m, d).map(|s| (s, "m/d/yyyy")); + } + let (x, y_, year) = (num(a)?, num(b)?, num(c)?); + let (m, d) = match loc.region.date_order { + DateOrder::Dmy => (y_, x), + DateOrder::Mdy => (x, y_), + }; + valid_ymd(sys, expand_year(c, year), m, d).map(|s| (s, "m/d/yyyy")) + } + [a, b] => { + let (x, y_) = (num(a)?, num(b)?); + let (m, d) = match loc.region.date_order { + DateOrder::Dmy => (y_, x), + DateOrder::Mdy => (x, y_), + }; + if (1..=12).contains(&m) + && let Some(s) = valid_ymd(sys, current_year(), m, d) + { + return Some((s, "d-mmm")); + } + // Month and a two-digit year that can't be a month (`1.13` = January 2013; observed: + // `1.00` is text), or a four-digit year after `/` (`10/2026`). + let (m, y) = (x, y_); + let year_ok = (b.len() == 2 && y > 12) || (b.len() == 4 && t.contains('/')); + if year_ok && (1..=12).contains(&m) { + return valid_ymd(sys, expand_year(b, y), m, 1).map(|s| (s, "mmm-yy")); + } + None + } + _ => None, + } +} + +fn parse_datetime_in(t: &str, sys: DateSystem, loc: &NumberLocale) -> Option<(f64, Option<&'static str>)> { + if let Some((tf, secs, ampm)) = parse_time(t) { + let f = match (secs, ampm) { + _ if tf >= 1.0 => "[h]:mm:ss", + (false, false) => "h:mm", + (true, false) => "h:mm:ss", + (false, true) => "h:mm AM/PM", + (true, true) => "h:mm:ss AM/PM", + }; + return Some((tf, Some(f))); + } + if let Some((d, f)) = parse_date_in(t, sys, loc) { + return Some((d, Some(f))); + } + // Date and time: split at a space before a time-looking tail (`13.10.2026 14:30`, + // `13. Okt 2026 14:30`, `13.10.2026 2:30 PM`). + for (i, _) in t.match_indices(' ').take(16) { + let (a, b) = (t.get(..i)?, t.get(i + 1..)?); + if let (Some((d, _)), Some((tf, _, _))) = (parse_date_in(a, sys, loc), parse_time(b)) + && tf < 1.0 + { + return Some((d + tf, Some("m/d/yyyy h:mm"))); + } + } + None +} + /// Numeric text as arithmetic coerces it ("1,000", "50%", "$3", "1/2/2020", "10:00"). pub fn parse_number_text(s: &str) -> Option { parse_number_text_in(s, DateSystem::D1900) @@ -384,6 +667,183 @@ mod tests { assert_eq!(p("23:59").format, Some("h:mm")); } + /// What Excel 365 (de-DE regional format) made of typed entries, observed through its + /// automation interface on 2026-10-13: value and automatic format (canonical code). + #[test] + fn german_entries_read_as_excel_reads_them() { + let de = NumberLocale::DE_DE; + let year = current_year(); + let date = |y: i64, m: i64, d: i64| serial_from_ymd(DateSystem::D1900, y, m, d).unwrap(); + let num = |s: &str| parse_input_in(s, DateSystem::D1900, &de); + for (input, value, format) in [ + ("1,5", 1.5, None), + ("1.234,5", 1234.5, Some("#,##0.00")), + ("1.234", 1234.0, Some("#,##0")), + ("1,234", 1.234, None), + ("-3,2%", -0.032, Some("0.00%")), + ("12%", 0.12, Some("0%")), + ("12,5 %", 0.125, Some("0.00%")), + ("0,5%", 0.005, Some("0.00%")), + ("1,5E+3", 1500.0, Some("0.00E+00")), + ("1,5e3", 1500.0, Some("0.00E+00")), + ("1E3", 1000.0, Some("0.00E+00")), + ("1,23E-5", 1.23e-5, Some("0.00E+00")), + ("1.234.567,89", 1234567.89, Some("#,##0.00")), + ("-1.234,5", -1234.5, Some("#,##0.00")), + ("1.234,", 1234.0, Some("#,##0.00")), + ("1,", 1.0, None), + ("0,5", 0.5, None), + (",5", 0.5, None), + ("+1,5", 1.5, None), + ("- 1,5", -1.5, None), + ("(100)", -100.0, None), + ("(1,5)", -1.5, None), + ("001,5", 1.5, None), + ("1.000.000", 1_000_000.0, Some("#,##0")), + ("2026", 2026.0, None), + ("1 1/2", 1.5, Some("# ?/?")), + ("5 €", 5.0, Some("#,##0 \"€\";[Red]-#,##0 \"€\"")), + ("5€", 5.0, Some("#,##0 \"€\";[Red]-#,##0 \"€\"")), + ("-5 €", -5.0, Some("#,##0 \"€\";[Red]-#,##0 \"€\"")), + ("5,50 €", 5.5, Some("#,##0.00 \"€\";[Red]-#,##0.00 \"€\"")), + ("1.234,56 €", 1234.56, Some("#,##0.00 \"€\";[Red]-#,##0.00 \"€\"")), + ("€5", 5.0, None), + ("13.10.2026", date(2026, 10, 13), Some("m/d/yyyy")), + ("13.10.26", date(2026, 10, 13), Some("m/d/yyyy")), + ("1.5.26", date(2026, 5, 1), Some("m/d/yyyy")), + ("01.05.2026", date(2026, 5, 1), Some("m/d/yyyy")), + ("13.10.29", date(2029, 10, 13), Some("m/d/yyyy")), + ("13.10.30", date(1930, 10, 13), Some("m/d/yyyy")), + ("13.10.0", date(2000, 10, 13), Some("m/d/yyyy")), + ("1.2.3", date(2003, 2, 1), Some("m/d/yyyy")), + ("13/10/2026", date(2026, 10, 13), Some("m/d/yyyy")), + ("13-10-2026", date(2026, 10, 13), Some("m/d/yyyy")), + ("3/4/2026", date(2026, 4, 3), Some("m/d/yyyy")), + ("13. 10. 2026", date(2026, 10, 13), Some("m/d/yyyy")), + ("2026-10-13", date(2026, 10, 13), Some("m/d/yyyy")), + ("2026/10/13", date(2026, 10, 13), Some("m/d/yyyy")), + ("2026.10.13", date(2026, 10, 13), Some("m/d/yyyy")), + ("29.2.1900", 60.0, Some("m/d/yyyy")), + ("13.10", date(year, 10, 13), Some("d-mmm")), + ("1.5", date(year, 5, 1), Some("d-mmm")), + ("31.12", date(year, 12, 31), Some("d-mmm")), + ("1.13", date(2013, 1, 1), Some("mmm-yy")), + ("12.13", date(2013, 12, 1), Some("mmm-yy")), + ("10/2026", date(2026, 10, 1), Some("mmm-yy")), + ("13-Okt-2026", date(2026, 10, 13), Some("d-mmm-yy")), + ("13. Okt 2026", date(2026, 10, 13), Some("d-mmm-yy")), + ("13. Oktober 2026", date(2026, 10, 13), Some("d-mmm-yy")), + ("13. Okt. 2026", date(2026, 10, 13), Some("d-mmm-yy")), + ("13. März 2026", date(2026, 3, 13), Some("d-mmm-yy")), + ("13. Mär 2026", date(2026, 3, 13), Some("d-mmm-yy")), + ("13-Mrz-2026", date(2026, 3, 13), Some("d-mmm-yy")), + ("3. Juni 2026", date(2026, 6, 3), Some("d-mmm-yy")), + ("Okt 26", date(2026, 10, 1), Some("mmm-yy")), + ("Okt. 2026", date(2026, 10, 1), Some("mmm-yy")), + ("Oktober 2026", date(2026, 10, 1), Some("mmm-yy")), + ("Sept 26", date(2026, 9, 1), Some("mmm-yy")), + ("Mär 26", date(2026, 3, 1), Some("mmm-yy")), + ("13. Okt", date(year, 10, 13), Some("d-mmm")), + ("13.Okt", date(year, 10, 13), Some("d-mmm")), + ("13 Okt", date(year, 10, 13), Some("d-mmm")), + ("14:30", 14.5 / 24.0, Some("h:mm")), + ("14:30:15", (14.0 * 3600.0 + 30.0 * 60.0 + 15.0) / 86400.0, Some("h:mm:ss")), + ("25:00", 25.0 / 24.0, Some("[h]:mm:ss")), + ("2:30 PM", 14.5 / 24.0, Some("h:mm AM/PM")), + ("13.10.2026 14:30", date(2026, 10, 13) + 14.5 / 24.0, Some("m/d/yyyy h:mm")), + ("13.10.26 14:30", date(2026, 10, 13) + 14.5 / 24.0, Some("m/d/yyyy h:mm")), + ("13. Okt 2026 14:30", date(2026, 10, 13) + 14.5 / 24.0, Some("m/d/yyyy h:mm")), + ("13.10.2026 2:30 PM", date(2026, 10, 13) + 14.5 / 24.0, Some("m/d/yyyy h:mm")), + ] { + let p = num(input); + let Value::Number(n) = p.value else { panic!("{input}: {:?}", p.value) }; + assert!((n - value).abs() < 1e-9, "{input}: {n} != {value}"); + assert_eq!(p.format, format, "{input}"); + } + // Text in Excel de-DE, although some of them read as numbers or dates in en-US. + for input in [ + "13.10.", + "1.10.", + "1.5.", + "31.2.2026", + "29.2.1900x", + "13.10. 14:30", + "10/13/2026", + "13.13", + "1.0", + "1.00", + "1.0.0", + "1,5-", + "1,5%%", + "1,5E", + "E5", + "1e400", + "1 234,5", + "5 EUR", + "$5", + "5 $", + "13 10 2026", + "13:30 Uhr", + "13.10.1899", + "Okt", + "abc", + "1.2.3.4", + "13.10.2026,5", + "Oct 26", + ] { + assert_eq!(num(input).value, Value::text(input), "{input}"); + } + // Canonical words stay what they are; the formula language's own words are the UI's. + assert_eq!(num("TRUE").value, Value::Bool(true)); + assert_eq!(num("#N/A").value, Value::Error(CellError::NA)); + assert_eq!(num("'1,5").value, Value::text("1,5")); + assert_eq!(num("").value, Value::Empty); + // The canonical locale reads as `parse_input`. + assert_eq!(parse_input_in("1,234", DateSystem::D1900, &NumberLocale::EN_US), p("1,234")); + // Other separators (File › Options › Advanced) with the region's dates. + let swiss = de.with_separators('.', '\''); + assert_eq!(parse_input_in("1'234.5", DateSystem::D1900, &swiss).value, Value::Number(1234.5)); + assert_eq!(parse_input_in("13.10.2026", DateSystem::D1900, &swiss).value, Value::Number(date(2026, 10, 13))); + } + + #[test] + fn regional_parsing_never_panics_on_odd_input() { + let de = NumberLocale::DE_DE; + let long = "1.".repeat(10_000); + for input in [ + "", + " ", + ".", + ",", + "-", + "--1", + "(", + "()", + "((1))", + "€", + "€€", + "%", + "E", + "1E", + "1E+", + ".:.", + "1..2", + "..", + "1.2.", + "...", + "Okt Okt Okt", + "13. . 2026", + "99999999999.1.1", + "1.99999999", + "\u{0}", + "١٢٣", + long.as_str(), + ] { + let _ = parse_input_in(input, DateSystem::D1900, &de); + let _ = parse_input_in(input, DateSystem::D1904, &de.with_separators('.', ' ')); + } + } + #[test] fn fictitious_1900_02_29() { // Excel accepts 29 February 1900 (serial 60) and keeps 1 March 1900 at 61. diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 82dbe0be72..345ecd34f2 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -10,7 +10,15 @@ use crate::{Clipboard, Session}; pub fn specs() -> Vec { vec![ - cmd!("cell.set", "Enter Cell", [], None, "{cell?: \"B2\", input: \"text, number or =formula\", sheet?, array?: bool}", has_doc, cell_set), + cmd!( + "cell.set", + "Enter Cell", + [], + None, + "{cell?: \"B2\", input: \"text, number or =formula\", sheet?, array?: bool, autoFormat?: \"0.00%\" (the number format a constant takes when the cell's is General, instead of the one its spelling implies; for input read in a regional format)}", + has_doc, + cell_set + ), cmd!( "range.setValues", "Set Values", @@ -25,7 +33,7 @@ pub fn specs() -> Vec { "Fill Range With Input", [], Some("Ctrl+Enter"), - "{range?, input}: enters the same input in every selected cell (relative formulas adjust)", + "{range?, input, autoFormat?}: enters the same input in every selected cell (relative formulas adjust)", has_doc, range_fill ), @@ -122,6 +130,19 @@ pub fn specs() -> Vec { /// format). Inherits the destination's cell, row or column style, including its number /// format, before parsing. Errors when the formula can't be parsed. pub(crate) fn input_to_cell(input: &str, sheet: usize, at: CellRef, wb: &mut gridcraft_model::Workbook) -> Result> { + input_to_cell_with(input, None, sheet, at, wb) +} + +/// [`input_to_cell`] where a constant takes `auto_format` (when the cell's format is General) +/// instead of the format its canonical spelling implies: the interface reads `13.10.2026` in +/// de-DE and enters the date's serial with the short date format. Formulas ignore it. +pub(crate) fn input_to_cell_with( + input: &str, + auto_format: Option<&str>, + sheet: usize, + at: CellRef, + wb: &mut gridcraft_model::Workbook, +) -> Result> { let style = wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(); if input.is_empty() { let c = Cell { value: Value::Empty, formula: None, style }; @@ -178,7 +199,9 @@ pub(crate) fn input_to_cell(input: &str, sheet: usize, at: CellRef, wb: &mut gri } let parsed = gridcraft_core::parse::parse_input(input, wb.date_system); let mut style = style; - if let Some(code) = parsed.format { + // A code longer than Excel's limit is not a format (and not stored). + let auto_format = auto_format.filter(|code| !code.is_empty() && code.chars().count() <= gridcraft_numfmt::MAX_FORMAT_CODE_CHARS); + if let Some(code) = auto_format.or(parsed.format) { let cur = wb.styles.get(style).num_fmt.as_str().to_string(); if cur == "General" { style = wb.styles.derive(style, |s| s.num_fmt = gridcraft_model::NumFmt::new(code)); @@ -222,6 +245,8 @@ fn cell_set(s: &mut Session, p: &Json) -> Result { // Both spellings take a string, number or boolean, as typed (#697). let input = p.get("input").or_else(|| p.get("value")).map(json_to_input).ok_or_else(|| bad("cell.set", "missing `input` (or `value`)"))?; let array = bool_param(p, "array").unwrap_or(false); + let auto_format = str_param(p, "autoFormat").map(str::to_string); + let auto_format = auto_format.as_deref(); let d = s.doc()?; if let Some(sh) = d.wb.sheet(sheet) && sh.is_protected() @@ -241,7 +266,7 @@ fn cell_set(s: &mut Session, p: &Json) -> Result { if !range.is_single() && cx.wb.sheet(sheet).is_some_and(|sh| sh.tables.iter().any(|t| t.range.intersects(&range))) { return Err(EngineError::Other("Multi-cell array formulas aren't allowed in tables.".into())); } - let mut cell = input_to_cell(&input, sheet, range.start, &mut cx.wb)?.unwrap_or_default(); + let mut cell = input_to_cell_with(&input, auto_format, sheet, range.start, &mut cx.wb)?.unwrap_or_default(); if let Some(f) = cell.formula.as_mut() { std::sync::Arc::make_mut(f).array = Some(range); } @@ -257,7 +282,7 @@ fn cell_set(s: &mut Session, p: &Json) -> Result { cx.touch(sheet, range.start); return Ok(Json::Null); } - let cell = input_to_cell(&input, sheet, at, &mut cx.wb)?; + let cell = input_to_cell_with(&input, auto_format, sheet, at, &mut cx.wb)?; let sh = cx.sheet_mut(sheet)?; match cell { Some(c) => sh.set_cell(at, c), @@ -336,9 +361,10 @@ fn range_fill(s: &mut Session, p: &Json) -> Result { let sheet = target_sheet(s, p)?; let ranges = target_ranges(s, p)?; let input = str_param(p, "input").unwrap_or("").to_string(); + let auto_format = str_param(p, "autoFormat").map(str::to_string); let origin = s.doc()?.selection.active; edit(s, |cx| { - let base = input_to_cell(&input, sheet, origin, &mut cx.wb)?; + let base = input_to_cell_with(&input, auto_format.as_deref(), sheet, origin, &mut cx.wb)?; for r in &ranges { if r.count() > 2_000_000 { return Err(bad("range.fill", "range too large")); diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 7291ee8973..e0474d59d4 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -56,7 +56,7 @@ fn open(s: &mut Session, p: &Json) -> Result { return ok(); }; let encrypted = gridcraft_xlsx::sniff(&bytes) == gridcraft_xlsx::Format::Encrypted; - let (mut wb, warnings) = crate::io::open_bytes_with_password(&name, &bytes, str_param(p, "password"))?; + let (mut wb, warnings) = crate::io::open_bytes_with_password_in(&name, &bytes, str_param(p, "password"), &s.display_locale)?; wb.source_dir = str_param(p, "path").and_then(crate::io::source_dir); let imported = matches!(crate::io::FileKind::from_path(&name), Some(crate::io::FileKind::Ods | crate::io::FileKind::Xlsb | crate::io::FileKind::Xls)) @@ -109,6 +109,7 @@ fn save(s: &mut Session, p: &Json) -> Result { if std::path::Path::new(&path).extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("pdf")) { return super::print::export_pdf(s, &json!({"path": path})); } + let loc = s.display_locale; let d = s.doc_mut()?; // The document's own file keeps the format its content had when opened; any other path // goes by its extension. Relative paths to linked workbooks are rewritten for the new folder. @@ -116,8 +117,8 @@ fn save(s: &mut Session, p: &Json) -> Result { let rebased = crate::io::source_dir(&path).and_then(|f| gridcraft_xlsx::rebase_links(&d.wb, &f)); let wb = rebased.as_ref().unwrap_or(&d.wb); let bytes = match kind { - Some(kind) => crate::io::save_bytes_as(wb, &path, kind)?, - None => crate::io::save_bytes(wb, &path)?, + Some(kind) => crate::io::save_bytes_as_in(wb, &path, kind, &loc)?, + None => crate::io::save_bytes_in(wb, &path, &loc)?, }; crate::io::write_file(&path, &bytes)?; // Only a full-fidelity format clears the dirty flag and becomes the document's file. @@ -139,7 +140,7 @@ fn save_as(s: &mut Session, p: &Json) -> Result { fn save_bytes(s: &mut Session, p: &Json) -> Result { let fmt = str_param(p, "format").unwrap_or("xlsx"); let d = s.doc()?; - let bytes = crate::io::save_bytes(&d.wb, &format!("x.{fmt}"))?; + let bytes = crate::io::save_bytes_in(&d.wb, &format!("x.{fmt}"), &s.display_locale)?; let name = format!("{}.{fmt}", std::path::Path::new(&d.display_title()).file_stem().and_then(|x| x.to_str()).unwrap_or("Book")); Ok(json!({"name": name, "base64": crate::io::base64_encode(&bytes), "bytes": bytes.len()})) } @@ -164,7 +165,7 @@ fn export(s: &mut Session, p: &Json, ext: &str) -> Result { wb.active_sheet = target_sheet(s, p)?; } let target = if path.ends_with(&format!(".{ext}")) { path.clone() } else { format!("{path}.{ext}") }; - let bytes = crate::io::save_bytes(&wb, &target)?; + let bytes = crate::io::save_bytes_in(&wb, &target, &s.display_locale)?; crate::io::write_file(&target, &bytes)?; Ok(json!({"path": target, "bytes": bytes.len()})) } diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index a10f19d46c..a8b0045d72 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -9,6 +9,7 @@ //! sparklines. Headers and footers support `&L`/`&C`/`&R` sections and the `&P &N &D &T &A &F &Z` //! codes. +use crate::display::DisplayLocale; use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value}; use gridcraft_model::*; use gridcraft_pdf::{Font, Page, PdfDoc, Rgb}; @@ -391,9 +392,14 @@ pub struct HfContext { /// Today's date and the current time as headers and footers show them (`&D`, `&T`). pub fn hf_date_time(wb: &Workbook) -> (String, String) { + hf_date_time_in(wb, &DisplayLocale::CANONICAL) +} + +/// [`hf_date_time`] in a regional format (the short date: `13.10.2026` in de-DE). +pub fn hf_date_time_in(wb: &Workbook, loc: &DisplayLocale) -> (String, String) { let now = gridcraft_core::date::serial_in_system(wb.date_system, gridcraft_calc::now_serial()); - let date = crate::display::format(&Value::Number(now.floor()), "m/d/yyyy", wb).text; - let time = crate::display::format(&Value::Number(now), "h:mm AM/PM", wb).text; + let date = crate::display::format_in(&Value::Number(now.floor()), "m/d/yyyy", wb, loc).text; + let time = crate::display::format_in(&Value::Number(now), "h:mm AM/PM", wb, loc).text; (date, time) } @@ -806,6 +812,8 @@ fn wrap_lines(text: &str, font: Font, size: f32, width: f32) -> Vec { /// Everything needed to draw one page block (a run of rows × a run of columns). struct Block<'a> { wb: &'a Workbook, + /// How values are shown: a print shows what the person sees on screen. + loc: DisplayLocale, si: usize, sh: &'a Sheet, rows: (u32, u32), @@ -1060,7 +1068,7 @@ fn draw_text(pg: &mut Page, b: &Block, c: CellRef, l: &Look, rect: (f32, f32, f3 let indent = st.align.indent as f32 * 9.0 * b.k; let avail = (w - 2.0 * pad - indent).max(0.0); let code = st.num_fmt.as_str(); - let formatted = crate::display::format(&v, code, wb); + let formatted = crate::display::format_in(&v, code, wb, &b.loc); let mut color = formatted.color.map(numfmt_rgb).or_else(|| rgb_of(st.font.color, wb)).unwrap_or([0, 0, 0]); if l.cf.as_ref().and_then(|x| x.style.as_ref()).is_some_and(|s| s.font.color != Color::Auto) { color = rgb_of(st.font.color, wb).unwrap_or(color); @@ -1068,15 +1076,15 @@ fn draw_text(pg: &mut Page, b: &Block, c: CellRef, l: &Look, rect: (f32, f32, f3 let is_num = matches!(v, Value::Number(_)); let wrap = st.align.wrap; let mut text = if sh.show_formulas { - crate::display::cell_text(wb, sh, c) + crate::display::cell_text_in(wb, b.si, c, &b.loc) } else if code == "General" && let Value::Number(n) = v { let digit = gridcraft_pdf::text_width(font, size, "0").max(0.1); let max_chars = (avail / digit).floor().max(1.0) as usize; - gridcraft_numfmt::format_general_fit(n, max_chars.min(11)).unwrap_or_else(|| "#".repeat(max_chars.min(255))) + gridcraft_numfmt::format_general_fit_in(n, max_chars.min(11), &b.loc.number).unwrap_or_else(|| "#".repeat(max_chars.min(255))) } else { - crate::display::cell_text(wb, sh, c) + crate::display::cell_text_in(wb, b.si, c, &b.loc) }; if !wrap { text = text.replace(['\n', '\r'], " "); @@ -1419,7 +1427,18 @@ fn draw_header_footer(pg: &mut Page, lay: &SheetLayout, which: gridcraft_model:: } /// Builds a block for rows × cols positioned at (x, y) on the page. -fn make_block<'a>(wb: &'a Workbook, si: usize, sh: &'a Sheet, rows: (u32, u32), cols: (u32, u32), x: f32, y: f32, lay: &SheetLayout) -> Block<'a> { +#[allow(clippy::too_many_arguments)] +fn make_block<'a>( + wb: &'a Workbook, + si: usize, + sh: &'a Sheet, + rows: (u32, u32), + cols: (u32, u32), + x: f32, + y: f32, + lay: &SheetLayout, + loc: DisplayLocale, +) -> Block<'a> { let k = lay.k(); let mut xs = Vec::with_capacity((cols.1 - cols.0 + 2) as usize); let mut acc = x; @@ -1435,7 +1454,7 @@ fn make_block<'a>(wb: &'a Workbook, si: usize, sh: &'a Sheet, rows: (u32, u32), acc += sh.row_height(r) * k; ys.push(acc); } - Block { wb, si, sh, rows, cols, xs, ys, k, scale: lay.scale } + Block { wb, loc, si, sh, rows, cols, xs, ys, k, scale: lay.scale } } /// Segments of a page along one axis: repeated titles (before the band) then the band. @@ -1456,11 +1475,17 @@ fn seg_size(sh: &Sheet, seg: (u32, u32), rows: bool) -> f32 { /// Renders laid-out sheets to a PDF. pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str) -> (Vec, usize) { + render_pdf_in(wb, layouts, file, path, &DisplayLocale::CANONICAL) +} + +/// [`render_pdf`] with values as a person working in `loc` sees them on screen (`1.234,56`, +/// `13.10.2026`, `WAHR` in German): print and PDF show what the grid shows. +pub fn render_pdf_in(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str, loc: &DisplayLocale) -> (Vec, usize) { let mut doc = PdfDoc::new(); doc.info.title = if wb.props.title.is_empty() { file.to_string() } else { wb.props.title.clone() }; doc.info.author = wb.props.author.clone(); let total: usize = layouts.iter().map(|l| l.pages.len()).sum(); - let (date, time) = hf_date_time(wb); + let (date, time) = hf_date_time_in(wb, loc); let mut page_no = 0usize; for lay in layouts { let Some(sh) = wb.sheet(lay.sheet) else { continue }; @@ -1482,7 +1507,7 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str for (rsi, rs) in rsegs.iter().enumerate() { let mut x = ox + lay.heading_w * k; for (csi, cs) in csegs.iter().enumerate() { - let b = make_block(wb, lay.sheet, sh, *rs, *cs, x, y, lay); + let b = make_block(wb, lay.sheet, sh, *rs, *cs, x, y, lay, *loc); draw_block(pg, &b, lay.settings.gridlines, &mut cf); if lay.settings.headings { draw_headings(pg, &b, rsi == 0, csi == 0, (ox, oy), (lay.heading_w * k, lay.heading_h * k)); @@ -1525,7 +1550,7 @@ fn build_pdf(s: &Session, p: &Json, cmd: &str) -> Result<(Vec, usize)> { let d = s.doc()?; let lays = layouts(&d.wb, &opts); let path = d.path.clone().unwrap_or_default(); - Ok(render_pdf(&d.wb, &lays, &d.display_title(), &path)) + Ok(render_pdf_in(&d.wb, &lays, &d.display_title(), &path, &s.display_locale)) } pub(crate) fn export_pdf(s: &mut Session, p: &Json) -> Result { diff --git a/crates/engine/src/display.rs b/crates/engine/src/display.rs index b90e42727b..cf66776e10 100644 --- a/crates/engine/src/display.rs +++ b/crates/engine/src/display.rs @@ -1,11 +1,44 @@ //! How cells look as text: number formats applied, for the grid, clipboard, CSV and agents. +//! +//! The plain functions give the canonical (en-US, English) text agents, the clipboard and files +//! get. The `_in` variants draw for people in a [`DisplayLocale`]: the regional number format the +//! interface uses and the formula language's words for booleans, errors and formulas. use std::collections::HashMap; use std::sync::Mutex; -use gridcraft_core::{CellRef, RangeRef, Value}; +use gridcraft_core::{CellRef, NumberLocale, RangeRef, Value}; +use gridcraft_formula::FormulaLocale; use gridcraft_model::{Sheet, Workbook}; -use gridcraft_numfmt::{Formatted, NumberFormat, format_value}; +use gridcraft_numfmt::{Formatted, NumberFormat, format_value, format_value_in}; + +/// How values are shown to people: the regional number format (separators, dates) and the +/// formula language (booleans, error values, function names). The default is canonical: what +/// files, commands and agents use. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct DisplayLocale { + pub number: NumberLocale, + pub formula: FormulaLocale, +} + +impl DisplayLocale { + pub const CANONICAL: DisplayLocale = DisplayLocale { number: NumberLocale::EN_US, formula: FormulaLocale::En }; + + pub fn is_canonical(&self) -> bool { + *self == DisplayLocale::CANONICAL + } + + /// A boolean or error value in the formula language's words (`WAHR`, `#NV`); `None` for other + /// values. + pub fn word(&self, v: &Value) -> Option<&'static str> { + match v { + Value::Bool(b) => Some(self.formula.boolean_name(*b)), + Value::Error(e) => Some(self.formula.error_name(*e)), + Value::Array(a) => a.data.first().and_then(|first| self.word(first)), + _ => None, + } + } +} /// Parsed number formats are cached by code. fn parsed(code: &str) -> NumberFormat { @@ -33,6 +66,23 @@ pub fn format(v: &Value, code: &str, wb: &Workbook) -> Formatted { format_value(v, &parsed(code), wb.date_system) } +/// Formats a value with a format code as people see it in `loc`. +pub fn format_in(v: &Value, code: &str, wb: &Workbook, loc: &DisplayLocale) -> Formatted { + if let Some(word) = loc.word(v) { + return Formatted { text: word.to_string(), ..Formatted::default() }; + } + format_value_in(v, &parsed(code), wb.date_system, &loc.number) +} + +/// A formula as people see it in `loc` (`=SUMME(A1;2,5)` in German). Workbook and table names +/// that look like local function names keep their spelling (see [`gridcraft_formula::print_input`]). +pub fn formula_text_in(text: &str, wb: &Workbook, sheet: usize, loc: &DisplayLocale) -> String { + let Some(body) = text.strip_prefix('=').filter(|_| loc.formula != FormulaLocale::En) else { return text.to_string() }; + gridcraft_formula::parse(body) + .and_then(|expr| gridcraft_formula::print_input(&expr, loc.formula, |name| wb.name(name, sheet).is_some() || wb.table(name).is_some())) + .map_or_else(|_| text.to_string(), |body| format!("={body}")) +} + /// What the cell editor and the formula bar start from. Same as `Cell::input_text`, except that a /// constant number in a date or time format is shown as a date (as the grid shows it), not as its /// serial. When the formatted text would not read back as the same date (for example @@ -64,6 +114,45 @@ pub fn edit_text(wb: &Workbook, sheet: &Sheet, c: CellRef) -> String { if reads_back(&iso) { iso } else { raw } } +/// What the cell editor starts from in `loc`: [`edit_text`] with the regional format's numbers +/// and dates (`1234,5`, `13.10.2026`, `13.10.2026 14:30:00`) and the formula language's +/// booleans and errors. Formulas are the caller's (they need the editor's name lookups). The text +/// reads back as the same value through [`gridcraft_core::parse::parse_input_in`]. +pub fn edit_text_in(wb: &Workbook, sheet: &Sheet, c: CellRef, loc: &DisplayLocale) -> String { + if loc.is_canonical() { + return edit_text(wb, sheet, c); + } + let Some(cell) = sheet.cell(c).filter(|cell| cell.formula.is_none()) else { return edit_text(wb, sheet, c) }; + if let Some(word) = loc.word(&cell.value) { + return word.to_string(); + } + let Value::Number(n) = &cell.value else { return edit_text(wb, sheet, c) }; + let n = *n; + let raw = || { + let text = Value::Number(n).display(); + if loc.number.decimal == '.' { text } else { text.replace('.', loc.number.decimal.encode_utf8(&mut [0; 4])) } + }; + let code = wb.styles.get(cell.style).num_fmt.as_str(); + if !parsed(code).is_date() { + return raw(); + } + let reads_back = + |s: &str| matches!(gridcraft_core::parse::parse_input_in(s, wb.date_system, &loc.number).value, Value::Number(m) if (m - n).abs() < 1e-9); + let shown = format_in(&cell.value, code, wb, loc).text; + if reads_back(&shown) { + return shown; + } + // Excel's editor shows a date as the short date, with the time of day when there is one. + let short = if n < 1.0 { + format_in(&cell.value, "hh:mm:ss", wb, loc).text + } else if n.fract() == 0.0 { + format_in(&cell.value, "m/d/yyyy", wb, loc).text + } else { + format!("{} {}", format_in(&cell.value, "m/d/yyyy", wb, loc).text, format_in(&cell.value, "hh:mm:ss", wb, loc).text) + }; + if reads_back(&short) { short } else { raw() } +} + /// Whether `c` has a formula that must not be shown: its cell is formatted Hidden on a /// protected sheet. A spilled cell follows its anchor. pub fn formula_hidden(wb: &Workbook, sheet: &Sheet, c: CellRef) -> bool { @@ -91,11 +180,29 @@ pub fn cell_text(wb: &Workbook, sheet: &Sheet, c: CellRef) -> String { value_text(wb, sheet, c) } +/// [`cell_text`] as people see it in `loc`: formulas (Show Formulas) in the formula language, +/// values in the regional format. +pub fn cell_text_in(wb: &Workbook, si: usize, c: CellRef, loc: &DisplayLocale) -> String { + let Some(sheet) = wb.sheet(si) else { return String::new() }; + if sheet.show_formulas + && let Some(f) = sheet.cell(c).and_then(|x| x.formula.as_ref()) + && !formula_hidden(wb, sheet, c) + { + return formula_text_in(&format!("={}", f.text), wb, si, loc); + } + value_text_in(wb, sheet, c, loc) +} + /// A cell's value with its number format applied, independent of column width: no `#####` /// and no General narrowing (Save As CSV/Text writes this). Formulas give their result. pub fn value_text(wb: &Workbook, sheet: &Sheet, c: CellRef) -> String { + value_text_in(wb, sheet, c, &DisplayLocale::CANONICAL) +} + +/// [`value_text`] as people see it in `loc`. +pub fn value_text_in(wb: &Workbook, sheet: &Sheet, c: CellRef, loc: &DisplayLocale) -> String { let style = wb.styles.get(sheet.style_id(c)); - let f = format(&sheet.value(c), style.num_fmt.as_str(), wb); + let f = format_in(&sheet.value(c), style.num_fmt.as_str(), wb, loc); match f.fill { Some((_, pos)) => { let mut t = f.text; diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 3e84036cbc..babb327036 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -5,6 +5,7 @@ use std::fmt::Write as _; use gridcraft_core::{CellRef, RangeRef}; use gridcraft_model::{Sheet, Workbook}; +use crate::display::DisplayLocale; use crate::{EngineError, Result}; /// File formats GridCraft reads or writes. @@ -55,8 +56,15 @@ pub fn open_bytes(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec)> { /// [`open_bytes`], first decrypting a password-protected workbook with `password`. Errors never /// quote the password. pub fn open_bytes_with_password(name: &str, bytes: &[u8], password: Option<&str>) -> Result<(Workbook, Vec)> { + open_bytes_with_password_in(name, bytes, password, &DisplayLocale::CANONICAL) +} + +/// [`open_bytes_with_password`] for a person working in `loc`: delimited text (CSV, TSV) is read +/// in its regional format and formula language (`1,5`, `13.10.2026`, `WAHR` in German), unless +/// the file separates fields with commas, which says it was written in en-US style. +pub fn open_bytes_with_password_in(name: &str, bytes: &[u8], password: Option<&str>, loc: &DisplayLocale) -> Result<(Workbook, Vec)> { if gridcraft_xlsx::sniff(bytes) != gridcraft_xlsx::Format::Encrypted { - let (mut wb, warnings) = open_plain(name, bytes)?; + let (mut wb, warnings) = open_plain(name, bytes, loc)?; fit_imported_rows(&mut wb); return Ok((wb, warnings)); } @@ -70,7 +78,7 @@ pub fn open_bytes_with_password(name: &str, bytes: &[u8], password: Option<&str> if gridcraft_xlsx::sniff(&plain) == gridcraft_xlsx::Format::Encrypted { return Err(EngineError::Other(format!("We can't open '{name}': the encrypted file is damaged (it holds another encrypted file)."))); } - let (mut wb, mut warnings) = open_plain(name, &plain)?; + let (mut wb, mut warnings) = open_plain(name, &plain, loc)?; fit_imported_rows(&mut wb); warnings.insert(0, DECRYPTED_NOTICE.into()); Ok((wb, warnings)) @@ -112,7 +120,7 @@ fn decrypt_message(name: &str, e: gridcraft_xlsx::DecryptError) -> String { } /// Parses bytes that aren't encrypted. -fn open_plain(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec)> { +fn open_plain(name: &str, bytes: &[u8], loc: &DisplayLocale) -> Result<(Workbook, Vec)> { let kind = FileKind::from_path(name); let sniffed = gridcraft_xlsx::sniff(bytes); if sniffed == gridcraft_xlsx::Format::Xls { @@ -153,7 +161,7 @@ fn open_plain(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec)> { Ok((wb, vec![])) } Some(FileKind::Tsv) => { - let opts = gridcraft_xlsx::CsvOptions { delimiter: b'\t', ..Default::default() }; + let opts = gridcraft_xlsx::CsvOptions { delimiter: b'\t', number_locale: loc.number, formula_locale: loc.formula, ..Default::default() }; let mut wb = gridcraft_xlsx::read_csv(bytes, &opts).map_err(|e| EngineError::Other(e.to_string()))?; // Tab is what makes it a .tsv/.txt; saved as .csv it gets the CSV default. wb.csv_delimiter = None; @@ -161,7 +169,7 @@ fn open_plain(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec)> { Ok((wb, vec![])) } _ => { - let opts = gridcraft_xlsx::CsvOptions { delimiter: 0, ..Default::default() }; + let opts = gridcraft_xlsx::CsvOptions { delimiter: 0, number_locale: loc.number, formula_locale: loc.formula, ..Default::default() }; let mut wb = gridcraft_xlsx::read_csv(bytes, &opts).map_err(|e| EngineError::Other(e.to_string()))?; rename_first_sheet(&mut wb, name); Ok((wb, vec![])) @@ -185,17 +193,35 @@ fn rename_first_sheet(wb: &mut Workbook, path: &str) { /// Encodes a workbook in the format chosen by `path`'s extension. pub fn save_bytes(wb: &Workbook, path: &str) -> Result> { + save_bytes_in(wb, path, &DisplayLocale::CANONICAL) +} + +/// [`save_bytes`] for a person working in `loc`: CSV, TSV and web pages hold the values as that +/// person sees them, and a new CSV separates fields with `;` where the decimal separator is `,` +/// (Excel's list separator in such regions). Workbook formats are never localized. +pub fn save_bytes_in(wb: &Workbook, path: &str, loc: &DisplayLocale) -> Result> { // An unknown or missing extension is refused rather than written as an XLSX under that name: // a typo like `.cvs` would otherwise produce a file no other app recognises. let Some(kind) = FileKind::from_path(path) else { let name = std::path::Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or(path); return Err(EngineError::Other(format!("{name}: unknown file format. Save as .xlsx, .csv, .tsv, .json or .html."))); }; - save_bytes_as(wb, path, kind) + save_bytes_as_in(wb, path, kind, loc) } /// Encodes a workbook as `kind`, whatever `path`'s extension says. pub fn save_bytes_as(wb: &Workbook, path: &str, kind: FileKind) -> Result> { + save_bytes_as_in(wb, path, kind, &DisplayLocale::CANONICAL) +} + +/// The field separator of a new CSV in `loc`: `;` where numbers are written with a decimal comma +/// (Excel uses the regional list separator, `;` in de-DE), `,` otherwise. +pub fn csv_separator(loc: &DisplayLocale) -> u8 { + if loc.number.decimal == ',' { b';' } else { b',' } +} + +/// [`save_bytes_as`] for a person working in `loc` (see [`save_bytes_in`]). +pub fn save_bytes_as_in(wb: &Workbook, path: &str, kind: FileKind, loc: &DisplayLocale) -> Result> { let sheet = wb.active_sheet; match kind { // The extension picks the package kind; only the macro-enabled kinds (.xlsm, .xltm) carry @@ -207,10 +233,14 @@ pub fn save_bytes_as(wb: &Workbook, path: &str, kind: FileKind) -> Result Err(EngineError::Other("XLSB is supported for data import only. Save as .xlsx or another supported export format.".into())), FileKind::Ods => Err(EngineError::Other("ODS is supported for data import only. Save as .xlsx or another supported export format.".into())), // A workbook read from CSV keeps the separator it was read with (a `;` file stays `;`). - FileKind::Csv => wb.sheet(sheet).map(|sh| csv(wb, sh, wb.csv_delimiter.unwrap_or(b','))).transpose().map(Option::unwrap_or_default), - FileKind::Tsv => wb.sheet(sheet).map(|sh| csv(wb, sh, b'\t')).transpose().map(Option::unwrap_or_default), + FileKind::Csv => wb + .sheet(sheet) + .map(|sh| csv(wb, sh, wb.csv_delimiter.unwrap_or_else(|| csv_separator(loc)), loc)) + .transpose() + .map(Option::unwrap_or_default), + FileKind::Tsv => wb.sheet(sheet).map(|sh| csv(wb, sh, b'\t', loc)).transpose().map(Option::unwrap_or_default), FileKind::Json => serde_json::to_vec_pretty(wb).map_err(|e| EngineError::Other(e.to_string())), - FileKind::Html => to_html(wb, sheet).map(String::into_bytes), + FileKind::Html => to_html_in(wb, sheet, loc).map(String::into_bytes), } } @@ -231,7 +261,7 @@ pub fn source_dir(path: &str) -> Option { /// Save As CSV/Text writes "the text and values as they are displayed in cells" with "all /// characters in each cell" (Microsoft Support, *Excel formatting and features that are not /// transferred to other file formats*): number formats applied, never column-width `#####`. -fn csv(wb: &Workbook, sh: &Sheet, delimiter: u8) -> Result> { +fn csv(wb: &Workbook, sh: &Sheet, delimiter: u8, loc: &DisplayLocale) -> Result> { gridcraft_xlsx::write_csv(sh, delimiter, |c| { // General's grid-width formatting rounds large identifiers to scientific notation. // Delimited files have no column width, so preserve the whole numeric value instead. @@ -239,9 +269,10 @@ fn csv(wb: &Workbook, sh: &Sheet, delimiter: u8) -> Result> { && let gridcraft_core::Value::Number(number) = sh.value(c) && number.is_finite() { - number.to_string() + let text = number.to_string(); + if loc.number.decimal == '.' { text } else { text.replace('.', loc.number.decimal.encode_utf8(&mut [0; 4])) } } else { - crate::display::value_text(wb, sh, c) + crate::display::value_text_in(wb, sh, c, loc) } }) .map_err(|e| EngineError::Other(e.to_string())) @@ -359,6 +390,11 @@ const HTML_EXPORT_MAX_ROWS: u32 = 100_001; /// A sheet as a standalone HTML table (Save as Web Page). pub fn to_html(wb: &Workbook, sheet: usize) -> Result { + to_html_in(wb, sheet, &DisplayLocale::CANONICAL) +} + +/// [`to_html`] with the cells as a person working in `loc` sees them. +pub fn to_html_in(wb: &Workbook, sheet: usize, loc: &DisplayLocale) -> Result { let Some(sh) = wb.sheet(sheet) else { return Ok(String::new()) }; let mut out = String::new(); let _ = write!( @@ -390,7 +426,7 @@ pub fn to_html(wb: &Workbook, sheet: usize) -> Result { .find(|m| m.start == c) .map(|m| format!(" rowspan=\"{}\" colspan=\"{}\"", m.height(), m.width())) .unwrap_or_default(); - let _ = write!(out, "{}", esc(&crate::display::cell_text(wb, sh, c))); + let _ = write!(out, "{}", esc(&crate::display::cell_text_in(wb, sheet, c, loc))); } out.push_str(""); } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 258a1cb729..9700ccc396 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -256,6 +256,11 @@ pub struct Session { /// Review › Notes › Show All Notes: every note is displayed. An application display setting /// (like Excel's comment indicator option), so it's not saved in the file. pub show_all_notes: bool, + /// How values are shown to people in what this session prints and writes for them: PDF and + /// print, and Save As CSV/Text (its separator and its numbers). The interface sets it from its + /// language and the separators option; headless sessions (CLI, MCP) stay canonical. Never + /// saved in a workbook. + pub display_locale: display::DisplayLocale, } impl Session { diff --git a/crates/engine/tests/german_locale.rs b/crates/engine/tests/german_locale.rs new file mode 100644 index 0000000000..1fa0088201 --- /dev/null +++ b/crates/engine/tests/german_locale.rs @@ -0,0 +1,179 @@ +//! A German interface: what people see and type is German (`1.234,56`, `13.10.2026`, `WAHR`, +//! `#NV`, `=SUMME(A1;2,5)`), while the workbook, its files and every command stay canonical. +use gridcraft_core::{CellRef, NumberLocale, Value}; +use gridcraft_engine::Session; +use gridcraft_engine::display::{self, DisplayLocale}; +use gridcraft_engine::formula::FormulaLocale; +use serde_json::json; + +const GERMAN: DisplayLocale = DisplayLocale { number: NumberLocale::DE_DE, formula: FormulaLocale::De }; + +fn session() -> Session { + let mut s = Session::new(); + s.new_workbook(); + s +} + +fn shown(s: &Session, cell: &str) -> String { + let d = s.doc().unwrap(); + display::value_text_in(&d.wb, d.wb.active().unwrap(), CellRef::parse(cell).unwrap(), &GERMAN) +} + +fn edit_text(s: &Session, cell: &str) -> String { + let d = s.doc().unwrap(); + display::edit_text_in(&d.wb, d.wb.active().unwrap(), CellRef::parse(cell).unwrap(), &GERMAN) +} + +#[test] +fn values_show_in_the_regional_format_and_the_formula_language() { + let mut s = session(); + for (cell, input) in [ + ("A1", "1234.56"), + ("A2", "TRUE"), + ("A3", "=NA()"), + ("A4", "=1/0"), + ("A5", "=\"a\"+1"), + ("A6", "10/13/2026"), + ("A7", "10/13/2026 14:30"), + ("A8", "=SQRT(-1)"), + ("A9", "=FALSE"), + ] { + s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap(); + } + s.execute("home.numberFormat", json!({"range": "A1", "code": "#,##0.00"})).unwrap(); + for (cell, text) in [ + ("A1", "1.234,56"), + ("A2", "WAHR"), + ("A3", "#NV"), + ("A4", "#DIV/0!"), + ("A5", "#WERT!"), + ("A6", "13.10.2026"), + ("A7", "13.10.2026 14:30"), + ("A8", "#ZAHL!"), + ("A9", "FALSCH"), + ] { + assert_eq!(shown(&s, cell), text, "{cell}"); + } + // Agents and files read the canonical text. + let d = s.doc().unwrap(); + assert_eq!(display::value_text(&d.wb, d.wb.active().unwrap(), CellRef::parse("A1").unwrap()), "1,234.56"); + assert_eq!(s.execute("cell.get", json!({"cell": "A2"})).unwrap()["text"], "TRUE"); +} + +#[test] +fn the_editor_starts_from_text_that_reads_back_in_german() { + let mut s = session(); + for (cell, input) in + [("A1", "1234.5"), ("A2", "10/13/2026"), ("A3", "10/13/2026 14:30:15"), ("A4", "14:30"), ("A5", "13-Oct-26"), ("A6", "FALSE"), ("A7", "#N/A")] + { + s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap(); + } + s.execute("home.numberFormat", json!({"range": "A1", "code": "#,##0.00"})).unwrap(); + for (cell, text) in [ + ("A1", "1234,5"), + ("A2", "13.10.2026"), + ("A3", "13.10.2026 14:30:15"), + ("A4", "14:30"), + ("A5", "13. Okt 26"), + ("A6", "FALSCH"), + ("A7", "#NV"), + ] { + assert_eq!(edit_text(&s, cell), text, "{cell}"); + } + // A month-and-year format would read back as the first of the month: the short date instead. + s.execute("cell.set", json!({"cell": "B1", "input": "10/13/2026"})).unwrap(); + s.execute("home.numberFormat", json!({"range": "B1", "code": "mmm-yy"})).unwrap(); + assert_eq!(shown(&s, "B1"), "Okt 26"); + assert_eq!(edit_text(&s, "B1"), "13.10.2026"); +} + +#[test] +fn typed_values_enter_with_the_format_german_typing_picks() { + let mut s = session(); + // The interface reads `13.10.2026` and enters the serial with the short date format. + s.execute("cell.set", json!({"cell": "A1", "input": "46308", "autoFormat": "m/d/yyyy"})).unwrap(); + s.execute("cell.set", json!({"cell": "A2", "input": "5.5", "autoFormat": "#,##0.00 \"€\";[Red]-#,##0.00 \"€\""})).unwrap(); + s.execute("selection.set", json!({"range": "B1:B2"})).unwrap(); + s.execute("range.fill", json!({"range": "B1:B2", "input": "-0.032", "autoFormat": "0.00%"})).unwrap(); + let d = s.doc().unwrap(); + let sh = d.wb.active().unwrap(); + let fmt = |c: &str| d.wb.styles.get(sh.style_id(CellRef::parse(c).unwrap())).num_fmt.as_str().to_string(); + assert_eq!(sh.value(CellRef::parse("A1").unwrap()), Value::Number(46308.0)); + assert_eq!(fmt("A1"), "m/d/yyyy"); + assert_eq!(shown(&s, "A2"), "5,50 €"); + assert_eq!(shown(&s, "B2"), "-3,20%"); + // A cell already formatted keeps its format, as automatic formats do; formulas ignore it. + s.execute("home.numberFormat", json!({"range": "C1", "code": "0.000"})).unwrap(); + s.execute("cell.set", json!({"cell": "C1", "input": "1.5", "autoFormat": "0%"})).unwrap(); + s.execute("cell.set", json!({"cell": "C2", "input": "=1+1", "autoFormat": "0%"})).unwrap(); + let d = s.doc().unwrap(); + let sh = d.wb.active().unwrap(); + let fmt = |c: &str| d.wb.styles.get(sh.style_id(CellRef::parse(c).unwrap())).num_fmt.as_str().to_string(); + assert_eq!(fmt("C1"), "0.000"); + assert_eq!(fmt("C2"), "General"); + // Programmatic calls without it are unchanged, and a format longer than Excel allows is ignored. + s.execute("cell.set", json!({"cell": "D1", "input": "12%", "autoFormat": "0".repeat(300)})).unwrap(); + let d = s.doc().unwrap(); + let sh = d.wb.active().unwrap(); + assert_eq!(d.wb.styles.get(sh.style_id(CellRef::parse("D1").unwrap())).num_fmt.as_str(), "0%"); +} + +#[test] +fn show_formulas_and_print_text_follow_the_formula_language() { + let mut s = session(); + s.execute("cell.set", json!({"cell": "A1", "input": "=SUM(1.5,2,{1,2;3,4})+IF(TRUE,1,#N/A)"})).unwrap(); + std::sync::Arc::make_mut(&mut s.active_mut().unwrap().wb).sheet_mut(0).unwrap().show_formulas = true; + let d = s.doc().unwrap(); + assert_eq!(display::cell_text_in(&d.wb, 0, CellRef::parse("A1").unwrap(), &GERMAN), "=SUMME(1,5;2;{1.2;3.4})+WENN(WAHR;1;#NV)"); + assert_eq!(display::cell_text(&d.wb, d.wb.active().unwrap(), CellRef::parse("A1").unwrap()), "=SUM(1.5,2,{1,2;3,4})+IF(TRUE,1,#N/A)"); + // Headers and footers print the date as the region's short date. + let (date, _) = gridcraft_engine::cmd::print::hf_date_time_in(&d.wb, &GERMAN); + assert!(date.split('.').count() == 3 && date.len() == 10, "{date}"); +} + +#[test] +fn csv_is_written_and_read_as_german_excel_does_in_a_german_session() { + let mut s = session(); + s.execute("range.setValues", json!({"range": "A1", "values": [[1234.5, "x;y", true], ["10/13/2026", 0.25, "=NA()"]]})).unwrap(); + s.execute("home.numberFormat", json!({"range": "B2", "code": "0%"})).unwrap(); + let wb = s.doc().unwrap().wb.clone(); + // A canonical session writes commas and en-US numbers, as before. + let plain = String::from_utf8(gridcraft_engine::io::save_bytes(&wb, "out.csv").unwrap()).unwrap(); + assert_eq!(plain, "1234.5,x;y,TRUE\r\n10/13/2026,25%,#N/A\r\n"); + // A German one writes `;` (the list separator where the decimal separator is `,`) and German values. + let german = String::from_utf8(gridcraft_engine::io::save_bytes_in(&wb, "out.csv", &GERMAN).unwrap()).unwrap(); + assert_eq!(german, "1234,5;\"x;y\";WAHR\r\n13.10.2026;25%;#NV\r\n"); + // ... and reads it back as the same values. + let (back, _) = gridcraft_engine::io::open_bytes_with_password_in("out.csv", german.as_bytes(), None, &GERMAN).unwrap(); + let sh = back.sheet(0).unwrap(); + assert_eq!(back.csv_delimiter, Some(b';')); + assert_eq!(sh.value(CellRef::parse("A1").unwrap()), Value::Number(1234.5)); + assert_eq!(sh.value(CellRef::parse("C1").unwrap()), Value::Bool(true)); + assert_eq!(sh.value(CellRef::parse("A2").unwrap()), Value::Number(46308.0)); + assert_eq!(sh.value(CellRef::parse("B2").unwrap()), Value::Number(0.25)); + assert_eq!(sh.value(CellRef::parse("C2").unwrap()), Value::Error(gridcraft_core::CellError::NA)); + // A comma-separated file is en-US style whoever opens it. + let (us, _) = gridcraft_engine::io::open_bytes_with_password_in("us.csv", b"1.5,10/13/2026\r\n", None, &GERMAN).unwrap(); + assert_eq!(us.sheet(0).unwrap().value(CellRef::parse("A1").unwrap()), Value::Number(1.5)); + assert_eq!(us.sheet(0).unwrap().value(CellRef::parse("B1").unwrap()), Value::Number(46308.0)); + // Workbook formats never change: XLSX written from a German session equals a canonical one. + assert_eq!(gridcraft_engine::io::save_bytes_in(&wb, "b.json", &GERMAN).unwrap(), gridcraft_engine::io::save_bytes(&wb, "b.json").unwrap()); +} + +#[test] +fn file_commands_use_the_session_locale_and_default_to_canonical() { + let dir = std::env::temp_dir().join(format!("gridcraft-german-csv-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("Liste.csv").to_string_lossy().into_owned(); + let mut s = session(); + s.execute("range.setValues", json!({"range": "A1", "values": [[1.5, 2.25]]})).unwrap(); + s.execute("file.saveAs", json!({"path": path})).unwrap(); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "1.5,2.25\r\n"); + s.display_locale = GERMAN; + s.execute("file.saveAs", json!({"path": path})).unwrap(); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "1,5;2,25\r\n"); + s.execute("file.open", json!({"path": path})).unwrap(); + let d = s.doc().unwrap(); + assert_eq!(d.wb.sheet(0).unwrap().value(CellRef::parse("B1").unwrap()), Value::Number(2.25)); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/numfmt/src/lib.rs b/crates/numfmt/src/lib.rs index 6280e1d268..df434cce90 100644 --- a/crates/numfmt/src/lib.rs +++ b/crates/numfmt/src/lib.rs @@ -1,5 +1,10 @@ //! GridCraft number formats: Excel format codes (`#,##0.00;[Red](#,##0.00)`, dates, fractions, -//! scientific…), the General display format and the `TEXT()` worksheet function. en-US only. +//! scientific…), the General display format and the `TEXT()` worksheet function. +//! +//! Codes are always canonical (en-US syntax: `.` is the decimal point, `,` groups thousands, `/` +//! separates dates). [`format_value`] draws en-US; [`format_value_in`] draws in a regional format +//! ([`NumberLocale`]: its separators, month and day names, date separator, and the built-in date +//! formats it shows its own way). `TEXT()` stays en-US. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] @@ -10,7 +15,7 @@ mod render; use std::sync::Arc; -use gridcraft_core::{CellError, DateSystem, Value}; +use gridcraft_core::{CellError, DateSystem, NumberLocale, Value}; pub use builtin::{builtin_format, builtin_id}; pub use decimal::format_general_fit; @@ -256,9 +261,9 @@ impl NumberFormat { } /// Formats a number. The flag is true when the value cannot be shown (date out of range). - fn format_number(&self, n: f64, sys: DateSystem) -> (Formatted, bool) { + fn format_number(&self, n: f64, sys: DateSystem, loc: &NumberLocale) -> (Formatted, bool) { let general = |n: f64| Formatted { - text: format_general_fit(n, GENERAL_WIDTH).unwrap_or_else(|| OVERFLOW.into()), + text: format_general_fit(n, GENERAL_WIDTH).map_or_else(|| OVERFLOW.into(), |t| render::localize_general(t, loc)), color: None, fill: None, numeric: true, @@ -269,19 +274,19 @@ impl NumberFormat { let Some((sec, drop_sign)) = self.select(n) else { return (general(n), false) }; let minus = n < 0.0 && !drop_sign; let out: Option = match sec.kind { - SecKind::Text => Some(render::render_text(sec, &format_general_fit(n, GENERAL_WIDTH).unwrap_or_default())), + SecKind::Text => Some(render::render_text(sec, &render::localize_general(format_general_fit(n, GENERAL_WIDTH).unwrap_or_default(), loc))), SecKind::General => { let width = if minus { GENERAL_WIDTH - 1 } else { GENERAL_WIDTH }; - Some(render::render_general(sec, n.abs(), width)) + Some(render::render_general(sec, n.abs(), width, loc)) } SecKind::Date => { if minus { None } else { - render::render_date(sec, n.abs(), sys) + render::render_date(sec, n.abs(), sys, loc) } } - SecKind::Number => Some(render::render_number(sec, n.abs())), + SecKind::Number => Some(render::render_number(sec, n.abs(), loc)), }; let Some(mut out) = out else { return (Formatted { text: OVERFLOW.into(), color: sec.color, fill: None, numeric: true }, true); @@ -304,22 +309,42 @@ impl NumberFormat { } } -/// Formats a cell value for display. +/// Formats a cell value for display (en-US, the canonical spelling). pub fn format_value(v: &Value, fmt: &NumberFormat, sys: DateSystem) -> Formatted { + format_value_in(v, fmt, sys, &NumberLocale::EN_US) +} + +/// Formats a cell value for display in a regional format. Booleans and error values keep their +/// canonical words: they belong to the formula language, which the caller knows. +pub fn format_value_in(v: &Value, fmt: &NumberFormat, sys: DateSystem, loc: &NumberLocale) -> Formatted { match v { Value::Empty => Formatted::default(), Value::Number(n) if !n.is_finite() => Formatted { text: CellError::Num.as_str().into(), ..Formatted::default() }, - Value::Number(n) => fmt.format_number(*n, sys).0, + Value::Number(n) => { + // A built-in format the region draws its own way (id 14 `m/d/yyyy` is the short date). + let regional = loc.regional_code(fmt.code()); + if regional == fmt.code() { + fmt.format_number(*n, sys, loc).0 + } else { + NumberFormat::parse_cached(regional).format_number(*n, sys, loc).0 + } + } Value::Text(t) => fmt.format_text(t), Value::Bool(b) => Formatted { text: if *b { "TRUE" } else { "FALSE" }.into(), ..Formatted::default() }, Value::Error(e) => Formatted { text: e.as_str().into(), ..Formatted::default() }, Value::Array(a) => match a.data.first() { Some(Value::Array(_)) | None => Formatted::default(), - Some(first) => format_value(first, fmt, sys), + Some(first) => format_value_in(first, fmt, sys, loc), }, } } +/// General text of a number for a column `width` characters wide ([`format_general_fit`]), +/// with the locale's decimal separator. +pub fn format_general_fit_in(n: f64, width: usize, loc: &NumberLocale) -> Option { + format_general_fit(n, width).map(|t| render::localize_general(t, loc)) +} + /// A small per-thread cache of parsed format codes for callers that format many values with the /// same code (`TEXT` over a range, chart labels). A parsed format depends on the code text only /// (the date system and values are applied when formatting), so the code is the whole key. @@ -417,7 +442,7 @@ pub fn text_function(v: &Value, code: &str, sys: DateSystem) -> Result ",".into(), Tok::Percent => "%".into(), Tok::Exp { upper, plus } => format!("{}{}", if *upper { 'E' } else { 'e' }, if *plus { '+' } else { '-' }), - Tok::Slash => "/".into(), + Tok::Slash | Tok::DateSep => "/".into(), Tok::Fill(_) | Tok::At | Tok::General | Tok::Date(_) => String::new(), } } @@ -122,6 +124,8 @@ pub(crate) struct Section { pub fraction: bool, pub currency: bool, pub fill: bool, + /// The language id of a `[$-409]` or `[$€-407]` tag: month and day names follow it. + pub lcid: Option, } /// Splits a code into sections at unquoted, unescaped, unbracketed semicolons. @@ -251,11 +255,12 @@ struct Raw { color: Option, cond: Option, currency: bool, + lcid: Option, } fn tokenize(src: &str) -> Raw { let cs: Vec = src.chars().collect(); - let mut raw = Raw { toks: Vec::new(), color: None, cond: None, currency: false }; + let mut raw = Raw { toks: Vec::new(), color: None, cond: None, currency: false, lcid: None }; let toks = &mut raw.toks; // With no `]` at or after a `[`, that `[` is a literal; checking this once keeps the scan linear. let last_close = cs.iter().rposition(|&c| c == ']'); @@ -306,7 +311,7 @@ fn tokenize(src: &str) -> Raw { let close = if last_close.is_some_and(|l| l > i) { cs.get(i + 1..).and_then(|r| r.iter().position(|&d| d == ']')) } else { None }; if let Some(p) = close { let content: String = cs.get(i + 1..i + 1 + p).unwrap_or(&[]).iter().collect(); - bracket(&mut raw_parts(toks, &mut raw.color, &mut raw.cond, &mut raw.currency), &content); + bracket(&mut raw_parts(toks, &mut raw.color, &mut raw.cond, &mut raw.currency, &mut raw.lcid), &content); i += p + 2; } else { push_lit(toks, "["); @@ -391,10 +396,17 @@ struct RawParts<'a> { color: &'a mut Option, cond: &'a mut Option, currency: &'a mut bool, + lcid: &'a mut Option, } -fn raw_parts<'a>(toks: &'a mut Vec, color: &'a mut Option, cond: &'a mut Option, currency: &'a mut bool) -> RawParts<'a> { - RawParts { toks, color, cond, currency } +fn raw_parts<'a>( + toks: &'a mut Vec, + color: &'a mut Option, + cond: &'a mut Option, + currency: &'a mut bool, + lcid: &'a mut Option, +) -> RawParts<'a> { + RawParts { toks, color, cond, currency, lcid } } /// Handles the content of a `[...]` tag. @@ -411,11 +423,15 @@ fn bracket(p: &mut RawParts<'_>, content: &str) { return; } if let Some(body) = content.strip_prefix('$') { - let sym = body.split('-').next().unwrap_or(""); + let (sym, tag) = body.split_once('-').unwrap_or((body, "")); if !sym.is_empty() { *p.currency = true; push_lit(p.toks, sym); } + // `[$-407]`, `[$€-407]`, `[$-de-DE]`-style tags: the hex language id, when there is one. + if let Ok(id) = u32::from_str_radix(tag.get(tag.len().saturating_sub(4)..).unwrap_or(""), 16) { + *p.lcid = Some(id & 0xFFFF); + } return; } let mut chars = lc.chars(); @@ -438,7 +454,7 @@ fn date_part(t: &Tok) -> Option { } fn analyze(raw: Raw) -> Section { - let Raw { mut toks, color, cond, currency } = raw; + let Raw { mut toks, color, cond, currency, lcid } = raw; let is_date = toks.iter().any(|t| matches!(t, Tok::Date(_))); let has_general = toks.iter().any(|t| matches!(t, Tok::General)); let mut sec = Section { @@ -455,6 +471,7 @@ fn analyze(raw: Raw) -> Section { fraction: false, currency, fill: toks.iter().any(|t| matches!(t, Tok::Fill(_))), + lcid, }; if is_date { sec.kind = SecKind::Date; @@ -489,6 +506,8 @@ fn analyze(raw: Raw) -> Section { } match t { Tok::Date(_) | Tok::Fill(_) => out.push(t.clone()), + // Observed: `dd/mm/yyyy` shows 13.10.2026 with the de-DE regional format. + Tok::Slash => out.push(Tok::DateSep), other => push_lit(&mut out, &other.literal_text()), } i += 1; diff --git a/crates/numfmt/src/render.rs b/crates/numfmt/src/render.rs index 4408d383fe..f7f87b1c7d 100644 --- a/crates/numfmt/src/render.rs +++ b/crates/numfmt/src/render.rs @@ -1,7 +1,8 @@ //! Rendering a value with a parsed section. use gridcraft_core::DateSystem; -use gridcraft_core::date::{MONTHS, WEEKDAYS, datetime_from_serial, max_serial}; +use gridcraft_core::date::{datetime_from_serial, max_serial}; +use gridcraft_core::locale::{self, NumberLocale, Region}; use crate::decimal::{decimal_digits, fixed, fixed_from_digits, format_general_fit}; use crate::parse::{DatePart, Section, Tok}; @@ -45,12 +46,17 @@ pub(crate) fn render_text(sec: &Section, text: &str) -> Out { out } +/// General text with the locale's decimal separator (General never groups thousands). +pub(crate) fn localize_general(text: String, loc: &NumberLocale) -> String { + if loc.decimal == '.' { text } else { text.replace('.', loc.decimal.encode_utf8(&mut [0; 4])) } +} + /// Renders a number through a General section (literals around the General keyword). -pub(crate) fn render_general(sec: &Section, v: f64, width: usize) -> Out { +pub(crate) fn render_general(sec: &Section, v: f64, width: usize, loc: &NumberLocale) -> Out { let mut out = Out::default(); for t in &sec.toks { match t { - Tok::General => out.push(&format_general_fit(v, width).unwrap_or_else(|| "#".repeat(width.clamp(1, 11)))), + Tok::General => out.push(&format_general_fit(v, width).map_or_else(|| "#".repeat(width.clamp(1, 11)), |t| localize_general(t, loc))), Tok::Fill(c) => out.fill(*c), other => out.push(&other.literal_text()), } @@ -58,8 +64,19 @@ pub(crate) fn render_general(sec: &Section, v: f64, width: usize) -> Out { out } +/// The region whose month and day names a section uses: a `[$-409]`-style tag's, else the +/// locale's. +fn names_region(sec: &Section, loc: &NumberLocale) -> &'static Region { + match sec.lcid { + Some(0x0409) => &locale::EN_US, + // German (Germany, Switzerland, Austria, Luxembourg, Liechtenstein). + Some(0x0407 | 0x0807 | 0x0C07 | 0x1007 | 0x1407) => &locale::DE_DE, + _ => loc.region, + } +} + /// Renders a date/time. `None` when the serial is outside the supported range. -pub(crate) fn render_date(sec: &Section, v: f64, sys: DateSystem) -> Option { +pub(crate) fn render_date(sec: &Section, v: f64, sys: DateSystem, loc: &NumberLocale) -> Option { if !v.is_finite() || !(0.0..max_serial(sys) as f64 + 1.0).contains(&v) { return None; } @@ -80,8 +97,12 @@ pub(crate) fn render_date(sec: &Section, v: f64, sys: DateSystem) -> Option let second = (ms / 1000) % 60; let milli = ms % 1000; let pad = |n: i64, w: u8| if w >= 2 { format!("{:0w$}", n, w = w.min(20) as usize) } else { n.to_string() }; - let month_name = MONTHS.get((dt.month as usize).wrapping_sub(1)).copied().unwrap_or(""); - let day_name = WEEKDAYS.get(dt.weekday as usize).copied().unwrap_or(""); + let names = names_region(sec, loc); + let month_index = (dt.month as usize).wrapping_sub(1); + let month_name = names.months.get(month_index).copied().unwrap_or(""); + let month_short = names.months_short.get(month_index).copied().unwrap_or(""); + let day_name = names.weekdays.get(dt.weekday as usize).copied().unwrap_or(""); + let day_short = names.weekdays_short.get(dt.weekday as usize).copied().unwrap_or(""); let mut out = Out::default(); for t in &sec.toks { match t { @@ -96,13 +117,13 @@ pub(crate) fn render_date(sec: &Section, v: f64, sys: DateSystem) -> Option } DatePart::Month(n) => match n { 1 | 2 => pad(dt.month as i64, n), - 3 => month_name.chars().take(3).collect(), + 3 => month_short.to_string(), 5 => month_name.chars().take(1).collect(), _ => month_name.to_string(), }, DatePart::Day(n) => match n { 1 | 2 => pad(dt.day as i64, n), - 3 => day_name.chars().take(3).collect(), + 3 => day_short.to_string(), _ => day_name.to_string(), }, DatePart::Hour(n) => { @@ -117,8 +138,8 @@ pub(crate) fn render_date(sec: &Section, v: f64, sys: DateSystem) -> Option DatePart::Minute(n) => pad(minute, n), DatePart::Second(n) => pad(second, n), DatePart::SubSec(n) => { - let mut s = format!(".{:03}", milli); - s.truncate(1 + (n as usize).min(3)); + let mut s = format!("{}{:03}", loc.decimal, milli); + s.truncate(loc.decimal.len_utf8() + (n as usize).min(3)); if n > 3 { s.push_str(&"0".repeat(n as usize - 3)); } @@ -132,6 +153,7 @@ pub(crate) fn render_date(sec: &Section, v: f64, sys: DateSystem) -> Option }; out.push(&s); } + Tok::DateSep => out.push(loc.region.date_separator.encode_utf8(&mut [0; 4])), Tok::Fill(c) => out.fill(*c), other => out.push(&other.literal_text()), } @@ -142,7 +164,7 @@ pub(crate) fn render_date(sec: &Section, v: f64, sys: DateSystem) -> Option /// Assigns integer digits right-to-left to the placeholders at `idxs`; the leftmost placeholder /// takes all remaining digits. `force_zero` shows `0` in the units placeholder when there are no /// digits. A blank `?` pads with a space, and so does the thousands separator it would carry. -fn fill_int(toks: &[Tok], idxs: &[usize], digits: &str, thousands: bool, force_zero: bool, pieces: &mut [String]) { +fn fill_int(toks: &[Tok], idxs: &[usize], digits: &str, thousands: Option, force_zero: bool, pieces: &mut [String]) { let ds: Vec = digits.chars().collect(); let mut rem = ds.len(); let mut pos = 0usize; @@ -155,8 +177,11 @@ fn fill_int(toks: &[Tok], idxs: &[usize], digits: &str, thousands: bool, force_z }; let mut rev = String::new(); let push_digit = |c: char, rev: &mut String, pos: &mut usize| { - if thousands && *pos > 0 && pos.is_multiple_of(3) { - rev.push(','); + if let Some(sep) = thousands + && *pos > 0 + && pos.is_multiple_of(3) + { + rev.push(sep); } rev.push(c); *pos += 1; @@ -167,7 +192,7 @@ fn fill_int(toks: &[Tok], idxs: &[usize], digits: &str, thousands: bool, force_z '0' => push_digit('0', &mut rev, &mut pos), _ if force_zero && k == 0 => push_digit('0', &mut rev, &mut pos), '?' => { - if thousands && pos > 0 && pos.is_multiple_of(3) { + if thousands.is_some() && pos > 0 && pos.is_multiple_of(3) { rev.push(' '); } rev.push(' '); @@ -187,15 +212,13 @@ fn fill_int(toks: &[Tok], idxs: &[usize], digits: &str, thousands: bool, force_z } } -fn with_commas(digits: &str, thousands: bool) -> String { - if !thousands { - return digits.to_string(); - } +fn with_commas(digits: &str, thousands: Option) -> String { + let Some(sep) = thousands else { return digits.to_string() }; let n = digits.chars().count(); let mut s = String::with_capacity(n + n / 3); for (i, c) in digits.chars().enumerate() { if i > 0 && (n - i).is_multiple_of(3) { - s.push(','); + s.push(sep); } s.push(c); } @@ -230,8 +253,10 @@ fn digit_indices(toks: &[Tok], range: std::ops::Range) -> Vec { range.filter(|&i| matches!(toks.get(i), Some(Tok::Digit(_)))).collect() } -/// Renders a non-negative number through a number section. -pub(crate) fn render_number(sec: &Section, v: f64) -> Out { +/// Renders a non-negative number through a number section, with the locale's decimal and +/// thousands separators. +pub(crate) fn render_number(sec: &Section, v: f64, loc: &NumberLocale) -> Out { + let thousands = sec.thousands.then_some(loc.thousands); let mut x = v.abs(); // Observed: `%` in a scientific section is only a literal (`#%e+#` shows 123456.789 as // `1%e+5`). @@ -254,7 +279,7 @@ pub(crate) fn render_number(sec: &Section, v: f64) -> Out { let mut zero = false; if sec.fraction { lits = vec![None; n]; - render_fraction(sec, x, &mut pieces, &mut lits); + render_fraction(sec, x, thousands, &mut pieces, &mut lits); } else { let point = toks.iter().position(|t| matches!(t, Tok::Point)); let exp = toks.iter().position(|t| matches!(t, Tok::Exp { .. })); @@ -309,7 +334,7 @@ pub(crate) fn render_number(sec: &Section, v: f64) -> Out { if let Some(b) = before.get_mut(first) { *b = sign.to_string(); } - fill_int(toks, &exp_idx, &digits, false, false, &mut pieces); + fill_int(toks, &exp_idx, &digits, None, false, &mut pieces); } None => { if let Some(p) = pieces.get_mut(e_at) { @@ -322,19 +347,19 @@ pub(crate) fn render_number(sec: &Section, v: f64) -> Out { fixed(x, frac_idx.len()) }; zero = int_s.chars().chain(frac_s.chars()).all(|c| c == '0'); - fill_int(toks, &int_idx, &int_s, sec.thousands, false, &mut pieces); + fill_int(toks, &int_idx, &int_s, thousands, false, &mut pieces); if int_idx.is_empty() && !int_s.is_empty() && let Some(p) = point.or(exp) && let Some(b) = before.get_mut(p) { - *b = with_commas(&int_s, sec.thousands); + *b = with_commas(&int_s, thousands); } fill_frac(toks, &frac_idx, &frac_s, &mut pieces); if let Some(p) = point && let Some(s) = pieces.get_mut(p) { - *s = ".".into(); + *s = loc.decimal.to_string(); } } let mut out = Out::default(); @@ -432,7 +457,7 @@ fn blank_range(toks: &[Tok], range: std::ops::Range, spaces: bool, pieces /// becomes spaces when the whole part or the numerator has a `?`); when the fraction is zero and /// the numerator has no `0` placeholder, the separator, numerator, bar and denominator disappear /// (or become spaces when any part has a `?`) and the whole part shows at least `0`. -fn render_fraction(sec: &Section, x: f64, pieces: &mut [String], lits: &mut [Option]) { +fn render_fraction(sec: &Section, x: f64, thousands: Option, pieces: &mut [String], lits: &mut [Option]) { let toks = &sec.toks; let is_digit = |i: usize| matches!(toks.get(i), Some(Tok::Digit(_))); let Some(slash) = toks.iter().position(|t| matches!(t, Tok::Slash)) else { return }; @@ -480,7 +505,7 @@ fn render_fraction(sec: &Section, x: f64, pieces: &mut [String], lits: &mut [Opt let whole_s = if whole >= 1.0 { fixed(whole, 0).0 } else { String::new() }; let num_zero_ph = num_idx.iter().any(|&i| toks.get(i) == Some(&Tok::Digit('0'))); if has_whole && num == 0 && !num_zero_ph { - fill_int(toks, &whole_idx, &whole_s, sec.thousands, true, pieces); + fill_int(toks, &whole_idx, &whole_s, thousands, true, pieces); let spaces = has_q(&whole_idx) || has_q(&num_idx) || has_q(&den_idx); blank_range(toks, sep_start..den_end, spaces, pieces, lits); return; @@ -489,14 +514,14 @@ fn render_fraction(sec: &Section, x: f64, pieces: &mut [String], lits: &mut [Opt // A zero value shows `0` in a `?` or `0` units place, nothing in a `#` one. let units_hash = whole_idx.last().is_some_and(|&i| toks.get(i) == Some(&Tok::Digit('#'))); let force = whole_s.is_empty() && num == 0 && !units_hash; - fill_int(toks, &whole_idx, &whole_s, sec.thousands, force, pieces); + fill_int(toks, &whole_idx, &whole_s, thousands, force, pieces); let whole_shown = whole_idx.iter().any(|&i| pieces.get(i).is_some_and(|p| p.chars().any(|c| c.is_ascii_digit()))); if !whole_shown { let spaces = has_q(&whole_idx) || has_q(&num_idx); blank_range(toks, sep_start..num_start, spaces, pieces, lits); } } - fill_int(toks, &num_idx, &num.to_string(), false, true, pieces); + fill_int(toks, &num_idx, &num.to_string(), None, true, pieces); if let Some(p) = pieces.get_mut(slash) { *p = "/".into(); } diff --git a/crates/numfmt/src/tests.rs b/crates/numfmt/src/tests.rs index e5687f5bd8..542eb3f5da 100644 --- a/crates/numfmt/src/tests.rs +++ b/crates/numfmt/src/tests.rs @@ -637,7 +637,7 @@ fn text_uncached(v: &Value, code: &str, sys: DateSystem) -> Result String { /// Picks comma, semicolon or tab: the candidate with the most consistent count per line /// (outside quotes) over the first non-blank lines. A candidate must appear on every such line, otherwise comma wins. -pub fn sniff_delimiter(text: &str, quote: char) -> u8 { +#[cfg(test)] +pub(crate) fn sniff_delimiter(text: &str, quote: char) -> u8 { + sniff_delimiter_preferring(text, quote, None) +} + +/// [`sniff_delimiter`], taking `preferred` whenever every line holds it. A reader whose numbers +/// use a decimal comma prefers `;` (Excel splits on its list separator there), so `1,5;2,25` is +/// two fields, not three. +pub fn sniff_delimiter_preferring(text: &str, quote: char, preferred: Option) -> u8 { let mut best = (b',', 0usize, 0usize); for d in [',', ';', '\t'] { let mut counts = vec![]; @@ -80,6 +87,9 @@ pub fn sniff_delimiter(text: &str, quote: char) -> u8 { } let min = counts.iter().copied().min().unwrap_or(0); let total: usize = counts.iter().sum(); + if min > 0 && preferred == Some(d as u8) { + return d as u8; + } if min > 0 && (min, total) > (best.1, best.2) { best = (d as u8, min, total); } @@ -133,10 +143,29 @@ fn parse_records(text: &str, delim: char, quote: char, mut on_record: impl FnMut } } +/// A field spelled as the formula language's boolean or error value (`WAHR`, `#NV`); the +/// canonical words are left to the parser. English reads every field as `parse_input` does. +fn local_word(field: &str, words: gridcraft_formula::FormulaLocale) -> Option { + if words == gridcraft_formula::FormulaLocale::En { + return None; + } + let t = field.trim(); + let same = |a: &str| a.chars().flat_map(char::to_uppercase).eq(t.chars().flat_map(char::to_uppercase)); + let value = if same(words.boolean_name(true)) { + Value::Bool(true) + } else if same(words.boolean_name(false)) { + Value::Bool(false) + } else { + Value::Error(gridcraft_core::CellError::ALL.into_iter().find(|e| same(words.error_name(*e)))?) + }; + Some(gridcraft_core::parse::Parsed { value, format: None }) +} + pub fn read_csv(bytes: &[u8], opts: &CsvOptions) -> Result { let text = decode_text(bytes); let quote = if opts.quote == 0 { '"' } else { opts.quote as char }; - let delim = if opts.delimiter == 0 { sniff_delimiter(&text, quote) } else { opts.delimiter } as char; + let preferred = (opts.number_locale.decimal == ',').then_some(b';'); + let delim = if opts.delimiter == 0 { sniff_delimiter_preferring(&text, quote, preferred) } else { opts.delimiter } as char; if delim == quote { return Err(IoError::Format("the delimiter and quote character must differ".into())); } @@ -147,6 +176,12 @@ pub fn read_csv(bytes: &[u8], opts: &CsvOptions) -> Result { let mut styles = std::mem::take(&mut wb.styles); let mut fmt_cache: Vec<(&'static str, StyleId)> = vec![]; let mut row: u32 = 0; + // Comma-separated fields mean en-US numbers; other separators follow the reader's locale. + let (number_locale, words) = if delim == ',' { + (gridcraft_core::NumberLocale::EN_US, gridcraft_formula::FormulaLocale::En) + } else { + (opts.number_locale, opts.formula_locale) + }; parse_records(&text, delim, quote, |rec| { if row >= MAX_ROWS { return false; @@ -161,7 +196,7 @@ pub fn read_csv(bytes: &[u8], opts: &CsvOptions) -> Result { } let pos = CellRef::new(row, col as u32); let cell = if opts.parse_values { - let p = parse_input(&f, opts.date_system); + let p = local_word(&f, words).unwrap_or_else(|| gridcraft_core::parse::parse_input_in(&f, opts.date_system, &number_locale)); let style = match p.format { Some(code) => match fmt_cache.iter().find(|(c, _)| *c == code) { Some((_, id)) => *id, diff --git a/crates/xlsx/src/lib.rs b/crates/xlsx/src/lib.rs index 3300f97fe5..93a06f69e7 100644 --- a/crates/xlsx/src/lib.rs +++ b/crates/xlsx/src/lib.rs @@ -147,11 +147,23 @@ pub struct CsvOptions { pub date_system: DateSystem, /// Interpret fields like typed input (numbers, dates, booleans…); otherwise keep text. pub parse_values: bool, + /// The regional format fields are read in (`1,5`, `13.10.2026` in de-DE) and the formula + /// language whose booleans and error values they may hold (`WAHR`, `#NV`). A file whose fields + /// are separated by commas is read in en-US whatever these say: commas there mean en-US style. + pub number_locale: gridcraft_core::NumberLocale, + pub formula_locale: gridcraft_formula::FormulaLocale, } impl Default for CsvOptions { fn default() -> Self { - CsvOptions { delimiter: b',', quote: b'"', date_system: DateSystem::D1900, parse_values: true } + CsvOptions { + delimiter: b',', + quote: b'"', + date_system: DateSystem::D1900, + parse_values: true, + number_locale: gridcraft_core::NumberLocale::EN_US, + formula_locale: gridcraft_formula::FormulaLocale::En, + } } } diff --git a/crates/xlsx/src/tests/csv_tests.rs b/crates/xlsx/src/tests/csv_tests.rs index cc52072e71..e17d6e69bd 100644 --- a/crates/xlsx/src/tests/csv_tests.rs +++ b/crates/xlsx/src/tests/csv_tests.rs @@ -78,6 +78,10 @@ fn delimiter_sniffing() { assert_eq!(sniff_delimiter("alpha;beta\r\ngamma\r\n", '"'), b','); assert_eq!(sniff_delimiter("alpha\tbeta\r\ngamma\r\n", '"'), b','); assert_eq!(sniff_delimiter("a;b\r\n\r\nc;d\r\n", '"'), b';'); + // Decimal commas outnumber the separators; a reader using decimal commas prefers `;`. + assert_eq!(sniff_delimiter("1,5;2,25\r\n", '"'), b','); + assert_eq!(crate::csv::sniff_delimiter_preferring("1,5;2,25\r\n", '"', Some(b';')), b';'); + assert_eq!(crate::csv::sniff_delimiter_preferring("1,5\r\n2,5\r\n", '"', Some(b';')), b','); let wb = read_csv(b"x;y\n1,5;2", &CsvOptions { delimiter: 0, ..Default::default() }).unwrap(); assert_eq!(v(&wb, 1, 1), Value::Number(2.0)); // The workbook remembers the separator it was read with, sniffed or given. From 34d5b01061525fb66eaec38d71b0ab70c3c1d73f Mon Sep 17 00:00:00 2001 From: Herr-Enklo <168219178+Herr-Enklo@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:19:05 +0200 Subject: [PATCH 4/7] UI: German numbers, dates and formula words wherever people type and read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the interface in German, everything a person types or reads follows German Excel, while engine commands, files, CLI and MCP stay canonical. - Cells, the formula bar, the editor's starting text, the status bar, number-format previews, Ctrl+; / Ctrl+Shift+; and the Page Layout and print previews show values in the regional format (`1.234,56`, `13.10.2026`) and booleans and errors in the formula language (`WAHR`, `#NV`, `#ÜBERLAUF!`). - Typed constants are read with the regional format (crates/core parse_input_in) and entered through cell.set/range.fill with their canonical value and automatic format (autoFormat). `WAHR` and `#NV` are values; `TRUE`, `#N/A`, `10/13/2026` and `13.10.` stay text, as observed in German Excel. Spanish, Portuguese and Russian keep #443's decimal-comma entry and en-US display. - Dialog fields that hold formulas or typed values speak the formula language: Name Manager's Refers to (and its list), data validation sources (`Ja;Nein`), bounds (`1,5`) and custom formulas, conditional-format values and formulas, calculated fields. OK and the control channel's ui.dialog.confirm convert them to canonical. - File › Options gets an Advanced section with "Use system separators" ("Trennzeichen vom Betriebssystem übernehmen") and the Decimal/Thousands separator fields (options.separators), kept in ui.json, never in a workbook. "System" is the interface language's regional format, which follows the system on first run. - Every engine command run from the interface carries the display locale, so Save As CSV and PDF from a German window are German, and the same commands from the CLI are not. Tests: crates/ui-egui/tests/german_interface.rs (typing, re-editing, English unchanged, separators option and its persistence, CSV, dialogs, the Options page in German) and regional.rs unit tests. Co-Authored-By: Claude Opus 5.5 --- crates/formula/tests/german_observed.rs | 2 +- crates/numfmt/src/tests.rs | 2 +- crates/ui-egui/src/backstage.rs | 25 ++- crates/ui-egui/src/control.rs | 9 +- crates/ui-egui/src/dialogs.rs | 129 ++++++++++++- crates/ui-egui/src/formula_bar.rs | 2 +- crates/ui-egui/src/formula_locale.rs | 106 +++++++++-- crates/ui-egui/src/grid.rs | 14 +- crates/ui-egui/src/i18n.rs | 15 ++ crates/ui-egui/src/i18n/de.rs | 4 + crates/ui-egui/src/keymap.rs | 14 +- crates/ui-egui/src/lib.rs | 41 +++- crates/ui-egui/src/paper_view.rs | 2 +- crates/ui-egui/src/regional.rs | 142 ++++++++++++++ crates/ui-egui/src/ribbon.rs | 8 +- crates/ui-egui/src/tabs.rs | 5 +- crates/ui-egui/tests/german_interface.rs | 229 +++++++++++++++++++++++ 17 files changed, 700 insertions(+), 49 deletions(-) create mode 100644 crates/ui-egui/src/regional.rs create mode 100644 crates/ui-egui/tests/german_interface.rs diff --git a/crates/formula/tests/german_observed.rs b/crates/formula/tests/german_observed.rs index 2f957082f1..ce6c7f2730 100644 --- a/crates/formula/tests/german_observed.rs +++ b/crates/formula/tests/german_observed.rs @@ -662,7 +662,7 @@ fn german_input_never_panics() { "[#Diese Zeile]", ]) .collect(); - let mut seed: u64 = 0x5eed_de_de; + let mut seed: u64 = 0x5eed_dede; let mut next = |n: usize| { seed ^= seed << 13; seed ^= seed >> 7; diff --git a/crates/numfmt/src/tests.rs b/crates/numfmt/src/tests.rs index 542eb3f5da..f219333ac8 100644 --- a/crates/numfmt/src/tests.rs +++ b/crates/numfmt/src/tests.rs @@ -871,7 +871,7 @@ fn closed_brackets_still_hold_their_semicolons() { fn german_regional_format_draws_as_excel_does() { let de = NumberLocale::DE_DE; let g = |code: &str, n: f64| format_value_in(&Value::Number(n), &NumberFormat::parse(code), S, &de).text; - let date = 46308.604_166_666_7; // 2026-10-13 14:30 + let date = 46_308.604_166_666_7; // 2026-10-13 14:30 for (code, n, shown) in [ ("General", 1234.56, "1234,56"), ("General", -1234.56, "-1234,56"), diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index 922baabc59..435f63e9e2 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -825,7 +825,11 @@ fn paint_page(p: &egui::Painter, rect: Rect, paper_w: f32, page: &Json, wb: &gri break; } let cell = gridcraft_engine::core::CellRef::new(row, col); - let text = gridcraft_engine::display::cell_text(wb, sh, cell); + // The preview shows what the print will: values as the interface shows them. + let text = wb + .sheet_index(&sh.name) + .map(|si| gridcraft_engine::display::cell_text_in(wb, si, cell, &crate::i18n::display_locale(p.ctx()))) + .unwrap_or_default(); if !text.is_empty() { let numeric = sh.value(cell).as_f64().is_some(); // Text runs on over empty neighbours, as on the sheet; numbers stay in their cell. @@ -968,6 +972,25 @@ fn options(app: &mut SheetApp, ui: &mut Ui) { app.run_or_alert("options.autoComplete", json!({"on": autocomplete})); } ui.add_space(18.0); + // Advanced › Editing options: the separators numbers are shown and typed with. + section(ui, lang, "Options|Advanced"); + let mut use_system = app.ui.separators.use_system; + if ui.checkbox(&mut use_system, lang.tr("Use system separators")).changed() { + app.run_or_alert("options.separators", json!({"useSystem": use_system})); + } + let shown = crate::regional::number_locale(app.ui.language, &app.ui.separators); + egui::Grid::new("backstage_options_separators").num_columns(2).spacing(vec2(18.0, 8.0)).show(ui, |ui| { + for (key, label, current) in [("decimal", "Decimal separator:", shown.decimal), ("thousands", "Thousands separator:", shown.thousands)] { + ui.label(lang.tr(label)); + let mut text = current.to_string(); + let field = egui::TextEdit::singleline(&mut text).char_limit(1).desired_width(28.0); + if ui.add_enabled(!use_system, field).changed() && text.chars().count() == 1 { + app.run_or_alert("options.separators", json!({"useSystem": false, key: text})); + } + ui.end_row(); + } + }); + ui.add_space(18.0); section(ui, lang, "Options|Save"); let mut autosave = app.grid.autosave(); if ui.checkbox(&mut autosave, lang.tr("Save .xlsx workbooks automatically after every change")).changed() { diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 2ea5cfae01..a1960f4317 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -182,6 +182,11 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) - ("view.theme", "Display Theme", ""), ("view.animations", "Animations", "{on?: bool} (toggles without it) → {on}"), ("options.autoComplete", "Enable AutoComplete for cell values", "{on?: bool} (toggles without it) → {on}"), + ( + "options.separators", + "Use system separators", + "{useSystem?: bool, decimal?: \",\", thousands?: \".\"} → {useSystem, decimal, thousands} (the separators in effect)", + ), ("view.interfaceScale", "Interface Size", "{percent: 75–200 | \"auto\"} (100 = auto; clamped)"), ("ui.dialog", "Open Dialog", ""), ("ui.backstage", "File (Backstage view)", "{page?: home|new|open|info|saveAs|print|export|options|close}"), @@ -252,8 +257,8 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) - "ui.dialog.confirm" => match app.dialog.take() { Some(d) => match d.command { Some(cmd) => { - let params = Json::Object(d.values.clone()); - let r = app.run(cmd, params); + // Fields hold what a person types: the interface's formula and number syntax. + let r = crate::dialogs::canonical_values(app, &d).and_then(|values| app.run(cmd, Json::Object(values))); if r.is_err() { app.dialog = Some(d); } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d16a9a6bfe..f74fd0192d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -795,10 +795,128 @@ impl Dialog { } _ => return None, }; + let mut d = d; + show_typed_fields(app, &mut d); Some(d) } } +/// What a form field holding typed input is: shown in the interface's syntax (German +/// `=SUMME(A1;2,5)`, `1,5`, `a;b;c`), sent canonical. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Typed { + /// A formula, with or without its `=` (a rule's condition, a calculated field). + Formula, + /// `=formula`, a number, or text (a bound, a name's reference). + Value, + /// A list validation's source: `=range` or items separated by the list separator. + Source, +} + +/// The fields of a form that hold typed input, and what they hold. +fn typed_fields(d: &Dialog) -> Vec<(&'static str, Typed)> { + match d.name.as_str() { + "defineName" => vec![("refersTo", Typed::Value)], + "dataValidation" => match d.values.get("type").and_then(Json::as_str) { + Some("list") => vec![("formula1", Typed::Source)], + Some("custom") => vec![("formula1", Typed::Formula)], + _ => vec![("formula1", Typed::Value), ("formula2", Typed::Value)], + }, + "cfQuick" => vec![("value", Typed::Value), ("value2", Typed::Value), ("formula", Typed::Formula)], + "pivotCalculatedField" => vec![("formula", Typed::Formula)], + _ => vec![], + } +} + +/// A list validation's items (no `=`, not a reference or a name). +fn is_item_list(text: &str) -> bool { + !text.starts_with('=') + && (text.starts_with('"') + || gridcraft_engine::formula::parse(text) + .map_or(true, |e| !matches!(e, gridcraft_engine::formula::Expr::Ref(_) | gridcraft_engine::formula::Expr::Name(_)))) +} + +/// Canonical typed input as the interface shows it. +fn show_typed(app: &SheetApp, kind: Typed, text: &str) -> String { + let loc = app.display_locale(); + if loc.is_canonical() || text.is_empty() { + return text.to_string(); + } + let Some(doc) = app.session.active() else { return text.to_string() }; + let formula = |text: &str| { + let (eq, body) = text.strip_prefix('=').map_or((false, text), |b| (true, b)); + let shown = crate::formula_locale::display(&format!("={body}"), loc.formula, &doc.wb, doc.wb.active_sheet); + if eq { shown } else { shown.strip_prefix('=').map(str::to_string).unwrap_or(shown) } + }; + match kind { + Typed::Formula => formula(text), + Typed::Source if is_item_list(text) => text.replace(',', &loc.formula.list_separator().to_string()), + Typed::Source => formula(text), + Typed::Value if text.starts_with('=') => formula(text), + Typed::Value => match gridcraft_engine::core::parse::parse_input(text, doc.wb.date_system) { + parsed if parsed.format.is_none() && matches!(parsed.value, gridcraft_engine::core::Value::Number(_)) => { + text.replace('.', &loc.number.decimal.to_string()) + } + _ => text.to_string(), + }, + } +} + +/// Typed input in the interface's syntax, made canonical for the command. +fn canonical_typed(app: &SheetApp, kind: Typed, text: &str) -> Result { + let loc = app.display_locale(); + if loc.is_canonical() || text.is_empty() { + return Ok(text.to_string()); + } + let Some(doc) = app.session.active() else { return Ok(text.to_string()) }; + let (sheet, cell) = (doc.wb.active_sheet, doc.selection.active); + let formula = |text: &str| -> Result { + let (eq, body) = text.strip_prefix('=').map_or((false, text), |b| (true, b)); + let canonical = crate::formula_locale::canonical_text(&format!("={body}"), &loc, &doc.wb, sheet, cell).map_err(|e| e.to_string())?; + Ok(if eq { canonical } else { canonical.strip_prefix('=').map(str::to_string).unwrap_or(canonical) }) + }; + match kind { + Typed::Formula => formula(text), + Typed::Source if text.starts_with('=') => formula(text), + Typed::Source => Ok(text.split(loc.formula.list_separator()).collect::>().join(",")), + Typed::Value if text.starts_with('=') => formula(text), + Typed::Value => { + let entry = crate::formula_locale::canonical(text, &loc, &doc.wb, sheet, cell).map_err(|e| e.to_string())?; + // A bound is a value, never a forced text: `'` only matters in a cell. + Ok(entry.text.strip_prefix('\'').map(str::to_string).unwrap_or(entry.text)) + } + } +} + +/// Shows a form's typed fields in the interface's syntax (when it opens). +fn show_typed_fields(app: &SheetApp, d: &mut Dialog) { + for (key, kind) in typed_fields(d) { + if let Some(text) = d.values.get(key).and_then(Json::as_str) { + let shown = show_typed(app, kind, text); + d.values.insert(key.into(), json!(shown)); + } + } +} + +/// A form's values with its typed fields made canonical, as its command takes them. Both OK and +/// `ui.dialog.confirm` send these: dialog fields hold what a person types. +pub(crate) fn canonical_values(app: &SheetApp, d: &Dialog) -> Result, String> { + let mut values = d.values.clone(); + for (key, kind) in typed_fields(d) { + if let Some(text) = d.values.get(key).and_then(Json::as_str) { + values.insert(key.into(), json!(canonical_typed(app, kind, text)?)); + } + } + Ok(values) +} + +/// [`build_params`] from [`canonical_values`]. +fn command_params(app: &SheetApp, d: &Dialog) -> Result { + let mut canonical = d.clone(); + canonical.values = canonical_values(app, d)?; + Ok(build_params(&canonical)) +} + /// Initial values of the Data Validation form: the rule stored on the active cell, so OK without /// edits keeps it; a plain list rule when the cell has none. fn validation_defaults(app: &SheetApp) -> Json { @@ -1257,8 +1375,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { confirm = false; } if confirm && let Some(cmd) = d.command { - let params = build_params(&d); - match app.run(cmd, params) { + match command_params(app, &d).and_then(|params| app.run(cmd, params)) { Ok(r) => { open = false; if d.name == "solver" { @@ -1557,7 +1674,11 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: } } cols[1].label(egui::RichText::new(lang.tr("Sample")).strong()); - let preview = app.session.active().map(|doc| gridcraft_engine::display::format(&sample, &code, &doc.wb).text).unwrap_or_default(); + let preview = app + .session + .active() + .map(|doc| gridcraft_engine::display::format_in(&sample, &code, &doc.wb, &app.display_locale()).text) + .unwrap_or_default(); cols[1].label(egui::RichText::new(preview).font(theme::ui_font(15.0))); cols[1].add_space(8.0); cols[1].label(lang.tr("Type (custom format code):")); @@ -2019,7 +2140,7 @@ fn name_manager(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, open: &mu d.list_index = i; } ui.label(n.get("value").map(|v| v.to_string()).unwrap_or_default().chars().take(30).collect::()); - ui.label(field(n, "refersTo")); + ui.label(show_typed(app, Typed::Value, &field(n, "refersTo"))); ui.label(field(n, "scope")); ui.end_row(); } diff --git a/crates/ui-egui/src/formula_bar.rs b/crates/ui-egui/src/formula_bar.rs index 7f1ad87fc1..9bc684703e 100644 --- a/crates/ui-egui/src/formula_bar.rs +++ b/crates/ui-egui/src/formula_bar.rs @@ -78,7 +78,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) + gridcraft_engine::display::edit_text_in(&d.wb, sh, a, &app.display_locale()) }; } // A spilled cell shows the anchor's formula greyed out in Excel; we show it plainly. diff --git a/crates/ui-egui/src/formula_locale.rs b/crates/ui-egui/src/formula_locale.rs index 0490451a71..a15e4ff429 100644 --- a/crates/ui-egui/src/formula_locale.rs +++ b/crates/ui-egui/src/formula_locale.rs @@ -1,30 +1,73 @@ -//! UI formula syntax boundary. Engine commands, control/API callers and XLSX keep English. +//! UI formula and number syntax boundary. Engine commands, control/API callers and XLSX keep +//! English formulas and en-US numbers; people type and see their language's. +use gridcraft_engine::core::{CellError, Value}; +use gridcraft_engine::display::DisplayLocale; use gridcraft_engine::formula::{self, FormulaLocale, ParseError}; use gridcraft_engine::model::Workbook; -pub(crate) fn canonical( +/// Typed input in canonical form: what `cell.set` takes as `input`, and the number format a +/// constant takes when the cell's format is General (`autoFormat`): German `13.10.2026` is the +/// serial `46308` with the short date format. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Entry { + pub text: String, + pub auto_format: Option<&'static str>, +} + +impl Entry { + fn plain(text: impl Into) -> Entry { + Entry { text: text.into(), auto_format: None } + } +} + +/// Canonical text of a number that the engine reads back as exactly `n` (shortest round trip, +/// never an exponent, which en-US input would also read). +fn number_text(n: f64) -> String { + if n == 0.0 { "0".into() } else { format!("{n}") } +} + +/// [`canonical`] for the text only (dialog fields, where no automatic format applies). +pub(crate) fn canonical_text( text: &str, - locale: FormulaLocale, + locale: &DisplayLocale, wb: &Workbook, sheet: usize, cell: gridcraft_engine::core::CellRef, ) -> Result { + canonical(text, locale, wb, sheet, cell).map(|e| e.text) +} + +/// What the user typed, in canonical syntax: formulas in English with `,` and `.`, constants in +/// en-US (with the format their regional spelling picked), the formula language's booleans and +/// error values in English. In a non-English interface the English words typed into a cell stay +/// text, as in German Excel (`TRUE`, `#N/A`). +pub(crate) fn canonical( + text: &str, + loc: &DisplayLocale, + wb: &Workbook, + sheet: usize, + cell: gridcraft_engine::core::CellRef, +) -> Result { + let locale = loc.formula; // The editor hid the `_xludf.` prefixes of this cell's formula; confirming it unchanged keeps // the user-defined calls instead of binding them to the built-ins of the same name. if let Some(stored) = wb.sheet(sheet).and_then(|sh| sh.cell(cell)).filter(|c| c.formula.is_some()).map(|c| c.input_text()) && has_udf_prefix(&stored) && text == display(&stored, locale, wb, sheet) { - return Ok(stored); + return Ok(Entry::plain(stored)); } - 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()); + if loc.is_canonical() || wb.sheet(sheet).is_some_and(|sh| wb.styles.get(sh.style_id(cell)).num_fmt.as_str() == "@") { + return Ok(Entry::plain(text)); } // Same formula detection as the engine's `input_to_cell` (no trimming: " =1" is text). 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 == '(')) - { + let looks_like_formula = body.starts_with('=') + || ((body.starts_with('+') || body.starts_with('-')) && body.chars().nth(1).is_some_and(|c| c.is_ascii_alphabetic() || c == '(')); + if looks_like_formula && locale == FormulaLocale::En { + return Ok(Entry::plain(text)); + } + if looks_like_formula { let known = |name: &str| wb.name(name, sheet).is_some() || wb.table(name).is_some(); // Match the engine's existing convenience for missing closing parentheses. let mut body = body.to_string(); @@ -36,15 +79,46 @@ pub(crate) fn canonical( body.push(')'); expr = formula::parse_input(&body, locale, known); } - return Ok(format!("={}", formula::print(&expr?))); + return Ok(Entry::plain(format!("={}", formula::print(&expr?)))); + } + let canonical_value = |t: &str| gridcraft_engine::core::parse::parse_input(t, wb.date_system).value; + if locale != FormulaLocale::En && !text.starts_with('\'') { + // The formula language's booleans and errors (`WAHR`, `#NV`) are values; the English + // words are text there. + let t = text.trim(); + let same = |word: &str| word.chars().flat_map(char::to_uppercase).eq(t.chars().flat_map(char::to_uppercase)); + for b in [true, false] { + if same(locale.boolean_name(b)) { + return Ok(Entry::plain(if b { "TRUE" } else { "FALSE" })); + } + } + if let Some(e) = CellError::ALL.into_iter().find(|e| same(locale.error_name(*e))) { + return Ok(Entry::plain(e.as_str())); + } + if matches!(canonical_value(text), Value::Bool(_) | Value::Error(_)) { + return Ok(Entry::plain(format!("'{text}"))); + } } - // Decimal-comma number entry follows the same preference, without rewriting text or dates. - let normalized = body.trim().replace(locale.decimal_separator(), "."); - let number = normalized.strip_suffix('%').unwrap_or(&normalized); - if number.parse::().is_ok_and(f64::is_finite) { - return Ok(normalized); + if loc.number.is_canonical() { + // A formula language with a decimal comma but no observed regional format (Spanish, + // Portuguese, Russian) reads `1,5` as a number, without rewriting text or dates. + let normalized = body.trim().replace(locale.decimal_separator(), "."); + let number = normalized.strip_suffix('%').unwrap_or(&normalized); + if number.parse::().is_ok_and(f64::is_finite) { + return Ok(Entry::plain(normalized)); + } + return Ok(Entry::plain(text)); } - Ok(text.to_string()) + let parsed = gridcraft_engine::core::parse::parse_input_in(text, wb.date_system, &loc.number); + Ok(match parsed.value { + Value::Number(n) => Entry { text: number_text(n), auto_format: parsed.format }, + // Text in the regional format that en-US would read as a number or a date (`10/13/2026`, + // `$5`, `1,234.5` in German) is entered as text. + Value::Text(_) if !text.starts_with('\'') && !matches!(canonical_value(text), Value::Text(_) | Value::Empty) => { + Entry::plain(format!("'{text}")) + } + _ => Entry::plain(text), + }) } pub(crate) fn display(text: &str, locale: FormulaLocale, wb: &Workbook, sheet: usize) -> String { diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 97b787ef33..2197edb89c 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -516,7 +516,9 @@ fn look(wb: &Workbook, si: usize, sh: &Sheet, c: CellRef, cf: &mut CfCache) -> ( (Look { style, fill, text_color }, cfl) } -/// Text for a value with General narrowing to the available width. +/// Text for a value with General narrowing to the available width, as `loc` shows values +/// (`1.234,56`, `WAHR`, `#NV` in German). +#[allow(clippy::too_many_arguments)] fn display_text( wb: &Workbook, sh: &Sheet, @@ -525,6 +527,7 @@ fn display_text( st: &Style, width_px: f32, char_w: f32, + loc: &gridcraft_engine::display::DisplayLocale, ) -> (String, Option, bool, Option<(char, usize)>) { if sh.show_formulas && let Some(f) = sh.cell(c).and_then(|x| x.formula.as_ref()) @@ -543,12 +546,12 @@ fn display_text( && let Value::Number(n) = v { let max_chars = ((width_px - 4.0) / char_w).floor().max(1.0) as usize; - return match gridcraft_engine::numfmt::format_general_fit(*n, max_chars.min(11)) { + return match gridcraft_engine::numfmt::format_general_fit_in(*n, max_chars.min(11), &loc.number) { Some(t) => (t, None, true, None), None => ("#".repeat(max_chars.max(1)), None, true, None), }; } - let f = gridcraft_engine::display::format(v, code, wb); + let f = gridcraft_engine::display::format_in(v, code, wb, loc); (f.text, f.color.map(numfmt_color), f.numeric, f.fill) } @@ -1089,7 +1092,8 @@ pub(crate) fn paint_quadrant( } looks.push((anchor, rect, lk, cfl)); } - // Text and per-cell decorations. + // Text and per-cell decorations, with values as the interface shows them. + let display_locale = crate::i18n::display_locale(p.ctx()); for (c, rect, lk, cfl) in &looks { let c = *c; if let Some(picture) = sh.cell_pictures.get(&c) { @@ -1159,7 +1163,7 @@ pub(crate) fn paint_quadrant( // Digits fit by the cell's own font and size, not the default 11pt (9pt marks in narrow columns). let char_w = p.layout_no_wrap("0".to_string(), font.clone(), Color32::PLACEHOLDER).size().x.max(1.0); let avail_w = rect.right() - text_left; - let (mut text, ncolor, numeric, fill_char) = display_text(wb, sh, c, &v, st, avail_w / z.max(0.1) * z, char_w); + let (mut text, ncolor, numeric, fill_char) = display_text(wb, sh, c, &v, st, avail_w / z.max(0.1) * z, char_w, &display_locale); if sh.show_formulas && sh.cell(c).is_some_and(|cell| cell.formula.is_some()) { text = crate::formula_locale::display(&text, crate::i18n::current(p.ctx()).formula_locale(), wb, si); } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 69e8703857..0ad6a46d47 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -183,6 +183,21 @@ pub fn set_current(ctx: &egui::Context, language: Language) { ctx.data_mut(|d| d.insert_temp(lang_key(), language)); } +fn display_key() -> egui::Id { + egui::Id::new("gridcraft.display_locale") +} + +/// How the current frame shows values (regional numbers, the formula language's words); +/// canonical when unset. +pub fn display_locale(ctx: &egui::Context) -> gridcraft_engine::display::DisplayLocale { + ctx.data(|d| d.get_temp(display_key())).unwrap_or_default() +} + +/// Sets [`display_locale`] for the current frame, next to [`set_current`]. +pub fn set_display_locale(ctx: &egui::Context, locale: gridcraft_engine::display::DisplayLocale) { + ctx.data_mut(|d| d.insert_temp(display_key(), locale)); +} + /// English label → Japanese. Keys are the exact interface strings (command ids, file names and /// document text are never translated); anything absent falls back to English. const JAPANESE: &[(&str, &str)] = &[ diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index c20c7d1b36..83231bff81 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -318,6 +318,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Save the workbook as a web page." => "Speichert die Arbeitsmappe als Webseite.", "Options|General" => "Allgemein", "Options|Save" => "Speichern", + "Options|Advanced" => "Erweitert", + "Use system separators" => "Trennzeichen vom Betriebssystem übernehmen", + "Decimal separator:" => "Dezimaltrennzeichen:", + "Thousands separator:" => "Tausendertrennzeichen:", "Show formula bar" => "Bearbeitungsleiste anzeigen", "Enable AutoComplete for cell values" => "AutoVervollständigen für Zellwerte aktivieren", "Save .xlsx workbooks automatically after every change" => ".xlsx-Arbeitsmappen nach jeder Änderung automatisch speichern", diff --git a/crates/ui-egui/src/keymap.rs b/crates/ui-egui/src/keymap.rs index 032db1a2dc..30298a8cc1 100644 --- a/crates/ui-egui/src/keymap.rs +++ b/crates/ui-egui/src/keymap.rs @@ -185,19 +185,27 @@ pub(crate) fn dispatch(app: &mut SheetApp, ctx: &egui::Context, key: Key, physic .map(|d| { let now = gridcraft_engine::core::date::serial_in_system(d.wb.date_system, gridcraft_engine::calc::now_serial()); let (value, format) = if shortcut == Shortcut::Date { (now.floor(), "m/d/yyyy") } else { (now.fract(), "h:mm AM/PM") }; - gridcraft_engine::display::format(&gridcraft_engine::core::Value::Number(value), format, &d.wb).text + gridcraft_engine::display::format_in(&gridcraft_engine::core::Value::Number(value), format, &d.wb, &app.display_locale()).text }) .unwrap_or_default(); edit_with(app, text); } Shortcut::FormulaAbove | Shortcut::ValueAbove => { let Some(above) = active.row.checked_sub(1).map(|r| CellRef::new(r, active.col)) else { return true }; + // Typed into the editor, so in the interface's syntax (`=SUMME(A1;2,5)`, `1,5`). + let loc = app.display_locale(); let text = if shortcut == Shortcut::FormulaAbove { - app.session.active().map(|d| gridcraft_engine::display::edit_text(&d.wb, sh, above)).unwrap_or_default() + app.session + .active() + .map(|d| match sh.cell(above) { + Some(c) if c.formula.is_some() => crate::formula_locale::display(&c.input_text(), loc.formula, &d.wb, d.wb.active_sheet), + _ => gridcraft_engine::display::edit_text_in(&d.wb, sh, above, &loc), + }) + .unwrap_or_default() } else { app.session .active() - .and_then(|d| sh.value_ref(above).map(|v| gridcraft_engine::display::format(v, "General", &d.wb).text)) + .and_then(|d| sh.value_ref(above).map(|v| gridcraft_engine::display::format_in(v, "General", &d.wb, &loc).text)) .unwrap_or_default() }; edit_with(app, text); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f96cdac3c0..96ce4537c1 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -27,6 +27,7 @@ pub mod panes; pub mod paper_view; pub mod pivot_pane; pub mod printing; +pub mod regional; pub mod ribbon; pub mod selection_glide; pub mod tabs; @@ -76,6 +77,10 @@ pub struct UiState { /// [`INTERFACE_SCALE_MIN`]..=[`INTERFACE_SCALE_MAX`]; a malformed saved value reads as 100. #[serde(deserialize_with = "lenient_interface_scale")] pub interface_scale: u16, + /// File ▸ Options ▸ Advanced ▸ Use system separators, and the separators used without it + /// (see [`regional`]). + #[serde(deserialize_with = "regional::lenient")] + pub separators: regional::Separators, } pub const INTERFACE_SCALE_MIN: u16 = 75; @@ -107,6 +112,7 @@ impl Default for UiState { keytip_invoke: None, language: i18n::Language::En, interface_scale: 100, + separators: regional::Separators::default(), } } } @@ -266,6 +272,12 @@ pub struct SheetApp { } impl SheetApp { + /// How this interface shows values: the language's formula syntax and the regional number + /// format (with File ▸ Options ▸ Advanced separators). + pub fn display_locale(&self) -> gridcraft_engine::display::DisplayLocale { + regional::display_locale(self.ui.language, &self.ui.separators) + } + pub fn new(session: Session, services: Services) -> SheetApp { SheetApp { session, @@ -380,6 +392,8 @@ impl SheetApp { if let Some(r) = self.run_ui_command(id, ¶ms) { return r; } + // What the engine prints and writes for people (PDF, CSV) looks as the interface does. + self.session.display_locale = self.display_locale(); let r = self.session.run(id, params); self.after_engine(); // The new chart is selected, as after inserting any object, so Delete removes it, not its data. @@ -608,6 +622,7 @@ impl SheetApp { self.ui.autocomplete = p.get("on").and_then(Json::as_bool).unwrap_or(!self.ui.autocomplete); Ok(json!({"on": self.ui.autocomplete})) } + "options.separators" => regional::command(self.ui.language, &mut self.ui.separators, p), "view.interfaceScale" => { // `{"percent": 125}` or `{"percent": "auto"}`; out-of-range values are clamped. let percent = match p.get("percent") { @@ -882,7 +897,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), + _ => gridcraft_engine::display::edit_text_in(&d.wb, sh, at, &self.display_locale()), } }; let (text, replace) = match text { @@ -906,16 +921,19 @@ impl SheetApp { Some(full) if full.to_lowercase().starts_with(&ed.text.to_lowercase()) => full.clone(), _ => ed.text.clone(), }; - let text = match self.session.active().map(|d| formula_locale::canonical(&text, ed.locale, &d.wb, ed.sheet, ed.cell)) { - Some(Ok(text)) => text, + // The editor's formula language (fixed when editing began) and the regional number format. + let loc = gridcraft_engine::display::DisplayLocale { formula: ed.locale, ..self.display_locale() }; + let entry = match self.session.active().map(|d| formula_locale::canonical(&text, &loc, &d.wb, ed.sheet, ed.cell)) { + Some(Ok(entry)) => entry, Some(Err(error)) => { self.message = Some(("GridCraft".into(), error.to_string())); self.editor = Some(ed); self.session.mode = gridcraft_engine::Mode::Edit; return false; } - None => text, + None => formula_locale::Entry { text, auto_format: None }, }; + let text = entry.text; // Data validation. if let Some(d) = self.session.active() && let Some((dv, msg)) = gridcraft_engine::cmd::data::check_validation(&d.wb, ed.sheet, ed.cell, &text) @@ -929,11 +947,12 @@ impl SheetApp { } self.toast = Some((msg, now_ms())); } - let r = if fill_selection { - self.session.run("range.fill", json!({"input": text})) - } else { - self.session.run("cell.set", json!({"cell": ed.cell.a1(), "input": text, "array": array})) - }; + let mut params = if fill_selection { json!({"input": text}) } else { json!({"cell": ed.cell.a1(), "input": text, "array": array}) }; + if let Some(code) = entry.auto_format { + params["autoFormat"] = json!(code); + } + self.session.display_locale = self.display_locale(); + let r = self.session.run(if fill_selection { "range.fill" } else { "cell.set" }, params); match r { Ok(_) => { if dr != 0 || dc != 0 { @@ -1100,8 +1119,10 @@ impl SheetApp { } let t0 = now_ms(); text_box::before_ui(self, &ctx); - // The language the widgets translate with this frame (see `i18n::current`). + // The language the widgets translate with this frame (see `i18n::current`), and how + // values are shown in it (see `i18n::display_locale`). i18n::set_current(&ctx, self.ui.language); + i18n::set_display_locale(&ctx, self.display_locale()); let t = theme::Tokens::get(&ctx); if self.backstage.is_none() { self.ribbon_keys(&ctx); diff --git a/crates/ui-egui/src/paper_view.rs b/crates/ui-egui/src/paper_view.rs index 621a979033..1924361701 100644 --- a/crates/ui-egui/src/paper_view.rs +++ b/crates/ui-egui/src/paper_view.rs @@ -213,7 +213,7 @@ pub(crate) fn paint_headers_footers(app: &SheetApp, ui: &egui::Ui, p: &Painter, pg.hf_zone_at(x, y) }); let needs_text = sh.print.has_header_footer(); - let (date, time) = if needs_text { print::hf_date_time(wb) } else { Default::default() }; + let (date, time) = if needs_text { print::hf_date_time_in(wb, &crate::i18n::display_locale(ui.ctx())) } else { Default::default() }; for (i, j) in pages_iter(geo, &pg) { let page = screen_rect(geo, pg.page_rect(i, j)); if !page.intersects(geo.cells) { diff --git a/crates/ui-egui/src/regional.rs b/crates/ui-egui/src/regional.rs new file mode 100644 index 0000000000..9fe0926b31 --- /dev/null +++ b/crates/ui-egui/src/regional.rs @@ -0,0 +1,142 @@ +//! The regional format people see and type numbers in, and the display locale built from it. +//! +//! Numbers follow the interface language's regional format (German: `1.234,56`, `13.10.2026`) +//! unless File › Options › Advanced › "Use system separators" is off, in which case its Decimal +//! and Thousands separator fields apply, as in Excel. A preference kept in `ui.json`, never in a +//! workbook. Formulas follow the interface language ([`crate::i18n::Language::formula_locale`]). +//! +//! The interface language itself starts from the system's on first run, so "system" here means +//! the regional format of the language the interface uses. + +use gridcraft_engine::core::NumberLocale; +use gridcraft_engine::display::DisplayLocale; +use serde::{Deserialize, Serialize}; +use serde_json::{Value as Json, json}; + +use crate::i18n::Language; + +/// File › Options › Advanced › Use system separators, and the separators used without it. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Separators { + pub use_system: bool, + /// One character each; empty or unusable falls back to the regional format's. + pub decimal: String, + pub thousands: String, +} + +impl Default for Separators { + fn default() -> Self { + Separators { use_system: true, decimal: String::new(), thousands: String::new() } + } +} + +/// Reads a saved preference leniently: anything malformed is the default, rather than failing the +/// whole `UiState` (which would reset every other preference with it). +pub fn lenient<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + let v = Json::deserialize(d)?; + Ok(serde_json::from_value(v).unwrap_or_default()) +} + +/// The regional format of an interface language. Languages whose regional format hasn't been +/// observed yet use en-US. +pub fn region_of(language: Language) -> NumberLocale { + match language { + Language::De => NumberLocale::DE_DE, + _ => NumberLocale::EN_US, + } +} + +fn one_char(s: &str) -> Option { + let mut chars = s.chars(); + match (chars.next(), chars.next()) { + (Some(c), None) => Some(c), + _ => None, + } +} + +/// The number locale numbers are shown and typed in. +pub fn number_locale(language: Language, separators: &Separators) -> NumberLocale { + let region = region_of(language); + if separators.use_system { + return region; + } + let decimal = one_char(&separators.decimal).unwrap_or(region.decimal); + let thousands = one_char(&separators.thousands).unwrap_or(region.thousands); + region.with_separators(decimal, thousands) +} + +/// How values are shown to people in this interface: numbers in [`number_locale`], booleans, +/// errors and formulas in the language's formula syntax. +pub fn display_locale(language: Language, separators: &Separators) -> DisplayLocale { + DisplayLocale { number: number_locale(language, separators), formula: language.formula_locale() } +} + +/// `options.separators {useSystem?, decimal?, thousands?}`: changes the preference and answers +/// with it and the separators in effect. A pair that can't be used (a digit, a letter, the same +/// character twice…) is refused and nothing changes. +pub fn command(language: Language, separators: &mut Separators, p: &Json) -> Result { + let mut next = separators.clone(); + if let Some(on) = p.get("useSystem").and_then(Json::as_bool) { + next.use_system = on; + } + for (key, slot) in [("decimal", &mut next.decimal), ("thousands", &mut next.thousands)] { + if let Some(v) = p.get(key) { + let s = v.as_str().ok_or_else(|| format!("`{key}` must be one character"))?; + if one_char(s).is_none() { + return Err(format!("`{key}` must be one character")); + } + *slot = s.to_string(); + } + } + if !next.use_system { + let region = region_of(language); + let d = one_char(&next.decimal).unwrap_or(region.decimal); + let t = one_char(&next.thousands).unwrap_or(region.thousands); + if !gridcraft_engine::core::locale::separators_usable(d, t) { + return Err(format!("{d:?} and {t:?} can't be used as the decimal and thousands separators")); + } + } + *separators = next; + let loc = number_locale(language, separators); + Ok(json!({"useSystem": separators.use_system, "decimal": loc.decimal.to_string(), "thousands": loc.thousands.to_string()})) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn numbers_follow_the_language_unless_separators_are_set() { + let system = Separators::default(); + assert_eq!(number_locale(Language::De, &system), NumberLocale::DE_DE); + assert_eq!(number_locale(Language::En, &system), NumberLocale::EN_US); + assert_eq!(number_locale(Language::Fr, &system), NumberLocale::EN_US); + let own = Separators { use_system: false, decimal: ".".into(), thousands: "'".into() }; + let swiss = number_locale(Language::De, &own); + assert_eq!((swiss.decimal, swiss.thousands, swiss.region.tag), ('.', '\'', "de-DE")); + // Unusable or missing fields fall back to the region's. + let odd = Separators { use_system: false, decimal: "ab".into(), thousands: String::new() }; + assert_eq!(number_locale(Language::De, &odd), NumberLocale::DE_DE); + assert!(display_locale(Language::De, &system).formula == gridcraft_engine::formula::FormulaLocale::De); + assert!(display_locale(Language::En, &system).is_canonical()); + } + + #[test] + fn the_command_validates_and_reports() { + let mut s = Separators::default(); + let r = command(Language::De, &mut s, &json!({})).unwrap(); + assert_eq!(r, json!({"useSystem": true, "decimal": ",", "thousands": "."})); + let r = command(Language::De, &mut s, &json!({"useSystem": false, "decimal": ".", "thousands": ","})).unwrap(); + assert_eq!(r, json!({"useSystem": false, "decimal": ".", "thousands": ","})); + for bad in [json!({"decimal": "1"}), json!({"decimal": ".."}), json!({"thousands": "."}), json!({"decimal": 5}), json!({"thousands": ""})] { + assert!(command(Language::De, &mut s, &bad).is_err(), "{bad}"); + } + assert_eq!(s.decimal, ".", "a refused change changes nothing"); + // Malformed saved preferences read as the default. + let back: Separators = lenient(json!(5)).unwrap(); + assert_eq!(back, Separators::default()); + let back: Separators = lenient(json!({"useSystem": false, "decimal": ";"})).unwrap(); + assert!(!back.use_system); + } +} diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 9c6d89eeb2..82a7808e0c 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1128,7 +1128,11 @@ fn number_combo(app: &mut SheetApp, ui: &mut Ui, st: &Style) { .iter() .map(|n| { let code = gridcraft_engine::cmd::format::format_code_for(n); - let preview = app.session.active().map(|d| gridcraft_engine::display::format(&sample, code, &d.wb).text).unwrap_or_default(); + let preview = app + .session + .active() + .map(|d| gridcraft_engine::display::format_in(&sample, code, &d.wb, &app.display_locale()).text) + .unwrap_or_default(); (*n, format!("{:<12} {preview}", lang.tr(n))) }) .chain(std::iter::once(("__more", format!("{:>12}", lang.tr("More Number Formats…"))))) @@ -2467,7 +2471,7 @@ pub fn shortcut(app: &mut SheetApp, key: Key, m: Modifiers) { .active() .map(|d| { let today = gridcraft_engine::core::date::serial_in_system(d.wb.date_system, gridcraft_engine::calc::now_serial()).floor(); - gridcraft_engine::display::format(&gridcraft_engine::core::Value::Number(today), "m/d/yyyy", &d.wb).text + gridcraft_engine::display::format_in(&gridcraft_engine::core::Value::Number(today), "m/d/yyyy", &d.wb, &app.display_locale()).text }) .unwrap_or_default(); app.begin_edit(Some(text), false); diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index b77524bb96..608feb61b2 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -309,12 +309,13 @@ pub fn status_bar(app: &mut SheetApp, ui: &mut Ui) { && !d.selection.is_single_cell() { let st = gridcraft_engine::display::stats(sh, &d.selection.ranges); + let loc = app.display_locale(); let fmt = |v: f64| { let code = d.wb.styles.get(sh.style_id(d.selection.active)).num_fmt.as_str().to_string(); let code = if code == "General" { "#,##0.##########".to_string() } else { code }; - gridcraft_engine::display::format(&gridcraft_engine::core::Value::Number(v), &code, &d.wb) + gridcraft_engine::display::format_in(&gridcraft_engine::core::Value::Number(v), &code, &d.wb, &loc) .text - .trim_end_matches('.') + .trim_end_matches(loc.number.decimal) .to_string() }; let mut parts = Vec::new(); diff --git a/crates/ui-egui/tests/german_interface.rs b/crates/ui-egui/tests/german_interface.rs new file mode 100644 index 0000000000..8afd4e204e --- /dev/null +++ b/crates/ui-egui/tests/german_interface.rs @@ -0,0 +1,229 @@ +//! A German interface enters and shows numbers, dates, booleans, errors and formulas as German +//! Excel does (observed in Excel 365 de-DE), while workbooks, files and engine commands stay +//! canonical. +use egui::vec2; +use egui_kittest::kittest::Queryable; +use gridcraft_engine::Session; +use gridcraft_engine::core::{CellError, CellRef, Value}; +use gridcraft_ui_egui::control::{Outcome, handle}; +use gridcraft_ui_egui::{SheetApp, UiState, i18n::Language}; +use serde_json::{Value as Json, json}; + +fn app(language: &str) -> SheetApp { + let mut session = Session::new(); + session.new_workbook(); + let mut app = SheetApp::new(session, Default::default()); + app.run("app.language.set", json!({"language": language})).unwrap(); + app +} + +fn enter(app: &mut SheetApp, cell: &str, text: &str) -> bool { + app.run("selection.set", json!({"cell": cell})).unwrap(); + app.begin_edit(Some(text.into()), false); + app.commit_edit(0, 0, false, false) +} + +fn value(app: &SheetApp, cell: &str) -> Value { + app.session.active().unwrap().wb.active().unwrap().value(CellRef::parse(cell).unwrap()) +} + +fn format(app: &SheetApp, cell: &str) -> String { + let d = app.session.active().unwrap(); + let sh = d.wb.active().unwrap(); + d.wb.styles.get(sh.style_id(CellRef::parse(cell).unwrap())).num_fmt.as_str().to_string() +} + +fn edit_text(app: &mut SheetApp, cell: &str) -> String { + app.run("selection.set", json!({"cell": cell})).unwrap(); + app.begin_edit(None, false); + let text = app.editor.as_ref().unwrap().text.clone(); + app.cancel_edit(); + text +} + +fn serial(y: i64, m: i64, d: i64) -> f64 { + gridcraft_engine::core::date::serial_from_ymd(gridcraft_engine::core::DateSystem::D1900, y, m, d).unwrap() +} + +#[test] +fn german_typing_enters_what_german_excel_enters() { + let mut app = app("de"); + let year = gridcraft_engine::core::parse::current_year(); + for (cell, typed, expected, fmt) in [ + ("A1", "1,5", Value::Number(1.5), "General"), + ("A2", "1.234,5", Value::Number(1234.5), "#,##0.00"), + ("A3", "-3,2%", Value::Number(-0.032), "0.00%"), + ("A4", "1,5E+3", Value::Number(1500.0), "0.00E+00"), + ("A5", "13.10.2026", Value::Number(serial(2026, 10, 13)), "m/d/yyyy"), + ("A6", "13.10.26", Value::Number(serial(2026, 10, 13)), "m/d/yyyy"), + ("A7", "13.10", Value::Number(serial(year, 10, 13)), "d-mmm"), + ("A8", "14:30", Value::Number(14.5 / 24.0), "h:mm"), + ("A9", "WAHR", Value::Bool(true), "General"), + ("A10", "falsch", Value::Bool(false), "General"), + ("A11", "#NV", Value::Error(CellError::NA), "General"), + ("A12", "#WERT!", Value::Error(CellError::Value), "General"), + // English words and en-US spellings are text in German Excel. + ("A13", "TRUE", Value::text("TRUE"), "General"), + ("A14", "#N/A", Value::text("#N/A"), "General"), + ("A15", "10/13/2026", Value::text("10/13/2026"), "General"), + // Observed: a day and month with a trailing dot and no year stay text. + ("A16", "13.10.", Value::text("13.10."), "General"), + ("A17", "5 €", Value::Number(5.0), "#,##0 \"€\";[Red]-#,##0 \"€\""), + ("A18", "13. Okt 2026", Value::Number(serial(2026, 10, 13)), "d-mmm-yy"), + ("A19", "13.10.2026 14:30", Value::Number(serial(2026, 10, 13) + 14.5 / 24.0), "m/d/yyyy h:mm"), + ] { + assert!(enter(&mut app, cell, typed), "{typed}"); + let v = value(&app, cell); + match (&v, &expected) { + (Value::Number(a), Value::Number(b)) => assert!((a - b).abs() < 1e-9, "{typed}: {a} != {b}"), + _ => assert_eq!(v, expected, "{typed}"), + } + assert_eq!(format(&app, cell), fmt, "{typed}"); + } + // The editor starts from German text that reads back as the same value. + for (cell, shown) in [("A1", "1,5"), ("A2", "1234,5"), ("A5", "13.10.2026"), ("A9", "WAHR"), ("A11", "#NV"), ("A18", "13. Okt 26")] { + assert_eq!(edit_text(&mut app, cell), shown, "{cell}"); + } + let before = value(&app, "A2"); + app.run("selection.set", json!({"cell": "A2"})).unwrap(); + app.begin_edit(None, false); + assert!(app.commit_edit(0, 0, false, false)); + assert_eq!(value(&app, "A2"), before); + // German formulas still work, with decimal commas. + assert!(enter(&mut app, "B1", "=SUMME(A1;2,5)")); + assert_eq!(value(&app, "B1"), Value::Number(4.0)); + // Engine commands stay canonical whatever the interface shows. + app.run("cell.set", json!({"cell": "C1", "input": "1.5"})).unwrap(); + assert_eq!(value(&app, "C1"), Value::Number(1.5)); +} + +#[test] +fn english_typing_is_unchanged() { + let mut app = app("en"); + for (cell, typed, expected) in [ + ("A1", "1,234", Value::Number(1234.0)), + ("A2", "1,5", Value::text("1,5")), + ("A3", "TRUE", Value::Bool(true)), + ("A4", "10/13/2026", Value::Number(serial(2026, 10, 13))), + ] { + assert!(enter(&mut app, cell, typed)); + assert_eq!(value(&app, cell), expected, "{typed}"); + } + assert_eq!(edit_text(&mut app, "A4"), "10/13/2026"); + assert_eq!(edit_text(&mut app, "A3"), "TRUE"); +} + +#[test] +fn use_system_separators_is_a_preference_not_a_workbook_setting() { + let mut app = app("de"); + assert_eq!(app.run("options.separators", json!({})).unwrap(), json!({"useSystem": true, "decimal": ",", "thousands": "."})); + // Excel's Swiss-style separators with the German interface. + app.run("options.separators", json!({"useSystem": false, "decimal": ".", "thousands": "'"})).unwrap(); + assert!(enter(&mut app, "A1", "1'234.5")); + assert_eq!(value(&app, "A1"), Value::Number(1234.5)); + assert_eq!(edit_text(&mut app, "A1"), "1234.5"); + // Dates keep the region's order. + assert!(enter(&mut app, "A2", "13.10.2026")); + assert_eq!(value(&app, "A2"), Value::Number(serial(2026, 10, 13))); + assert!(app.run("options.separators", json!({"decimal": "'"})).is_err()); + // Saved in ui.json, read back leniently. + let restored: UiState = serde_json::from_str(&serde_json::to_string(&app.ui).unwrap()).unwrap(); + assert!(!restored.separators.use_system); + assert_eq!(restored.separators.decimal, "."); + let odd: UiState = serde_json::from_str(r#"{"dark": true, "separators": "garbage"}"#).unwrap(); + assert!(odd.dark && odd.separators.use_system); + // The option is not part of the workbook: its file is the same either way. + let xlsx = |app: &mut SheetApp| app.run("file.saveBytes", json!({"format": "xlsx"})).unwrap()["base64"].clone(); + let with_option = xlsx(&mut app); + app.run("options.separators", json!({"useSystem": true})).unwrap(); + assert_eq!(xlsx(&mut app), with_option); +} + +#[test] +fn csv_saved_from_a_german_interface_uses_semicolons_and_german_numbers() { + let mut app = app("de"); + app.run("range.setValues", json!({"range": "A1", "values": [[1.5, true]]})).unwrap(); + let csv = app.run("file.saveBytes", json!({"format": "csv"})).unwrap(); + let bytes = gridcraft_engine::io::base64_decode(csv["base64"].as_str().unwrap()).unwrap(); + assert_eq!(String::from_utf8(bytes).unwrap(), "1,5;WAHR\r\n"); + app.run("app.language.set", json!({"language": "en"})).unwrap(); + let csv = app.run("file.saveBytes", json!({"format": "csv"})).unwrap(); + let bytes = gridcraft_engine::io::base64_decode(csv["base64"].as_str().unwrap()).unwrap(); + assert_eq!(String::from_utf8(bytes).unwrap(), "1.5,TRUE\r\n"); +} + +fn control(app: &mut SheetApp, method: &str, params: Json) -> Json { + let ctx = egui::Context::default(); + match handle(app, &ctx, method, ¶ms) { + Outcome::Done(v) => v, + Outcome::Screenshot { .. } => json!(null), + } +} + +#[test] +fn formula_fields_in_dialogs_speak_german() { + let mut app = app("de"); + // Name Manager › New: Refers to is typed in German and stored canonically. + app.open_dialog("defineName", json!({})); + control(&mut app, "ui.dialog.set", json!({"field": "name", "value": "Preis"})); + control(&mut app, "ui.dialog.set", json!({"field": "refersTo", "value": "=SUMME(Tabelle1!$A$1:$A$3;2,5)"})); + let r = control(&mut app, "ui.dialog.confirm", json!({})); + assert_eq!(r["ok"], json!(true), "{r}"); + let names = app.run("formulas.nameManager", json!({})).unwrap(); + assert_eq!(names[0]["refersTo"], "=SUM(Tabelle1!$A$1:$A$3,2.5)"); + // Edit… shows it in German again. + app.open_dialog("defineName", json!({"name": "Preis", "refersTo": names[0]["refersTo"], "oldName": "Preis"})); + assert_eq!(app.dialog.as_ref().unwrap().values["refersTo"], "=SUMME(Tabelle1!$A$1:$A$3;2,5)"); + app.dialog = None; + // Data validation: a custom formula and decimal bounds. + app.run("selection.set", json!({"cell": "B2"})).unwrap(); + app.open_dialog("dataValidation", json!({})); + control(&mut app, "ui.dialog.set", json!({"field": "type", "value": "custom"})); + control(&mut app, "ui.dialog.set", json!({"field": "formula1", "value": "=ISTZAHL(B2)"})); + assert_eq!(control(&mut app, "ui.dialog.confirm", json!({}))["ok"], json!(true)); + app.open_dialog("dataValidation", json!({})); + assert_eq!(app.dialog.as_ref().unwrap().values["formula1"], "ISTZAHL(B2)"); + control(&mut app, "ui.dialog.set", json!({"field": "type", "value": "decimal"})); + control(&mut app, "ui.dialog.set", json!({"field": "operator", "value": "between"})); + control(&mut app, "ui.dialog.set", json!({"field": "formula1", "value": "1,5"})); + control(&mut app, "ui.dialog.set", json!({"field": "formula2", "value": "2,5"})); + assert_eq!(control(&mut app, "ui.dialog.confirm", json!({}))["ok"], json!(true)); + let doc = app.session.active().unwrap(); + let dv = &doc.wb.active().unwrap().validations[0]; + assert_eq!((dv.f1.as_str(), dv.f2.as_deref()), ("1.5", Some("2.5"))); + app.open_dialog("dataValidation", json!({})); + assert_eq!(app.dialog.as_ref().unwrap().values["formula1"], "1,5"); + app.dialog = None; + // A list's items are separated by `;`. + app.run("selection.set", json!({"cell": "C3"})).unwrap(); + app.open_dialog("dataValidation", json!({})); + control(&mut app, "ui.dialog.set", json!({"field": "type", "value": "list"})); + control(&mut app, "ui.dialog.set", json!({"field": "formula1", "value": "Ja;Nein"})); + assert_eq!(control(&mut app, "ui.dialog.confirm", json!({}))["ok"], json!(true)); + let doc = app.session.active().unwrap(); + assert!(doc.wb.active().unwrap().validations.iter().any(|dv| dv.f1 == "Ja,Nein")); +} + +#[test] +fn the_options_page_offers_the_separators_with_german_labels() { + let mut session = Session::new(); + session.new_workbook(); + let mut app = SheetApp::new(session, Default::default()); + app.ui.language = Language::De; + let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 1100.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + h.state_mut().run("ui.backstage", json!({"page": "options"})).unwrap(); + h.run_steps(6); + h.get_by_label("Erweitert"); + h.get_by_label("Dezimaltrennzeichen:"); + h.get_by_label("Tausendertrennzeichen:"); + h.get_by_label("Trennzeichen vom Betriebssystem übernehmen").click(); + h.run_steps(4); + assert!(!h.state().ui.separators.use_system); +} From 5497a019d323407ffaad492433a125529e9af04f Mon Sep 17 00:00:00 2001 From: Herr-Enklo <168219178+Herr-Enklo@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:28:16 +0200 Subject: [PATCH 5/7] Docs and fuzz: German formulas and numbers docs/formula-input.md describes where the formula language now applies (dialog fields, cell booleans and errors), the new Numbers and dates section (regional format, Use system separators, built-in date formats, print/PDF and CSV) and what is not localized yet; German's words are documented as observed in Excel 365 de-DE. localization-parity.md, function-parity.md, decisions.md, README and ROADMAP are updated with their Last updated lines and history rows. Random German-looking input is fuzzed through parse_input_in and format_value_in (with multi-byte separators); the Options page's Editing heading gets its German label. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- ROADMAP.md | 8 ++- crates/core/src/parse.rs | 23 ++++++++ crates/numfmt/src/tests.rs | 7 +++ crates/ui-egui/src/i18n/de.rs | 1 + crates/ui-egui/tests/german_interface.rs | 3 + docs/decisions.md | 17 ++++++ docs/formula-input.md | 73 +++++++++++++++++++----- docs/function-parity.md | 5 +- docs/localization-parity.md | 27 ++++++--- 10 files changed, 136 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index e028acf03b..00c795915d 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ cargo run --release -p gridcraft -- --sample sales --control 7979 # + JSON con cargo xtask ci # fmt, clippy, tests, assets, layering, wasm ``` -Choose Español, Deutsch, Português (Brasil) or Русский under View → Interface language for [local formula names, booleans, errors and separators](docs/formula-input.md). +Choose Español, Deutsch, Português (Brasil) or Русский under View → Interface language for [local formula names, booleans, errors and separators](docs/formula-input.md); Deutsch also shows and reads numbers and dates as German Excel does (`1.234,56`, `13.10.2026`). Workbooks, clipboard exchange, and programmatic APIs retain canonical English syntax. ### Web diff --git a/ROADMAP.md b/ROADMAP.md index 7b8ce57940..0989889813 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (rows auto-fit oversized fonts on open and whole-row/column formatting) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** minor (German formulas and numbers as Excel 365 de-DE shows them) · **Target:** Microsoft Excel (Microsoft 365) GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web. @@ -96,8 +96,9 @@ interface follows the system language, and CJK, Thai and Hebrew text falls back (#38). Dialogs, menus and messages are English. Formulas follow the interface language in Spanish, German, Brazilian Portuguese and Russian (function names, booleans, errors, table specifiers and separators at the editor; files keep English: [formula-input.md](docs/formula-input.md)); -number formats are en-US only. PR #163 (German formulas and numbers) is open. Detail: -[localization-parity.md](docs/localization-parity.md). +German was observed in Excel 365 de-DE. With German, numbers and dates show and are typed as in +German Excel (`1.234,56`, `13.10.2026`, Use system separators, `;` CSV); other languages keep +en-US numbers. Detail: [localization-parity.md](docs/localization-parity.md). | Language | Code | Status | UI strings | |---|---|---|---| @@ -135,6 +136,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-14 | German as Excel 365 de-DE shows it (observed): all 514 functions' German names (MAP stays MAP, `#CALC!`, `[@[A]:[B]]`), formula words in dialogs and cell values, and German numbers and dates: `1.234,56`, `13.10.2026`, `13. Okt 26` in cells, editor, print and PDF; typing `1,5`, `-3,2%`, `5 €`, `13.10.2026`, `13. Okt 2026` with Excel's formats; File › Options › Advanced › Use system separators; CSV with `;`. Files, CLI and MCP stay canonical (builds on #443) | | 2026-10-12 | Rows auto-fit oversized fonts: opening a file and whole-row/column formatting grow non-custom rows to their tallest font (#833) | | 2026-10-12 | Number formats: parsing a format code is linear in its length (long runs of `[`, `,` or `/` no longer take quadratic time), and an unclosed `[` is a literal when splitting sections as well as when tokenizing (#810) | | 2026-10-12 | Number formats: a fraction denominator with 65,536 zeros no longer crashes; format codes are capped at 255 characters like Excel (longer codes display as General, `TEXT` gives `#VALUE!`) (#807) | diff --git a/crates/core/src/parse.rs b/crates/core/src/parse.rs index e26f3c4144..5fa52e5b31 100644 --- a/crates/core/src/parse.rs +++ b/crates/core/src/parse.rs @@ -806,6 +806,29 @@ mod tests { assert_eq!(parse_input_in("13.10.2026", DateSystem::D1900, &swiss).value, Value::Number(date(2026, 10, 13))); } + #[test] + fn regional_parsing_survives_random_input() { + // Random strings from the characters German numbers and dates are made of. + let alphabet: Vec = "0123456789.,;:-+ %€$()/eE'\u{a0}OktMärzJanuar".chars().collect(); + let mut seed: u64 = 0x0dde_c0de; + let mut next = |n: usize| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + (seed % n.max(1) as u64) as usize + }; + let swiss = NumberLocale::DE_DE.with_separators('.', '\''); + for _ in 0..20_000 { + let len = next(16); + let s: String = (0..len).map(|_| alphabet[next(alphabet.len())]).collect(); + for loc in [NumberLocale::DE_DE, swiss] { + if let Value::Number(n) = parse_input_in(&s, DateSystem::D1900, &loc).value { + assert!(n.is_finite(), "{s:?}"); + } + } + } + } + #[test] fn regional_parsing_never_panics_on_odd_input() { let de = NumberLocale::DE_DE; diff --git a/crates/numfmt/src/tests.rs b/crates/numfmt/src/tests.rs index f219333ac8..5a7237c0ac 100644 --- a/crates/numfmt/src/tests.rs +++ b/crates/numfmt/src/tests.rs @@ -521,6 +521,13 @@ fn fuzz_no_panic() { assert!(r.text.is_char_boundary(pos), "{code:?} {v}"); } let _ = format_value(&Value::Number(v), &fmt, DateSystem::D1904); + // German drawing (multi-byte separators and names) keeps the fill position valid. + for loc in [NumberLocale::DE_DE, NumberLocale::DE_DE.with_separators('·', '\u{202f}')] { + let r = format_value_in(&Value::Number(v), &fmt, S, &loc); + if let Some((_, pos)) = r.fill { + assert!(r.text.is_char_boundary(pos), "{code:?} {v}"); + } + } let _ = text_function(&Value::Number(v), &code, S); } let _ = format_value(&Value::from("text"), &fmt, S); diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 83231bff81..1e64bcbd90 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -318,6 +318,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Save the workbook as a web page." => "Speichert die Arbeitsmappe als Webseite.", "Options|General" => "Allgemein", "Options|Save" => "Speichern", + "Options|Editing" => "Bearbeitungsoptionen", "Options|Advanced" => "Erweitert", "Use system separators" => "Trennzeichen vom Betriebssystem übernehmen", "Decimal separator:" => "Dezimaltrennzeichen:", diff --git a/crates/ui-egui/tests/german_interface.rs b/crates/ui-egui/tests/german_interface.rs index 8afd4e204e..d539d7569e 100644 --- a/crates/ui-egui/tests/german_interface.rs +++ b/crates/ui-egui/tests/german_interface.rs @@ -226,4 +226,7 @@ fn the_options_page_offers_the_separators_with_german_labels() { h.get_by_label("Trennzeichen vom Betriebssystem übernehmen").click(); h.run_steps(4); assert!(!h.state().ui.separators.use_system); + if let Ok(path) = std::env::var("GRIDCRAFT_LOCALE_SCREENSHOT") { + h.render().unwrap().save(std::path::Path::new(&path).with_file_name("german-options.png")).unwrap(); + } } diff --git a/docs/decisions.md b/docs/decisions.md index c15b4a67f0..6fcc2d7d96 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -14,6 +14,23 @@ errors, table specifiers, separators) behind one `Dialect` table, so a language data file and one `FormulaLocale` variant, without new parsing code. Every entry carries its source; Microsoft's localized pages are machine-translated, so names need two agreeing signals. +German's words were then observed in Excel 365 de-DE through its automation interface (every +function entered canonically and read back localized), which replaces the page votes for German +and is checked pair by pair in `crates/formula/tests/german_observed.rs`. + +## Regional number formats at the interface boundary + +Numbers and dates follow the interface language's regional format (de-DE observed; other +languages stay en-US until observed), with Excel's "Use system separators" option as a +`ui.json` preference. A `NumberLocale` (core, L0) carries the region's conventions and the +separators; `numfmt` draws with it (`format_value_in`) and `core` reads typed text with it +(`parse_input_in`). Workbooks, format codes, formulas, `TEXT()`, the clipboard and every engine +command stay en-US: the interface parses German input itself and calls `cell.set` with the +canonical value and the automatic format (`autoFormat`), as it already translates formulas. +`Session.display_locale` (set by the interface, canonical elsewhere) is what PDF, print and Save +As CSV/TSV/Web Page use, because those are the values as displayed; the CLI and MCP keep en-US. +A new CSV is separated by `;` where the decimal separator is `,`, Excel's list separator there. + ## Preserve array evaluation when exporting formulas (#64) GridCraft evaluates ordinary formulas with array semantics even when the final result occupies one cell. XLSX export marks actual spills and formulas whose results may differ under legacy implicit intersection as dynamic, using the spill range or a single-cell array reference. Legacy CSE array ranges keep their existing representation. diff --git a/docs/formula-input.md b/docs/formula-input.md index 6e60a151b8..05e974579b 100644 --- a/docs/formula-input.md +++ b/docs/formula-input.md @@ -18,13 +18,13 @@ preference follows the system language. | | English | Spanish | German | Portuguese (Brazil) | Russian | | --- | --- | --- | --- | --- | --- | | Example | `SUM`, `IF`, `VLOOKUP` | `SUMA`, `SI`, `BUSCARV` | `SUMME`, `WENN`, `SVERWEIS` | `SOMA`, `SE`, `PROCV` | `СУММ`, `ЕСЛИ`, `ВПР` | -| Function names translated | — | 424 | 403 | 412 | 459 | +| Function names translated | — | 424 | 419 | 412 | 459 | | Booleans | `TRUE`, `FALSE` | `VERDADERO`, `FALSO` | `WAHR`, `FALSCH` | `VERDADEIRO`, `FALSO` | `ИСТИНА`, `ЛОЖЬ` | -| Errors (e.g.) | `#VALUE!`, `#N/A` | `#¡VALOR!`, `#N/D` | `#WERT!`, `#NV` | `#VALOR!`, `#N/D` | `#ЗНАЧ!`, `#Н/Д` | +| Errors (e.g.) | `#VALUE!`, `#N/A` | `#¡VALOR!`, `#N/D` | `#WERT!`, `#NV`, `#ÜBERLAUF!` | `#VALOR!`, `#N/D` | `#ЗНАЧ!`, `#Н/Д` | | Arguments and unions | `,` | `;` | `;` | `;` | `;` | | Decimal separator | `.` | `,` | `,` | `,` | `,` | | Array columns / rows | `,` / `;` | `\` / `;` | `.` / `;` | `\` / `;` | `;` / `:` | -| Table specifiers (e.g.) | `#Headers`, `#This Row` | `#Encabezados`, `#Esta fila` | `#Kopfzeilen`, `#Diese Zeile` | `#Cabeçalhos`, `#Esta Linha` | `#Заголовки`, `#Эта строка` | +| Table specifiers (e.g.) | `#Headers`, `#This Row` | `#Encabezados`, `#Esta fila` | `#Kopfzeilen`, `#Diese Zeile` (shown as `@`) | `#Cabeçalhos`, `#Esta Linha` | `#Заголовки`, `#Эта строка` | For example, `=SI(VERDADERO;SUMA(1,5;2,25);0)` (Spanish), `=WENN(WAHR;SUMME({1.2;3,5.4});#NV)` (German) and `=ЕСЛИ(ИСТИНА;СУММ({1;2:3;4});#Н/Д)` (Russian) are stored as @@ -39,7 +39,11 @@ name (`BYROW(A1:C3;SOMA)`, `GROUPBY(…;SUMME)`) uses the same local name as its ## Where it applies The cell editor, formula bar, Show Formulas, autocomplete, the Formula ribbon, Insert Function -(searched and sorted by the local name), and argument hints use the selected formula language. +(searched and sorted by the local name), argument hints, and the formula fields of dialogs (Name +Manager's Refers to, data validation sources, bounds and custom formulas, conditional-format +values and formulas, calculated fields) use the selected formula language. Cells show booleans +and error values in its words (`WAHR`, `#NV`), and typing those words enters the value; the +English words typed into a cell stay text there, as in German Excel. Common Spanish function help is translated; other help stays English, shown under the local function name with the language's separators. General interface labels and errors fall back to English where translations are not provided. An edit captures its language when it starts so @@ -50,17 +54,56 @@ local names. Text, sheet names and table column names are unchanged. When a loca would collide with a name, displayed formulas retain the canonical spelling to preserve meaning. Stored formulas, XLSX import/export, engine commands, control/API calls, CLI evaluation and -clipboard exchange retain strict English syntax. This also applies to advanced dialogs such as -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. +clipboard exchange retain strict English syntax. Localization happens where people type and read: +the editor (including filling the selected range with Ctrl+Enter), dialog fields (which the +control channel's `ui.dialog.set` fills as a person would), and what cells, print and PDF show; +it does not change the workbook format or teach the calculation engine ambiguous aliases. + +## Numbers and dates + +Numbers follow the interface language's regional format, which German Excel takes from the +system's: with German, cells show `1.234,56`, `13.10.2026`, `13. Okt 26`, `Dienstag, 13. Oktober +2026`, and typing reads `1,5`, `1.234,5`, `-3,2%`, `1,5E+3`, `5 €`, `13.10.2026`, `13.10.26`, +`13.10` (this year), `13. Okt 2026`, `14:30` and `13.10.2026 14:30`, each with the number format +German Excel gives it. File › Options › Advanced › *Use system separators* (German *Trennzeichen +vom Betriebssystem übernehmen*) can be turned off to set the decimal and thousands separators by +hand; it is a preference (`ui.json`), never part of a workbook. Other languages keep en-US +numbers until their regional format is observed (Spanish, Portuguese and Russian still read a +typed decimal comma). + +Format codes stay canonical (`#,##0.00`, `m/d/yyyy`): the region decides how they draw. Excel's +built-in short date (id 14, stored as `m/d/yyyy`) and ids 15–17 and 20–22 draw in the region's +own way, `/` in a date code is the region's date separator, month and day names follow the +region unless a `[$-409]`-style tag picks another, and the Long Date and Time formats follow the +region's system formats. + +Print and PDF show what the grid shows. Save As CSV, TSV and Web Page write values as displayed; +a new CSV is separated by `;` where the decimal separator is `,` (Excel uses the regional list +separator), and a CSV opened there prefers `;` and reads German numbers, dates, booleans and +errors, unless its fields are separated by commas. The CLI, MCP and engine commands always use +en-US. + +Not localized yet: format codes typed into Format Cells (German Excel shows `#.##0,00`, +`TT.MM.JJJJ`), `TEXT()`'s and `VALUE()`'s locale-dependent strings (German Excel's +`=TEXT(A1;"TT.MM.JJJJ")`), numbers turned into text by formulas (`=1,5&""` is `1,5` in German +Excel, `1.5` here), chart axis labels, filter lists, R1C1 notation (German `Z1S1`), and the +ribbon's Currency format (German Excel uses `€`). ## Where the names come from -Each table lives in `crates/formula/src/input/.rs` with its sources. Function names -come from Microsoft Support's localized Excel function pages, which are machine-translated and -sometimes wrong, so an entry needs two agreeing signals on its page (title, description, syntax -paragraph aligned with the English page, examples); entries marked `reviewed` were decided by -hand against independent function-name references. Booleans, errors and table specifiers cite -their sources per item. The tables have not yet been checked against a running localized Excel -or by native speakers; corrections are one-line edits with a source. +Each table lives in `crates/formula/src/input/.rs` with its sources. + +German was observed black-box in Excel 365 (German interface, de-DE regional format) through its +automation interface on 2026-10-13: every function GridCraft knows, plus four it doesn't yet, was +entered in canonical form and read back as German Excel shows it, and so were the separators, +booleans, errors and table specifiers. `crates/formula/tests/german_observed.rs` checks all 518 +calls in both directions. The observation corrected two words Microsoft's German help pages give: +`MAP` stays `MAP`, and German Excel shows `#CALC!` (not `#KALK!`). + +The Spanish, Portuguese and Russian names come from Microsoft Support's localized Excel function +pages, which are machine-translated and sometimes wrong, so an entry needs two agreeing signals +on its page (title, description, syntax paragraph aligned with the English page, examples); +entries marked `reviewed` were decided by hand against independent function-name references. +Booleans, errors and table specifiers cite their sources per item. These tables have not yet been +checked against a running localized Excel or by native speakers; corrections are one-line edits +with a source. diff --git a/docs/function-parity.md b/docs/function-parity.md index 76b00688d1..9b334fd64a 100644 --- a/docs/function-parity.md +++ b/docs/function-parity.md @@ -1,6 +1,6 @@ # Function parity: worksheet functions and formulas -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** minor (formulas in es, de, pt-BR and ru) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** minor (German function names observed in Excel 365 de-DE) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Excel's worksheet functions, category by category, against GridCraft's function library. Part of [target-app-parity.md](target-app-parity.md); gaps feed [gaps.md](gaps.md). @@ -82,7 +82,7 @@ PIVOTBY, REGEXTEST…) are all present. | Iterative calculation for circular references | done: with Enable Iterative Calculation on (`formulas.calculationOptions` `{iterative, maxIterations, maxChange}`, saved as ``), loops are evaluated again from their previous values (blank = 0) until no cell on a loop changes by Maximum Change or Maximum Iterations is reached; off, cycles give `#CIRC!`. Differences: no Options dialog yet (command only); GridCraft recalculates on open, so a loop that never settles (`=A1+1`) advances again each time the file is opened | | 15-significant-digit comparison semantics | **missing** (#57: `=0.1+0.2=0.3` is FALSE) | | External workbook references (`[Book.xlsx]Sheet1!A1`) | parsed; links dropped on open, no update | -| Localized function names and `;` separators | partial (#45): Spanish, German, Brazilian Portuguese and Russian at the editor (names, booleans, errors, table specifiers, separators); files stay English ([formula-input.md](formula-input.md)); French, Czech and Serbian formulas stay English | +| Localized function names and `;` separators | partial (#45): Spanish, German, Brazilian Portuguese and Russian at the editor and in dialog formula fields (names, booleans, errors, table specifiers, separators); German observed in Excel 365 de-DE for all 514 functions; files stay English ([formula-input.md](formula-input.md)); French, Czech and Serbian formulas stay English; `TEXT()`/`VALUE()` strings stay en-US | | Error values | the seven classic errors plus `#SPILL!`, `#CALC!`, `#GETTING_DATA` done; `#FIELD!`, `#BLOCKED!`, `#CONNECT!`, `#BUSY!`, `#UNKNOWN!`, `#PYTHON!` missing (minor: they come from data types, cloud and Python features) | ## Depth (estimated) @@ -108,6 +108,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | German function names observed in Excel 365 de-DE for all 514 functions (MAP stays MAP; 17 names added); dialog formula fields follow the formula language | | 2026-10-13 | minor | Localized function names and `;` separators: partial, formulas follow the interface language in es, de, pt-BR and ru | | 2026-10-12 | minor | USDOLLAR added as a Compatibility function (not on Microsoft's list, so the counts don't change); MIRR with no positive flow is -1, COUNTIF's bare `"<="` / `">="` skip empty strings, INDIRECT with `a1` = FALSE gives `#REF!` for A1-style text (#738) | | 2026-10-11 | minor | TEXT / number formats fixed against Excel-cached results: fraction literals and zero fractions, `0` denominators zero padded, `?` pads the thousands separator, booleans use the text section (226 mismatches → 0 in 352 TEXT cells) | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 6e8948d5e6..0ef7a00946 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (formulas in es, de, pt-BR and ru) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (German formulas and numbers observed in Excel 365 de-DE) · **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, @@ -22,12 +22,18 @@ right-to-left sheets and full complex-script support. Part of strings plus ~500 function descriptions), so each shipped language covers **~12%** (measured count / estimated total). - **Formulas follow the interface language in Spanish, German, Brazilian Portuguese and - Russian** (#45): function names (403–459 per language), booleans, errors, table specifiers and - separators, at the editor boundary only; files and APIs keep canonical English - ([formula-input.md](formula-input.md)). The names come from machine-translated Microsoft pages - (two agreeing signals per entry, sources in `crates/formula/src/input/`) and have no native - review. French, Czech and Serbian formulas stay English. Number and date formats are en-US only - (`crates/numfmt` says "en-US only"); PR #163 (German formulas and numbers) is open. + Russian** (#45): function names (412–459 per language), booleans, errors, table specifiers and + separators, in the editor, dialog formula fields and cell values; files and APIs keep canonical + English ([formula-input.md](formula-input.md)). **German was observed in Excel 365 de-DE** + (2026-10-13, automation interface): all 514 GridCraft functions (419 German names, 99 English), + every word and separator, checked by `crates/formula/tests/german_observed.rs`. The Spanish, + Portuguese and Russian names come from machine-translated Microsoft pages (two agreeing signals + per entry) and have no native review. French, Czech and Serbian formulas stay English. +- **Numbers and dates in German** (de-DE, observed the same day): cells, the editor, print and + PDF show `1.234,56`, `13.10.2026`, `Okt`; typing reads `1,5`, `1.234,5`, `-3,2%`, `5 €`, + `13.10.2026`, `13.10`, `13. Okt 2026` with Excel's automatic formats; File › Options › Advanced + › Use system separators can be turned off for own separators; CSV uses `;` and German numbers. + Other languages keep en-US numbers until their regional format is observed. - **Script support.** System-font fallback for CJK, Thai and Hebrew landed in #38 (closes #32; #142 is still open pending confirmation). There is no complex-text shaping: Arabic and Devanagari don't join or reorder, and there is no bidirectional layout. **Sheet Right-to-Left** @@ -71,8 +77,10 @@ language (none so far) and of localized function names. status and error messages, command labels, function descriptions), with a missing-key check in `cargo xtask ci`. Language switching and the system-language default already exist (#31). 2. Locale-aware formulas: function tables and separators exist for es, de, pt-BR and ru at the - formula-bar boundary (files keep canonical names); still missing: tables for French and the - other languages, locale number and date formats, list separators in CSV. + formula-bar boundary (files keep canonical names); regional number and date formats exist for + de-DE (display, entry, print, CSV list separator). Still missing: tables for French and the + other languages, the other regions' number formats, localized format codes in Format Cells, + locale-dependent `TEXT()`/`VALUE()`, chart axis labels. 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. @@ -81,6 +89,7 @@ language (none so far) and of localized function names. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | German formulas observed in Excel 365 de-DE (all 514 functions; MAP stays MAP, `#CALC!` not `#KALK!`, `[@[A]:[B]]`); dialogs' formula fields and cell booleans/errors follow the formula language; de-DE number and date display and entry, Use system separators option, `;` CSV, print/PDF as displayed | | 2026-10-12 | minor | Format Cells looks up its tabs, labels, options and note through `Language::tr` (Fill tab keyed `Format Cells\|Fill`); dialog titles and OK/Cancel too. Labels already in a table (Number, Currency, Fill…) show translated; the rest fall back to English until the tables add them | | 2026-10-12 | minor | Brazilian Portuguese catalog (`i18n/pt.rs`) grows from 377 to 923 entries: every key the other catalogs translate, including the dialog strings #669 starts looking up (~42%) | | 2026-10-11 | minor | Formulas follow the interface language in es, de, pt-BR and ru (names, booleans, errors, table specifiers, separators); infrastructure item 2 partly done | From 58a5a7849da1357b4526049641e3c30e785d0ce2 Mon Sep 17 00:00:00 2001 From: Herr-Enklo <168219178+Herr-Enklo@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:17:13 +0200 Subject: [PATCH 6/7] German: TABLE is MEHRFACHOPERATION; separator fields and edit text polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A data table's TABLE formula (Data › What-If Analysis › Data Table) was observed in the hidden German Excel: `Range.Table` gives `{=MEHRFACHOPERATION(B1;B2)}` and, with one input, `MEHRFACHOPERATION(;B1)`. The German table lists it, so interfaces that show TABLE through the formula language spell it as German Excel does; both forms are checked in german_observed.rs. The calc test now allows Excel's names GridCraft doesn't compute as functions (GETPIVOTDATA, IMAGE, TRANSLATE, DETECTLANGUAGE, TABLE) without failing when a branch starts computing one. - File › Options › Advanced: a character typed into the Decimal or Thousands separator field replaces the one in effect (the field used to need clearing first). - The editor keeps en-US numbers and dates for formula languages without an observed regional format (Spanish, Portuguese, Russian) and only shows their words. - German Export page text: CSV is "durch Trennzeichen getrennt", not "durch Kommas". Co-Authored-By: Claude Opus 5.5 --- crates/calc/src/tests.rs | 11 ++++------- crates/engine/src/display.rs | 4 ++++ crates/formula/src/input/de.rs | 4 +++- crates/formula/tests/german_observed.rs | 8 ++++++-- crates/ui-egui/src/backstage.rs | 9 ++++++--- crates/ui-egui/src/i18n/de.rs | 2 +- crates/ui-egui/tests/german_interface.rs | 14 ++++++++++++++ 7 files changed, 38 insertions(+), 14 deletions(-) diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index b108e5ca70..9da7998d6c 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -1270,15 +1270,12 @@ fn offset_extreme_arguments_return_ref() { #[test] fn formula_language_tables_only_name_functions_the_engine_knows() { // A typo'd canonical name in a language table would turn a valid local name into #NAME?. - // Excel functions GridCraft doesn't compute yet may be listed (a file's GETPIVOTDATA shows as - // PIVOTDATENZUORDNEN in German), once they really are unknown here. - const NOT_YET: [&str; 4] = ["DETECTLANGUAGE", "GETPIVOTDATA", "IMAGE", "TRANSLATE"]; - for name in NOT_YET { - assert!(!crate::eval::is_known_function(name), "{name} is implemented now: drop it from NOT_YET"); - } + // Excel's names GridCraft doesn't compute as functions may be listed too: a file's GETPIVOTDATA + // shows as PIVOTDATENZUORDNEN in German, a data table's TABLE as MEHRFACHOPERATION. + const ALSO_LISTED: [&str; 5] = ["DETECTLANGUAGE", "GETPIVOTDATA", "IMAGE", "TABLE", "TRANSLATE"]; for locale in gridcraft_formula::FormulaLocale::ALL { let unknown: Vec<&str> = - locale.translations().iter().map(|(en, _)| *en).filter(|en| !crate::eval::is_known_function(en) && !NOT_YET.contains(en)).collect(); + locale.translations().iter().map(|(en, _)| *en).filter(|en| !crate::eval::is_known_function(en) && !ALSO_LISTED.contains(en)).collect(); assert!(unknown.is_empty(), "{locale:?}: {unknown:?}"); } } diff --git a/crates/engine/src/display.rs b/crates/engine/src/display.rs index cf66776e10..00c683d950 100644 --- a/crates/engine/src/display.rs +++ b/crates/engine/src/display.rs @@ -126,7 +126,11 @@ pub fn edit_text_in(wb: &Workbook, sheet: &Sheet, c: CellRef, loc: &DisplayLocal if let Some(word) = loc.word(&cell.value) { return word.to_string(); } + // Only the words differ when numbers are canonical (Spanish, Portuguese, Russian for now). let Value::Number(n) = &cell.value else { return edit_text(wb, sheet, c) }; + if loc.number.is_canonical() { + return edit_text(wb, sheet, c); + } let n = *n; let raw = || { let text = Value::Number(n).display(); diff --git a/crates/formula/src/input/de.rs b/crates/formula/src/input/de.rs index f0fb799b53..362442e2d6 100644 --- a/crates/formula/src/input/de.rs +++ b/crates/formula/src/input/de.rs @@ -5,7 +5,8 @@ //! its automation interface, in synthetic workbooks that were never saved: every function //! GridCraft knows (and GETPIVOTDATA, IMAGE, TRANSLATE and DETECTLANGUAGE, which it doesn't yet) //! was entered in canonical form (`Range.Formula2`) and read back as German Excel shows it -//! (`Range.Formula2Local`). Never copied from a string table or a help page. The 99 functions not +//! (`Range.Formula2Local`); a Data › What-If Analysis › Data Table showed its `TABLE` formula as +//! `{=MEHRFACHOPERATION(B1;B2)}`. Never copied from a string table or a help page. The 99 functions not //! listed kept their English name there, among them ABS, ASC, BAHTTEXT, the BESSEL functions, //! CODE, COS, FILTER, INDEX, LAMBDA, LET, MAP, MAX, MIN, REDUCE, SCAN, TEXT and USDOLLAR. //! @@ -410,6 +411,7 @@ pub(super) const DIALECT: Dialect = Dialect { ("T.DIST.2T", "T.VERT.2S"), ("T.DIST.RT", "T.VERT.RE"), ("T.INV.2T", "T.INV.2S"), + ("TABLE", "MEHRFACHOPERATION"), ("TAKE", "ÜBERNEHMEN"), ("TANH", "TANHYP"), ("TBILLEQ", "TBILLÄQUIV"), diff --git a/crates/formula/tests/german_observed.rs b/crates/formula/tests/german_observed.rs index ce6c7f2730..b22d216866 100644 --- a/crates/formula/tests/german_observed.rs +++ b/crates/formula/tests/german_observed.rs @@ -4,7 +4,7 @@ //! observed black-box through its automation interface on 2026-10-13 (`Range.Formula2` in, //! `Range.Formula2Local` out), in synthetic workbooks that were never saved. Every function //! GridCraft knows is here, with GETPIVOTDATA, IMAGE, TRANSLATE and DETECTLANGUAGE, which Excel -//! knows and GridCraft doesn't yet. +//! knows and GridCraft doesn't yet, and the `TABLE` formula of a data table (`Range.Table`). use gridcraft_formula::FormulaLocale::{self, De}; use gridcraft_formula::{parse, parse_input, print, print_input}; @@ -463,6 +463,8 @@ const FUNCTIONS: &[(&str, &str)] = &[ ("T.INV(1,1)", "T.INV(1;1)"), ("T.INV.2T(1,1)", "T.INV.2S(1;1)"), ("T.TEST(1,1,1,1)", "T.TEST(1;1;1;1)"), + // Data › What-If Analysis › Data Table (two inputs), shown as `{=MEHRFACHOPERATION(B1;B2)}`. + ("TABLE(B1,B2)", "MEHRFACHOPERATION(B1;B2)"), ("TAKE(1,1)", "ÜBERNEHMEN(1;1)"), ("TAN(1)", "TAN(1)"), ("TANH(1)", "TANHYP(1)"), @@ -574,6 +576,8 @@ const SYNTAX: &[(&str, &str)] = &[ ("ERROR.TYPE(FILTER({1;2},{FALSE;FALSE}))", "FEHLER.TYP(FILTER({1;2};{FALSCH;FALSCH}))"), ("LAMBDA(x,x)", "LAMBDA(x;x)"), ("INDIRECT(\"Z1S1\",FALSE)", "INDIREKT(\"Z1S1\";FALSCH)"), + // A one-input data table. + ("TABLE(,B1)", "MEHRFACHOPERATION(;B1)"), ]; fn canonical(german: &str) -> String { @@ -611,7 +615,7 @@ fn the_observation_covers_every_function_the_table_lists() { let (_, shown) = call.unwrap_or_else(|| panic!("{en} was not observed")); assert!(shown.starts_with(&format!("{local}(")), "{en}: table {local}, Excel {shown}"); } - assert_eq!(FUNCTIONS.len(), 518); + assert_eq!(FUNCTIONS.len(), 519); } #[test] diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index 435f63e9e2..3a2108a339 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -982,10 +982,13 @@ fn options(app: &mut SheetApp, ui: &mut Ui) { egui::Grid::new("backstage_options_separators").num_columns(2).spacing(vec2(18.0, 8.0)).show(ui, |ui| { for (key, label, current) in [("decimal", "Decimal separator:", shown.decimal), ("thousands", "Thousands separator:", shown.thousands)] { ui.label(lang.tr(label)); + // The field holds the separator in effect; a character typed into it replaces it. let mut text = current.to_string(); - let field = egui::TextEdit::singleline(&mut text).char_limit(1).desired_width(28.0); - if ui.add_enabled(!use_system, field).changed() && text.chars().count() == 1 { - app.run_or_alert("options.separators", json!({"useSystem": false, key: text})); + let field = egui::TextEdit::singleline(&mut text).desired_width(28.0); + if ui.add_enabled(!use_system, field).changed() + && let Some(typed) = text.chars().find(|c| *c != current) + { + app.run_or_alert("options.separators", json!({"useSystem": false, key: typed.to_string()})); } ui.end_row(); } diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 1e64bcbd90..79764f8578 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -313,7 +313,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Save the sheet as a PDF document that keeps its page layout." => "Speichert das Blatt als PDF-Dokument mit unverändertem Seitenlayout.", "Change File Type" => "Dateityp ändern", "CSV (Comma delimited)" => "CSV (Trennzeichen-getrennt)", - "Save the active sheet as comma-separated text." => "Speichert das aktive Blatt als durch Kommas getrennten Text.", + "Save the active sheet as comma-separated text." => "Speichert das aktive Blatt als durch Trennzeichen getrennten Text.", "Web Page (.html)" => "Webseite (.html)", "Save the workbook as a web page." => "Speichert die Arbeitsmappe als Webseite.", "Options|General" => "Allgemein", diff --git a/crates/ui-egui/tests/german_interface.rs b/crates/ui-egui/tests/german_interface.rs index d539d7569e..e126a75d24 100644 --- a/crates/ui-egui/tests/german_interface.rs +++ b/crates/ui-egui/tests/german_interface.rs @@ -113,6 +113,20 @@ fn english_typing_is_unchanged() { assert_eq!(edit_text(&mut app, "A3"), "TRUE"); } +#[test] +fn other_formula_languages_keep_en_us_numbers_and_use_their_words() { + // Spanish has a formula table but no observed regional format yet. + let mut app = app("es"); + assert!(enter(&mut app, "A1", "1,5")); + assert_eq!(value(&app, "A1"), Value::Number(1.5)); + assert_eq!(edit_text(&mut app, "A1"), "1.5"); + assert!(enter(&mut app, "A2", "verdadero")); + assert_eq!(value(&app, "A2"), Value::Bool(true)); + assert_eq!(edit_text(&mut app, "A2"), "VERDADERO"); + assert!(enter(&mut app, "A3", "10/13/2026")); + assert_eq!(edit_text(&mut app, "A3"), "10/13/2026"); +} + #[test] fn use_system_separators_is_a_preference_not_a_workbook_setting() { let mut app = app("de"); From f3735ab64e47f26b173b8fceb5060031c80f621f Mon Sep 17 00:00:00 2001 From: Herr-Enklo <168219178+Herr-Enklo@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:17:25 +0200 Subject: [PATCH 7/7] Docs: the German table includes the data table's TABLE Co-Authored-By: Claude Opus 5.5 --- docs/formula-input.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/formula-input.md b/docs/formula-input.md index 05e974579b..2bfd088d38 100644 --- a/docs/formula-input.md +++ b/docs/formula-input.md @@ -96,8 +96,8 @@ Each table lives in `crates/formula/src/input/.rs` with its sources. German was observed black-box in Excel 365 (German interface, de-DE regional format) through its automation interface on 2026-10-13: every function GridCraft knows, plus four it doesn't yet, was entered in canonical form and read back as German Excel shows it, and so were the separators, -booleans, errors and table specifiers. `crates/formula/tests/german_observed.rs` checks all 518 -calls in both directions. The observation corrected two words Microsoft's German help pages give: +booleans, errors, table specifiers and a data table's `TABLE` formula (`MEHRFACHOPERATION`). +`crates/formula/tests/german_observed.rs` checks all 519 calls in both directions. The observation corrected two words Microsoft's German help pages give: `MAP` stays `MAP`, and German Excel shows `#CALC!` (not `#KALK!`). The Spanish, Portuguese and Russian names come from Microsoft Support's localized Excel function