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
@@ -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. ### Web@@ -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)?; } } }@@ -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),@@ -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;new file mode 100644@@ -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)");+}@@ -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");+ }+ }+ } }@@ -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, } }new file mode 100644@@ -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"),+];@@ -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); }@@ -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.@@ -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`) |