collective

Browse / github.com/storytold/gridcraft / patch

Localization: Brazilian Portuguese formula entry (pt-BR)

Localization: Brazilian Portuguese formula entry (pt-BR) Português (Brasil) now reads and shows formulas with Brazilian function names (SOMA, MÉDIA, PROCV, SOMASES, SE, E/OU, PGTO...), VERDADEIRO/FALSO, decimal commas, `;` arguments and `\` array columns, like Spanish does. Files, clipboard and APIs keep canonical English syntax. - formula: FormulaLocale::PtBr with a 35-name table; locale tables and boolean aliases are now per-locale instead of Spanish-only; localized names match through same_name, so `média` is `MÉDIA`. - ui-egui: pt-BR maps to the new formula locale; Portuguese help (descriptions and argument names) for the translated functions.

Status
open
Score
0.00
Adopters
0 other operators
Author
maxgiovanny (imported from pull request #2754, open)
Base
89ac453efa7ea03a3ab0b0d24fd85ca51172f5cc
Size
+407 −53 in 11 files
Review
pending
Shared
2026-10-28
Id
0d44269b4ff1946bb9455b4b

How it went

No other operator has reported using it yet.

Diff

raw · README.md crates/formula/src/input.rs crates/formula/src/parser.rs crates/formula/tests/it/main.rs crates/formula/tests/it/portuguese_input.rs crates/ui-egui/src/formula_locale.rs crates/ui-egui/src/i18n.rs crates/ui-egui/src/i18n/pt_functions.rs crates/ui-egui/tests/it/formula_locale.rs docs/formula-input.md docs/localization-parity.md

diff --git a/README.md b/README.md--- a/README.md+++ b/README.md@@ -116,7 +116,7 @@ cargo run --release -p gridcraft -- --sample sales --control 7979   # + JSON con cargo xtask ci                                                 # fmt, clippy, tests, assets, layering, wasm ``` -Choose Español under View → Interface language for [Spanish formula names, decimal commas, and semicolon arguments](docs/formula-input.md).+Choose Español or Português (Brasil) under View → Interface language for [Spanish or Brazilian Portuguese formula names, decimal commas, and semicolon arguments](docs/formula-input.md). Workbooks, clipboard exchange, and programmatic APIs retain canonical English syntax.  ### Webdiff --git a/crates/formula/src/input.rs b/crates/formula/src/input.rs--- a/crates/formula/src/input.rs+++ b/crates/formula/src/input.rs@@ -9,6 +9,8 @@ pub enum FormulaLocale {     #[default]     En,     Es,+    /// Brazilian Portuguese: `SOMA`, `PROCV`, `SE`, decimal commas and `;` arguments.+    PtBr, }  const SPANISH: &[(&str, &str)] = &[@@ -30,29 +32,76 @@ const SPANISH: &[(&str, &str)] = &[     ("FALSE", "FALSO"), ]; +/// Brazilian Portuguese names, as the Portuguese (Brazil) edition of the target app spells them.+/// Accented names (`MÉDIA`, `CONT.NÚM`, `ÍNDICE`) also match unaccented-case input such as+/// `média`, through [`crate::same_name`].+const PORTUGUESE_BR: &[(&str, &str)] = &[+    ("SUM", "SOMA"),+    ("AVERAGE", "MÉDIA"),+    ("IF", "SE"),+    ("COUNT", "CONT.NÚM"),+    ("COUNTA", "CONT.VALORES"),+    ("SUMIF", "SOMASE"),+    ("SUMIFS", "SOMASES"),+    ("COUNTIF", "CONT.SE"),+    ("COUNTIFS", "CONT.SES"),+    ("SUMPRODUCT", "SOMARPRODUTO"),+    ("VLOOKUP", "PROCV"),+    ("HLOOKUP", "PROCH"),+    ("XLOOKUP", "PROCX"),+    ("INDEX", "ÍNDICE"),+    ("MATCH", "CORRESP"),+    ("ROUND", "ARRED"),+    ("ROUNDUP", "ARREDONDAR.PARA.CIMA"),+    ("ROUNDDOWN", "ARREDONDAR.PARA.BAIXO"),+    ("TRUNC", "TRUNCAR"),+    ("MAX", "MÁXIMO"),+    ("MIN", "MÍNIMO"),+    ("AND", "E"),+    ("OR", "OU"),+    ("NOT", "NÃO"),+    ("IFERROR", "SEERRO"),+    ("CONCATENATE", "CONCATENAR"),+    ("TODAY", "HOJE"),+    ("NOW", "AGORA"),+    // Financial block, as in Spanish (#1986).+    ("NPV", "VPL"),+    ("IRR", "TIR"),+    ("PMT", "PGTO"),+    ("PV", "VP"),+    ("FV", "VF"),+    ("TRUE", "VERDADEIRO"),+    ("FALSE", "FALSO"),+];+ impl FormulaLocale {+    /// Canonical English name and localized name of every translated function and boolean.+    fn table(self) -> &'static [(&'static str, &'static str)] {+        match self {+            Self::En => &[],+            Self::Es => SPANISH,+            Self::PtBr => PORTUGUESE_BR,+        }+    }+    /// Separates function arguments and reference unions: `,` in English, `;` otherwise.     pub fn list_separator(self) -> char {-        if self == Self::Es { ';' } else { ',' }+        if self == Self::En { ',' } else { ';' }     }+    /// The decimal mark of number literals: `.` in English, `,` otherwise.     pub fn decimal_separator(self) -> char {-        if self == Self::Es { ',' } else { '.' }+        if self == Self::En { '.' } else { ',' }     }+    /// Separates columns inside an array constant: `,` in English, `\` otherwise (rows use `;`).     pub fn array_column_separator(self) -> char {-        if self == Self::Es { '\\' } else { ',' }+        if self == Self::En { ',' } else { '\\' }     }+    /// The localized name of a canonical English function, or `canonical` when untranslated.     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.table().iter().find(|(en, _)| en.eq_ignore_ascii_case(canonical)).map(|(_, local)| *local).unwrap_or(canonical)     }+    /// The canonical English name of a localized function (any case), or `localized` unchanged.     pub fn canonical_name(self, localized: &str) -> &str {-        if self == Self::Es {-            SPANISH.iter().find(|(_, es)| es.eq_ignore_ascii_case(localized)).map(|(en, _)| *en).unwrap_or(localized)-        } else {-            localized-        }+        self.table().iter().find(|(_, local)| crate::same_name(local, localized)).map(|(en, _)| *en).unwrap_or(localized)     } } @@ -60,8 +109,8 @@ impl FormulaLocale { /// LET/LAMBDA bindings take precedence over aliases. File and API syntax uses [`crate::parse`]. pub fn parse_input(src: &str, locale: FormulaLocale, known_name: impl Fn(&str) -> bool) -> Result<Expr, ParseError> {     let mut expr = parser::parse_syntax(src, locale)?;-    if locale == FormulaLocale::Es {-        normalize(&mut expr, &known_name, &mut Vec::new(), 0, false)?;+    if locale != FormulaLocale::En {+        normalize(&mut expr, locale, &known_name, &mut Vec::new(), 0, false)?;     }     Ok(expr) }@@ -96,7 +145,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();@@ -121,30 +170,39 @@ pub fn print_input(expr: &Expr, locale: FormulaLocale, known_name: impl Fn(&str)     Ok(out) } -pub(crate) fn boolean_alias(name: &str) -> Option<bool> {-    if name.eq_ignore_ascii_case("VERDADERO") {-        Some(true)-    } else if name.eq_ignore_ascii_case("FALSO") {-        Some(false)-    } else {-        None+/// `VERDADERO` / `VERDADEIRO` -> `true`: the localized spelling of a boolean literal.+pub(crate) fn boolean_alias(name: &str, locale: FormulaLocale) -> Option<bool> {+    // Only the localized spelling: an English `TRUE` reaching here (`TRUE[...]`) stays a name.+    match locale.table().iter().find(|(_, local)| crate::same_name(local, name)).map(|(en, _)| *en) {+        Some("TRUE") => Some(true),+        Some("FALSE") => Some(false),+        _ => None,     } } -fn normalize(expr: &mut Expr, known_name: &impl Fn(&str) -> bool, bindings: &mut Vec<String>, depth: usize, display: bool) -> Result<(), ParseError> {+/// Rewrites function names and boolean literals between the canonical and `locale` spellings:+/// to canonical on input, to localized for `display`. Defined names and LET/LAMBDA bindings win.+fn normalize(+    expr: &mut Expr,+    locale: FormulaLocale,+    known_name: &impl Fn(&str) -> bool,+    bindings: &mut Vec<String>,+    depth: usize,+    display: bool,+) -> Result<(), ParseError> {     if depth > parser::MAX_DEPTH {         return Err(ParseError { msg: "formula is nested too deeply".into(), pos: 0 });     }     let bound = |name: &str, bindings: &[String]| bindings.iter().rev().any(|n| crate::same_name(n, name)) || known_name(name);     match expr {         Expr::Bool(value) if display => {-            let alias = if *value { "VERDADERO" } else { "FALSO" };+            let alias = locale.function_name(if *value { "TRUE" } else { "FALSE" });             if !bound(alias, bindings) {                 *expr = Expr::Name(alias.into());             }         }         Expr::Name(name) if !display && !bound(name, bindings) => {-            if let Some(value) = boolean_alias(name) {+            if let Some(value) = boolean_alias(name, locale) {                 *expr = Expr::Bool(value);             }         }@@ -154,13 +212,13 @@ fn normalize(expr: &mut Expr, known_name: &impl Fn(&str) -> bool, bindings: &mut                 for pair in pairs.chunks_mut(2) {                     if let [declaration, value] = pair {                         // The new name is not in scope in its own value expression.-                        normalize(value, known_name, bindings, depth + 1, display)?;+                        normalize(value, locale, known_name, bindings, depth + 1, display)?;                         if let Expr::Name(name) = declaration {                             bindings.push(name.clone());                         }                     }                 }-                normalize(body, known_name, bindings, depth + 1, display)?;+                normalize(body, locale, known_name, bindings, depth + 1, display)?;             }             bindings.truncate(saved);         }@@ -172,40 +230,40 @@ fn normalize(expr: &mut Expr, known_name: &impl Fn(&str) -> bool, bindings: &mut                         bindings.push(name.clone());                     }                 }-                normalize(body, known_name, bindings, depth + 1, display)?;+                normalize(body, locale, known_name, bindings, depth + 1, display)?;             }             bindings.truncate(saved);         }         Expr::Call(name, args) => {-            let alias = if display { FormulaLocale::Es.function_name(name) } else { FormulaLocale::Es.canonical_name(name) };+            let alias = if display { locale.function_name(name) } else { locale.canonical_name(name) };             if !bound(name, bindings) && (!display || !bound(alias, bindings)) {                 *name = alias.to_string();             }             for arg in args {-                normalize(arg, known_name, bindings, depth + 1, display)?;+                normalize(arg, locale, known_name, bindings, depth + 1, display)?;             }         }         Expr::Invoke(callee, args) => {-            normalize(callee, known_name, bindings, depth + 1, display)?;+            normalize(callee, locale, known_name, bindings, depth + 1, display)?;             for arg in args {-                normalize(arg, known_name, bindings, depth + 1, display)?;+                normalize(arg, locale, known_name, bindings, depth + 1, display)?;             }         }-        Expr::Unary(_, child) | Expr::Paren(child) => normalize(child, known_name, bindings, depth + 1, display)?,+        Expr::Unary(_, child) | Expr::Paren(child) => normalize(child, locale, known_name, bindings, depth + 1, display)?,         Expr::Binary(..) => {             // Follow the left spine of a flat chain (A1+A2+…) in a loop: it is not nesting.             // Each call leaves `bindings` as it found them, so the order does not matter.             let mut node = expr;             while let Expr::Binary(_, left, right) = node {-                normalize(right, known_name, bindings, depth + 1, display)?;+                normalize(right, locale, known_name, bindings, depth + 1, display)?;                 node = left;             }-            normalize(node, known_name, bindings, depth, display)?;+            normalize(node, locale, known_name, bindings, depth, display)?;         }         Expr::Array(rows) if display => {             for row in rows {                 for child in row {-                    normalize(child, known_name, bindings, depth + 1, display)?;+                    normalize(child, locale, known_name, bindings, depth + 1, display)?;                 }             }         }diff --git a/crates/formula/src/parser.rs b/crates/formula/src/parser.rs--- a/crates/formula/src/parser.rs+++ b/crates/formula/src/parser.rs@@ -66,7 +66,7 @@ fn parse_top(src: &str, locale: crate::FormulaLocale, allow_union: bool) -> Resu     }     let toks = tokenize_locale(body, locale).map_err(|e| ParseError { msg: e.msg, pos: e.pos })?;     let toks = significant_spaces(toks);-    let mut p = Parser { toks, i: 0, depth: 0, input: locale == crate::FormulaLocale::Es };+    let mut p = Parser { toks, i: 0, depth: 0, locale };     let e = p.expr(0, allow_union)?;     match p.peek() {         Tok::Eof => Ok(e),@@ -106,7 +106,8 @@ struct Parser {     toks: Vec<Token>,     i: usize,     depth: usize,-    input: bool,+    /// The UI formula syntax; aliases such as `VERDADERO` are only read from localized input.+    locale: crate::FormulaLocale, }  fn word_is_cols(w: &str) -> Option<(u32, bool)> {@@ -434,7 +435,7 @@ impl Parser {                 },                 Tok::Text(s) => Expr::Text(Arc::from(s)),                 Tok::Bool(b) => Expr::Bool(b),-                Tok::Word(w) if self.input => crate::input::boolean_alias(&w)+                Tok::Word(w) if self.locale != crate::FormulaLocale::En => crate::input::boolean_alias(&w, self.locale)                     .map(Expr::Bool)                     .ok_or_else(|| self.err(format!("array constants may only contain constants, found {}", describe(&Tok::Word(w)))))?,                 Tok::Error(e) => Expr::Error(e),diff --git a/crates/formula/tests/it/main.rs b/crates/formula/tests/it/main.rs--- a/crates/formula/tests/it/main.rs+++ b/crates/formula/tests/it/main.rs@@ -1,4 +1,5 @@ //! The crate's integration tests, one module per file, built as one test binary. +mod portuguese_input; mod printer_roundtrip; mod spanish_input;diff --git a/crates/formula/tests/it/portuguese_input.rs b/crates/formula/tests/it/portuguese_input.rsnew file mode 100644--- /dev/null+++ b/crates/formula/tests/it/portuguese_input.rs@@ -0,0 +1,89 @@+use gridcraft_formula::{FormulaLocale, parse, parse_input, print, print_input};++const PT: FormulaLocale = FormulaLocale::PtBr;++fn input(src: &str) -> String {+    print(&parse_input(src, PT, |_| false).unwrap())+}++#[test]+fn portuguese_aliases_and_argument_separators() {+    for (src, expected) in [+        ("=soma(A1:A10)", "SUM(A1:A10)"),+        ("MÉDIA(A1:A10)", "AVERAGE(A1:A10)"),+        ("média(A1:A10)", "AVERAGE(A1:A10)"),+        ("SE(A1>0;\"Sim\";\"Não\")", "IF(A1>0,\"Sim\",\"Não\")"),+        ("CONT.NÚM(A1:A10)", "COUNT(A1:A10)"),+        ("CONT.VALORES(A1:A10)", "COUNTA(A1:A10)"),+        ("SOMASE(A1:A10;\">0\")", "SUMIF(A1:A10,\">0\")"),+        ("SOMASES(C1:C9;A1:A9;\"x\";B1:B9;\">0\")", "SUMIFS(C1:C9,A1:A9,\"x\",B1:B9,\">0\")"),+        ("CONT.SE(A1:A10;\">0\")", "COUNTIF(A1:A10,\">0\")"),+        ("CONT.SES(A1:A9;\"x\")", "COUNTIFS(A1:A9,\"x\")"),+        ("SOMARPRODUTO(A1:A3;B1:B3)", "SUMPRODUCT(A1:A3,B1:B3)"),+        ("PROCV(A2;D:E;2;FALSO)", "VLOOKUP(A2,D:E,2,FALSE)"),+        ("PROCH(A2;D1:F2;2;0)", "HLOOKUP(A2,D1:F2,2,0)"),+        ("PROCX(A2;D:D;E:E)", "XLOOKUP(A2,D:D,E:E)"),+        ("ÍNDICE(A1:B9;CORRESP(5;A1:A9;0);2)", "INDEX(A1:B9,MATCH(5,A1:A9,0),2)"),+        ("ARRED(A1*1,1;2)", "ROUND(A1*1.1,2)"),+        ("ARREDONDAR.PARA.CIMA(A1;0)+ARREDONDAR.PARA.BAIXO(A1;0)", "ROUNDUP(A1,0)+ROUNDDOWN(A1,0)"),+        ("TRUNCAR(A1)", "TRUNC(A1)"),+        ("MÁXIMO(A1:A3)-MÍNIMO(A1:A3)", "MAX(A1:A3)-MIN(A1:A3)"),+        ("E(A1>0;OU(B1;NÃO(C1)))", "AND(A1>0,OR(B1,NOT(C1)))"),+        ("SEERRO(1/0;0)", "IFERROR(1/0,0)"),+        ("CONCATENAR(\"a\";\"b\")", "CONCATENATE(\"a\",\"b\")"),+        ("HOJE()+AGORA()", "TODAY()+NOW()"),+        ("VPL(0,1;A1:A5)", "NPV(0.1,A1:A5)"),+        ("TIR(A1:A5;0,1)", "IRR(A1:A5,0.1)"),+        ("PGTO(0,08/12;10;10000)", "PMT(0.08/12,10,10000)"),+        ("VP(0,05;12;100)+VF(0,05;12;100)", "PV(0.05,12,100)+FV(0.05,12,100)"),+        ("=verdadeiro", "TRUE"),+        ("=falso", "FALSE"),+        ("VERDADEIRO()", "TRUE()"),+        // English names still work, with Portuguese separators.+        ("SUM(1;SOMA(2;3))", "SUM(1,SUM(2,3))"),+        ("LAMBDA(x;x+1)(2)", "LAMBDA(x,x+1)(2)"),+    ] {+        assert_eq!(input(src), expected, "{src}");+    }+}++#[test]+fn portuguese_does_not_read_spanish_aliases() {+    // `VERDADERO` and `SUMA` are Spanish: in Portuguese they stay names.+    assert_eq!(input("SUMA(1;2)+VERDADERO"), "SUMA(1,2)+VERDADERO");+    assert!(parse_input("{VERDADERO}", PT, |_| false).is_err());+}++#[test]+fn portuguese_arrays_references_and_text_are_preserved() {+    for (src, expected) in [+        ("SOMA({1\\2;3\\4};5)", "SUM({1,2;3,4},5)"),+        ("{VERDADEIRO\\FALSO;TRUE\\FALSE}", "{TRUE,FALSE;TRUE,FALSE}"),+        ("\"SOMA;FALSO\"&\"a\"", "\"SOMA;FALSO\"&\"a\""),+        ("'SOMA;FALSO'!A1+SOMA!B2", "'SOMA;FALSO'!A1+SOMA!B2"),+        ("E1+E2", "E1+E2"),+    ] {+        assert_eq!(input(src), expected, "{src}");+    }+    assert!(parse_input("SOMA(1.5;2)", PT, |_| false).is_err());+}++#[test]+fn portuguese_display_round_trips() {+    let expr = parse(r#"IF(AND(A1>0,TRUE),SUM({1.5,2;3,4},0.25),AVERAGE(MAX(A1:A3),"a;b"))"#).unwrap();+    let shown = print_input(&expr, PT, |_| false).unwrap();+    assert_eq!(shown, r#"SE(E(A1>0;VERDADEIRO);SOMA({1,5\2;3\4};0,25);MÉDIA(MÁXIMO(A1:A3);"a;b"))"#);+    assert_eq!(print(&parse_input(&shown, PT, |_| false).unwrap()), print(&expr));+}++#[test]+fn portuguese_input_preserves_defined_and_local_names() {+    let known = |name: &str| ["SOMA", "FALSO"].iter().any(|n| n.eq_ignore_ascii_case(name));+    let expr = parse_input("SOMA(2;3)+FALSO+SE(VERDADEIRO;4;5)", PT, known).unwrap();+    assert_eq!(print(&expr), "SOMA(2,3)+FALSO+IF(TRUE,4,5)");+    assert_eq!(input("LET(SOMA;LAMBDA(x;x+1);SOMA(2))"), "LET(SOMA,LAMBDA(x,x+1),SOMA(2))");+    assert_eq!(input("LET(falso;7;falso)+FALSO"), "LET(falso,7,falso)+FALSE");+    // A defined name `SOMA` keeps SUM shown in English so the formula's meaning never changes.+    let expr = parse("SUM(1,2)").unwrap();+    assert_eq!(print_input(&expr, PT, known).unwrap(), "SUM(1;2)");+}diff --git a/crates/ui-egui/src/formula_locale.rs b/crates/ui-egui/src/formula_locale.rs--- a/crates/ui-egui/src/formula_locale.rs+++ b/crates/ui-egui/src/formula_locale.rs@@ -170,4 +170,62 @@ mod tests {         assert_eq!(description("SUMIFS", Es, Language::Es).as_deref(), Some("Adds the cells that meet every condition."));         assert_eq!(sig("IF", En, Language::En), "IF(logical_test, [value_if_true], [value_if_false])");     }++    /// Brazilian Portuguese hints: names, `;` and argument names, from either spelling.+    #[test]+    fn portuguese_function_help() {+        use FormulaLocale::PtBr;+        let pt = Language::PtBr;+        assert_eq!(sig("procv", PtBr, pt), "PROCV(valor_procurado; matriz_tabela; núm_índice_coluna; [procurar_intervalo])");+        assert_eq!(sig("SOMASES", PtBr, pt), "SOMASES(intervalo_soma; intervalo_critérios1; critérios1; ...)");+        assert_eq!(sig("média", PtBr, pt), "MÉDIA(núm1; [núm2]; ...)");+        assert_eq!(description("SE", PtBr, pt).as_deref(), Some("Retorna um valor conforme uma condição."));+        // Every function with a Portuguese name has Portuguese help and argument names.+        for name in [+            "SOMA",+            "MÉDIA",+            "SE",+            "CONT.NÚM",+            "CONT.VALORES",+            "SOMASE",+            "SOMASES",+            "CONT.SE",+            "CONT.SES",+            "SOMARPRODUTO",+            "PROCV",+            "PROCH",+            "PROCX",+            "ÍNDICE",+            "CORRESP",+            "ARRED",+            "ARREDONDAR.PARA.CIMA",+            "ARREDONDAR.PARA.BAIXO",+            "TRUNCAR",+            "MÁXIMO",+            "MÍNIMO",+            "E",+            "OU",+            "NÃO",+            "SEERRO",+            "CONCATENAR",+            "HOJE",+            "AGORA",+            "VPL",+            "TIR",+            "PGTO",+            "VP",+            "VF",+            "VERDADEIRO",+            "FALSO",+        ] {+            let english = crate::formula_bar::function_description(PtBr.canonical_name(name)).unwrap_or_default();+            assert!(pt.function_description(&english).is_some(), "{name}: no Portuguese description for {english:?}");+            let hint = sig(name, PtBr, pt);+            assert!(hint.starts_with(&format!("{name}(")), "{name}: {hint}");+            // Argument names are translated (all or none, so a changed hint means all).+            if !hint.ends_with("()") {+                assert_ne!(hint, sig(name, PtBr, Language::En), "{name}: untranslated arguments");+            }+        }+    } }diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs--- a/crates/ui-egui/src/i18n.rs+++ b/crates/ui-egui/src/i18n.rs@@ -21,6 +21,7 @@ mod ko; mod nl; mod pl; mod pt;+mod pt_functions; mod ro; mod ru; mod sr;@@ -186,7 +187,12 @@ impl Language {     }      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::PtBr => FormulaLocale::PtBr,+            _ => FormulaLocale::En,+        }     }      /// Translates an interface label; untranslated labels fall back to English.@@ -243,6 +249,7 @@ impl Language {     fn function_help(self) -> Option<(HelpTable, HelpTable)> {         match self {             Self::Es => Some((es_functions::DESCRIPTIONS, es_functions::ARGUMENTS)),+            Self::PtBr => Some((pt_functions::DESCRIPTIONS, pt_functions::ARGUMENTS)),             _ => None,         }     }diff --git a/crates/ui-egui/src/i18n/pt_functions.rs b/crates/ui-egui/src/i18n/pt_functions.rsnew file mode 100644--- /dev/null+++ b/crates/ui-egui/src/i18n/pt_functions.rs@@ -0,0 +1,99 @@+//! Brazilian Portuguese help for the worksheet functions that have Portuguese formula names+//! (SOMA, PROCV, SE…); the others keep their English help. Wording is our own.++/// Function descriptions, sorted by the English text (looked up by binary search).+pub(super) const DESCRIPTIONS: &[(&str, &str)] = &[+    ("Adds all the numbers in the arguments.", "Soma os números."),+    ("Adds the cells that meet a condition.", "Soma as células que atendem a um critério."),+    ("Adds the cells that meet every condition.", "Soma as células que atendem a todos os critérios."),+    ("Arithmetic mean of the numbers.", "Retorna a média aritmética dos números."),+    ("Counts the cells that meet a condition.", "Conta as células que atendem a um critério."),+    ("Counts the cells that meet every condition.", "Conta as células que atendem a todos os critérios."),+    ("Counts the non-empty values.", "Conta os valores não vazios."),+    ("Counts the numbers.", "Conta as células que contêm números."),+    ("Cuts off the fractional part of a number at the given digits.", "Trunca a parte decimal de um número."),+    (+        "Finds a value in the first column of a table and returns the value in the same row of another column.",+        "Procura um valor na primeira coluna de uma tabela e retorna o valor da mesma linha em outra coluna.",+    ),+    (+        "Finds a value in the first row of a table and returns the value in the same column of another row.",+        "Procura um valor na primeira linha de uma tabela e retorna o valor da mesma coluna em outra linha.",+    ),+    ("Future value of an investment with constant payments and rate.", "Valor futuro de um investimento com pagamentos e taxa constantes."),+    ("Internal rate of return of periodic cash flows.", "Taxa interna de retorno de fluxos de caixa periódicos."),+    ("Joins several text values into one.", "Junta vários textos em um só."),+    ("Largest number (0 when there are none).", "Retorna o maior número."),+    (+        "Multiplies matching elements of equally sized arrays and adds the products.",+        "Multiplica os elementos correspondentes das matrizes e soma os produtos.",+    ),+    ("Net present value of periodic cash flows at a discount rate.", "Valor presente líquido de fluxos de caixa periódicos."),+    (+        "Periodic payment for a loan or annuity with constant payments and rate.",+        "Pagamento periódico de um empréstimo com parcelas e taxa constantes.",+    ),+    ("Present value of a series of equal future payments.", "Valor presente de uma série de pagamentos iguais."),+    ("Returns a fallback when the value is an error, otherwise the value.", "Retorna outro valor quando a expressão resulta em erro."),+    ("Returns one value when a condition is TRUE and another when it is FALSE.", "Retorna um valor conforme uma condição."),+    ("Returns the 1-based position of a value in a row or column.", "Retorna a posição de um valor em uma linha ou coluna."),+    ("Returns the current date and time.", "Retorna a data e a hora atuais."),+    ("Returns the current date.", "Retorna a data atual."),+    (+        "Returns the element, row or column of an array at the given position.",+        "Retorna o elemento, a linha ou a coluna de uma matriz na posição indicada.",+    ),+    ("Returns the logical value FALSE.", "Retorna o valor lógico FALSO."),+    ("Returns the logical value TRUE.", "Retorna o valor lógico VERDADEIRO."),+    ("Reverses a logical value.", "Inverte um valor lógico."),+    ("Rounds a number away from zero to a given number of digits.", "Arredonda um número para cima, afastando-o de zero."),+    ("Rounds a number to a given number of digits, halves away from zero.", "Arredonda um número para a quantidade de dígitos indicada."),+    ("Rounds a number toward zero to a given number of digits.", "Arredonda um número para baixo, em direção a zero."),+    (+        "Searches a row or column and returns the matching item, row or column from another array.",+        "Procura em uma linha ou coluna e retorna o item correspondente de outra matriz.",+    ),+    ("Smallest number (0 when there are none).", "Retorna o menor número."),+    ("TRUE when at least one argument is TRUE.", "Retorna VERDADEIRO se algum argumento for VERDADEIRO."),+    ("TRUE when every argument is TRUE.", "Retorna VERDADEIRO se todos os argumentos forem VERDADEIRO."),+];++/// Argument names without brackets or index (`number`, not `[number2]`), sorted by the English.+pub(super) const ARGUMENTS: &[(&str, &str)] = &[+    ("area_num", "núm_área"),+    ("array", "matriz"),+    ("col_index_num", "núm_índice_coluna"),+    ("column_num", "núm_coluna"),+    ("criteria", "critérios"),+    ("criteria_range", "intervalo_critérios"),+    ("fv", "vf"),+    ("guess", "estimativa"),+    ("if_not_found", "se_não_encontrado"),+    ("logical", "lógico"),+    ("logical_test", "teste_lógico"),+    ("lookup_array", "matriz_pesquisa"),+    ("lookup_value", "valor_procurado"),+    ("match_mode", "modo_correspondência"),+    ("match_type", "tipo_correspondência"),+    ("nper", "nper"),+    ("num_digits", "núm_dígitos"),+    ("number", "núm"),+    ("pmt", "pgto"),+    ("pv", "vp"),+    ("range", "intervalo"),+    ("range_lookup", "procurar_intervalo"),+    ("rate", "taxa"),+    ("return_array", "matriz_retorno"),+    ("row_index_num", "núm_índice_linha"),+    ("row_num", "núm_linha"),+    ("search_mode", "modo_pesquisa"),+    ("sum_range", "intervalo_soma"),+    ("table_array", "matriz_tabela"),+    ("text", "texto"),+    ("type", "tipo"),+    ("value", "valor"),+    ("value_if_error", "valor_se_erro"),+    ("value_if_false", "valor_se_falso"),+    ("value_if_true", "valor_se_verdadeiro"),+    ("values", "valores"),+];diff --git a/crates/ui-egui/tests/it/formula_locale.rs b/crates/ui-egui/tests/it/formula_locale.rs--- a/crates/ui-egui/tests/it/formula_locale.rs+++ b/crates/ui-egui/tests/it/formula_locale.rs@@ -174,14 +174,25 @@ fn canonical_engine_templates_enter_the_selected_formula_language() { }  #[test]-fn portuguese_interface_retains_its_canonical_formula_fallback() {+fn portuguese_formulas_use_brazilian_names_and_separators() {     let mut app = app();     app.run("app.language.set", json!({"code":"pt-BR"})).unwrap();     assert_eq!(app.ui.language, Language::PtBr);-    assert!(enter(&mut app, "=SUM(1.5,2.25)"));+    assert!(enter(&mut app, "=SE(VERDADEIRO;SOMA(1,5;2,25);0)"));+    assert_eq!(formula(&app), "IF(TRUE,SUM(1.5,2.25),0)");+    // The file keeps English; reopening shows Portuguese again.+    let saved = app.run("file.save", json!({"format":"xlsx"})).unwrap();+    app.run("file.open", json!({"name":"pt.xlsx", "base64":saved["base64"]})).unwrap();     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, "=SE(VERDADEIRO;SOMA(1,5;2,25);0)");+    assert!(app.commit_edit(0, 0, false, false));+    assert_eq!(formula(&app), "IF(TRUE,SUM(1.5,2.25),0)");+    // Accented names match in any case; English separators are refused, as in Spanish.+    assert!(enter(&mut app, "=média(1;3)"));+    assert_eq!(formula(&app), "AVERAGE(1,3)");+    assert!(!enter(&mut app, "=SUM(1.5,2.25)"));+    // The API keeps English syntax.+    assert!(app.run("cell.set", json!({"cell":"B1","input":"=SOMA(1;2)"})).is_err());     let restored: UiState = serde_json::from_str(&serde_json::to_string(&app.ui).unwrap()).unwrap();     assert_eq!(restored.language, Language::PtBr); }diff --git a/docs/formula-input.md b/docs/formula-input.md--- a/docs/formula-input.md+++ b/docs/formula-input.md@@ -2,6 +2,7 @@  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.+Choose **Português (Brasil)** for Brazilian Portuguese formula entry and display (see below). English and the other interface languages currently use the English formula table and separators.  | Spanish | Canonical English |@@ -36,3 +37,26 @@ 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.++## Brazilian Portuguese++**Português (Brasil)** uses the Brazilian function names below with the same separators as+Spanish: decimal commas, `;` between arguments and `\` between array columns. For example,+`=SE(VERDADEIRO;SOMA(1,5;2,25);0)` evaluates to 3.75. Accented names match in any case+(`média` is `MÉDIA`). Spanish aliases such as `SUMA` are not read in Portuguese, and English+names (`SUM`) keep working with Portuguese separators.++| Brazilian Portuguese | Canonical English |+| -------------------- | ----------------- |+| `SOMA`, `SOMASE`, `SOMASES`, `SOMARPRODUTO` | `SUM`, `SUMIF`, `SUMIFS`, `SUMPRODUCT` |+| `MÉDIA`, `MÁXIMO`, `MÍNIMO` | `AVERAGE`, `MAX`, `MIN` |+| `CONT.NÚM`, `CONT.VALORES`, `CONT.SE`, `CONT.SES` | `COUNT`, `COUNTA`, `COUNTIF`, `COUNTIFS` |+| `SE`, `SEERRO`, `E`, `OU`, `NÃO` | `IF`, `IFERROR`, `AND`, `OR`, `NOT` |+| `PROCV`, `PROCH`, `PROCX`, `ÍNDICE`, `CORRESP` | `VLOOKUP`, `HLOOKUP`, `XLOOKUP`, `INDEX`, `MATCH` |+| `ARRED`, `ARREDONDAR.PARA.CIMA`, `ARREDONDAR.PARA.BAIXO`, `TRUNCAR` | `ROUND`, `ROUNDUP`, `ROUNDDOWN`, `TRUNC` |+| `CONCATENAR`, `HOJE`, `AGORA` | `CONCATENATE`, `TODAY`, `NOW` |+| `VPL`, `TIR`, `PGTO`, `VP`, `VF` | `NPV`, `IRR`, `PMT`, `PV`, `FV` |+| `VERDADEIRO`, `FALSO` | `TRUE`, `FALSE` |++The editor, help, name-precedence and file-boundary rules above apply to Portuguese as they do+to Spanish.diff --git a/docs/localization-parity.md b/docs/localization-parity.md--- a/docs/localization-parity.md+++ b/docs/localization-parity.md@@ -1,13 +1,13 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (missing translations are listed by `cargo xtask i18n-report` and never fail a check) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (Brazilian Portuguese formula names and separators) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4  Per-language status against Excel, which ships its UI in dozens of languages (Windows language packs cover 100+), with localized function names, argument separators, number and date formats, right-to-left sheets and full complex-script support. Part of [target-app-parity.md](target-app-parity.md). **Localization: ~10% ready, 100–165 h** (estimated). -## Where we are (measured from source on `main`, after the 2026-10-10 merges)+## Where we are (baseline measured from source on `main` after the 2026-10-10 merges; updated through 2026-10-27)  - **A first catalog exists, for the ribbon only.** `crates/ui-egui/src/i18n.rs` (merged 10-10 in   #31, #104, #82; German added 10-11) adds an interface-language preference that defaults to the@@ -22,7 +22,8 @@ right-to-left sheets and full complex-script support. Part of   menus) follows the interface language through per-language tables keyed by the catalog's   English text (`i18n/<lang>_functions.rs`): descriptions, and argument names from which the   hint is assembled with the formula syntax's function name and separator. Spanish translates-  the 10 common functions it had help for (SUM, AVERAGE, IF…); other languages show the English+  the 10 common functions it had help for (SUM, AVERAGE, IF…); Brazilian Portuguese translates+  the 35 functions it has formula names for (SOMA, PROCV, SE…); other languages show the English   help. - **A first catalog exists.** `crates/ui-egui/src/i18n.rs` (merged 10-10 in #31, #104, #82; German   added 10-11) adds an interface-language preference that defaults to the system language (closes@@ -37,9 +38,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 the ribbon-only languages cover **~12%** and   Japanese and Portuguese **~43%** (measured count / estimated total).-- **en-US only formulas and formats.** Function names, `,` argument separator, `.` decimal-  separator, en-US number and date formats (`crates/numfmt` says "en-US only"). Users ask for-  localized function names and `;` separators (#45). German with localized numbers and formulas+- **Formulas: en-US plus Spanish and Brazilian Portuguese entry.** Spanish and Brazilian+  Portuguese (`pt-BR`: `SOMA`, `MÉDIA`, `PROCV`, `SOMASES`, `SE`, `E`/`OU`, `PGTO`…, 35 names)+  translate common function names and use `;` arguments, `,` decimals and `\` array columns in+  the cell editor and formula bar; files and APIs keep en-US syntax. Other languages use en-US+  function names, `,` argument separator, `.` decimal separator, and every language uses 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). - **Script support.** System-font fallback for CJK, Thai and Hebrew landed in #38 (closes #32;   #142 is still open pending confirmation). Native CJK fallback also probes Flatpak's read-only@@ -74,7 +79,7 @@ separators, number/date formats, machine translation with agent review, and scri | Hindi | hi | 0 (0%) | no | Devanagari: **no shaping** | no | none | 12–18 h | | Arabic | ar | 0 (0%) | no | **no shaping, no RTL UI**; Sheet Right-to-Left mirrors the grid (#146) | no | none | 25–35 h | | French | fr | 0 (0%) | no | Latin: yes | no | none | 6–10 h |-| Portuguese | pt-BR | 943 (~43%) | dialogs and tooltips (dialogs take effect with #669) | Latin: yes | no | menus, File view, dialogs | 2–4 h |+| Portuguese | pt-BR | 943 (~43%) | dialogs and tooltips (dialogs take effect with #669); help for the 35 localized functions | Latin: yes | no | menus, File view, dialogs, formula names | 2–4 h | | Indonesian | id | 0 (0%) | no | Latin: yes | no | none | 5–8 h | | Japanese | ja | 268 (~12%) | no | CJK system-font fallback; IME unverified; no furigana display, but phonetic runs (ふりがな) survive an XLSX open + save | no | menus only | 8–12 h | | Japanese | ja | 943 (~43%) | dialogs and messages | CJK system-font fallback; IME unverified; no furigana display | no | menus, File view, dialogs, messages | 3–5 h |@@ -110,6 +115,7 @@ language (none so far) and of localized function names.  | Date | Change | Summary | |---|---|---|+| 2026-10-27 | minor | Brazilian Portuguese formula entry (`FormulaLocale::PtBr`): 35 function names (`SOMA`, `MÉDIA`, `CONT.NÚM`, `SOMASE(S)`, `CONT.SE(S)`, `PROCV`, `PROCH`, `PROCX`, `ÍNDICE`, `CORRESP`, `ARRED`, `SE`, `SEERRO`, `E`, `OU`, `NÃO`, `VPL`, `PGTO`…) plus `VERDADEIRO`/`FALSO`, with `;` arguments, decimal commas and `\` array columns in the cell editor and formula bar; accented names match in any case. Portuguese help (`i18n/pt_functions.rs`) for those functions. Files, clipboard and APIs keep English | | 2026-10-23 | minor | Missing translations are reported, never a failure: `cargo xtask i18n-report` prints, per language, the strings other catalogs translate and it lacks (plus Spanish function help rows keyed by English the function catalog no longer has) and always passes; `cargo xtask ci` runs it first. Coverage and per-language parity tests and tests of one language's exact wording are gone; UI tests take the expected text from the catalog. Duplicate-key, placeholder, context-prefix and line-shape checks stay | | 2026-10-21 | minor | Romanian interface (`ro`, Română): 970 labels covering every key of the other catalogs (`crates/ui-egui/src/i18n/ro.rs`), `ro-*` system locales, the View-tab switcher and `app.language.set`; context keys show no prefix (File › Options test). English formulas and formats; AI-assisted draft pending native-speaker review | | 2026-10-20 | minor | Japanese: furigana (`rPh` phonetic runs and `<phoneticPr>` in `xl/sharedStrings.xml`) is no longer deleted by an open + save; it is kept on the workbook and written back (a `<phoneticPr>` without runs is kept too, while duplicate texts with different readings keep only the first, reported on open). Display of the readings is still missing (`crates/xlsx/src/tests/phonetic_tests.rs`) |