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.
 
 ### Web
diff --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.rs
new 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.rs
new 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`) |
