diff --git a/ROADMAP.md b/ROADMAP.md index ed706403c..43c76188d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-15 | Function help follows the interface language: argument hints, autocomplete, Insert Function (search and readable categories) and the Formulas-tab menus read per-language tables of descriptions and argument names; Spanish keeps its 10 functions in the new format | | 2026-10-14 | Protect Sheet on a protected sheet: Home › Format, its keytips (HOP, RPS) and the tab menu now read Unprotect Sheet… and unprotect (straight away without a password); a protected sheet that forbids selecting locked (or unlocked) cells is enforced for clicks, arrows and Go To, and the Protect Sheet dialog has the select options (#1346) | | 2026-10-12 | Rows auto-fit oversized fonts: opening a file and whole-row/column formatting grow non-custom rows to their tallest font (#833) | | 2026-10-12 | Number formats: parsing a format code is linear in its length (long runs of `[`, `,` or `/` no longer take quadratic time), and an unclosed `[` is a literal when splitting sections as well as when tokenizing (#810) | diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 00a488cf3..45854b6af 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1749,31 +1749,33 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: } } +/// Insert Function: searches names (as the formula language spells them) and descriptions, shows +/// each hit's category and description, and starts editing `=NAME(` (or appends to the formula). fn insert_function(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { + let language = app.ui.language; ui.horizontal(|ui| { - ui.label("Search:"); - let r = ui.add(egui::TextEdit::singleline(&mut d.search).desired_width(300.0).hint_text("e.g. lookup, average, date")); + ui.label(language.tr("Search:")); + let r = ui.add(egui::TextEdit::singleline(&mut d.search).desired_width(300.0).hint_text(language.tr("e.g. lookup, average, date"))); r.request_focus(); }); - let locale = app.ui.language.formula_locale(); + let locale = language.formula_locale(); let list = gridcraft_engine::cmd::formulas::function_list(); - let q = d.search.to_ascii_lowercase(); + // The names and descriptions searched are the ones shown, so in the interface language. + let q = d.search.to_lowercase(); let hits: Vec<&Json> = list .iter() .filter(|f| { q.is_empty() - || f["name"].as_str().is_some_and(|n| locale.function_name(n).to_ascii_lowercase().contains(&q)) - || f["description"].as_str().is_some_and(|n| n.to_ascii_lowercase().contains(&q)) + || f["name"].as_str().is_some_and(|n| locale.function_name(n).to_lowercase().contains(&q)) + || f["description"].as_str().is_some_and(|n| language.function_description(n).unwrap_or(n).to_lowercase().contains(&q)) }) .take(300) .collect(); egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| { for (i, f) in hits.iter().enumerate() { let name = locale.function_name(f["name"].as_str().unwrap_or("")); - let r = ui.add( - egui::Button::selectable(i == d.list_index, format!("{name:<18} {}", f["category"].as_str().unwrap_or(""))) - .min_size(vec2(480.0, 20.0)), - ); + let category = language.tr(function_category_label(f["category"].as_str().unwrap_or(""))); + let r = ui.add(egui::Button::selectable(i == d.list_index, format!("{name:<18} {category}")).min_size(vec2(480.0, 20.0))); if r.clicked() { d.list_index = i; } @@ -1784,9 +1786,10 @@ fn insert_function(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confir } }); if let Some(f) = hits.get(d.list_index) { + let name = f["name"].as_str().unwrap_or(""); ui.separator(); - ui.label(egui::RichText::new(crate::formula_locale::signature(f["name"].as_str().unwrap_or(""), locale).unwrap_or_default()).strong()); - ui.label(crate::formula_locale::description(f["name"].as_str().unwrap_or(""), locale).unwrap_or_default()); + ui.label(egui::RichText::new(crate::formula_locale::signature(name, locale, language).unwrap_or_default()).strong()); + ui.label(crate::formula_locale::description(name, locale, language).unwrap_or_default()); } let mut open = true; ok_cancel(ui, confirm, &mut open); @@ -1804,6 +1807,18 @@ fn insert_function(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confir } } +/// Excel's name for a function category, given the catalog's id (`MathTrig`). +fn function_category_label(id: &str) -> &str { + match id { + "DateTime" => "Date & Time", + "Lookup" => "Lookup & Reference", + "MathTrig" => "Math & Trig", + "AddIn" => "Add-in and Automation", + // Financial, Logical, Text, Statistical, Engineering, Database, Information, … + other => other, + } +} + fn find(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog) { ui.horizontal(|ui| { for tab in ["Find", "Replace"] { diff --git a/crates/ui-egui/src/formula_bar.rs b/crates/ui-egui/src/formula_bar.rs index 0b8fddbec..3382ddc1b 100644 --- a/crates/ui-egui/src/formula_bar.rs +++ b/crates/ui-egui/src/formula_bar.rs @@ -357,6 +357,7 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: // Autocomplete list and argument hint under the editor. if mine && ui.is_rect_visible(resp.rect) && (!ed.autocomplete.is_empty() || ed.current_function().is_some()) { let below = resp.rect.left_bottom() + vec2(0.0, 4.0); + let language = app.ui.language; // Areas have their own clip rect: keep cell helpers inside the worksheet. let bounds = if ed.from_formula_bar { ui.ctx().content_rect() } else { ui.clip_rect() }; egui::Area::new(id.with("ac")).fixed_pos(below).constrain_to(bounds).order(egui::Order::Tooltip).show(ui.ctx(), |ui| { @@ -372,11 +373,11 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: if r.clicked() { pick = Some(i); } - if sel && let Some(desc) = crate::formula_locale::description(n, ed.locale) { + if sel && let Some(desc) = crate::formula_locale::description(n, ed.locale, language) { r.on_hover_text(desc); } } - if let Some(desc) = ed.autocomplete.get(ed.ac_index).and_then(|n| crate::formula_locale::description(n, ed.locale)) { + if let Some(desc) = ed.autocomplete.get(ed.ac_index).and_then(|n| crate::formula_locale::description(n, ed.locale, language)) { ui.separator(); ui.add(egui::Label::new(egui::RichText::new(desc).small()).wrap()); } @@ -386,12 +387,12 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: ed.request_focus = true; } } else if let Some((name, arg)) = ed.current_function() - && let Some(sig) = crate::formula_locale::signature(&name, ed.locale) + && let Some(sig) = crate::formula_locale::signature(&name, ed.locale, language) { ui.horizontal_wrapped(|ui| { ui.spacing_mut().item_spacing.x = 0.0; let (head, rest) = sig.split_once('(').unwrap_or((&sig, "")); - ui.label(egui::RichText::new(format!("{}(", ed.locale.function_name(head))).font(theme::ui_font(12.0))); + ui.label(egui::RichText::new(format!("{head}(")).font(theme::ui_font(12.0))); let inner = rest.trim_end_matches(')'); let parts: Vec<&str> = inner.split(&format!("{} ", ed.locale.list_separator())).collect(); for (i, part) in parts.iter().enumerate() { @@ -406,7 +407,7 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: }); // The description too, so values an argument takes (DATEDIF's units, #363) show // while it's typed; wrapped to the hint's width. - if let Some(desc) = crate::formula_locale::description(&name, ed.locale) { + if let Some(desc) = crate::formula_locale::description(&name, ed.locale, language) { ui.set_max_width(ui.min_rect().width().max(220.0)); ui.separator(); ui.add(egui::Label::new(egui::RichText::new(desc).small()).wrap()); diff --git a/crates/ui-egui/src/formula_locale.rs b/crates/ui-egui/src/formula_locale.rs index 6e833b95d..c34e3cf86 100644 --- a/crates/ui-egui/src/formula_locale.rs +++ b/crates/ui-egui/src/formula_locale.rs @@ -1,4 +1,5 @@ //! UI formula syntax boundary. Engine commands, control/API callers and XLSX keep English. +use crate::i18n::Language; use gridcraft_engine::formula::{self, FormulaLocale, ParseError}; use gridcraft_engine::model::Workbook; @@ -86,39 +87,55 @@ fn hide_udf_prefix(text: &str, locale: FormulaLocale) -> String { out } -// Descriptions/signatures for the supported Spanish function table; untranslated functions -// retain their English help. Parameter punctuation follows the same locale as entry. -const SPANISH_HELP: &[(&str, &str, &str)] = &[ - ("SUM", "SUMA(número1; [número2]; ...)", "Suma los números."), - ("AVERAGE", "PROMEDIO(número1; [número2]; ...)", "Devuelve el promedio de los números."), - ("IF", "SI(prueba_lógica; valor_si_verdadero; [valor_si_falso])", "Devuelve un valor según una condición."), - ("COUNT", "CONTAR(valor1; [valor2]; ...)", "Cuenta las celdas que contienen números."), - ("SUMIF", "SUMAR.SI(rango; criterio; [rango_suma])", "Suma las celdas que cumplen un criterio."), - ("COUNTIF", "CONTAR.SI(rango; criterio)", "Cuenta las celdas que cumplen un criterio."), - ("VLOOKUP", "BUSCARV(valor_buscado; matriz; indicador_columnas; [ordenado])", "Busca un valor en la primera columna de una tabla."), - ("TODAY", "HOY()", "Devuelve la fecha actual."), - ("TRUE", "VERDADERO()", "Devuelve el valor lógico VERDADERO."), - ("FALSE", "FALSO()", "Devuelve el valor lógico FALSO."), -]; - -pub(crate) fn signature(name: &str, locale: FormulaLocale) -> Option { - let canonical = locale.canonical_name(name); - if locale == FormulaLocale::Es - && let Some((_, signature, _)) = SPANISH_HELP.iter().find(|(name, ..)| *name == canonical) - { - return Some((*signature).into()); - } - crate::formula_bar::function_signature(canonical).map(|s| s.replace(',', &locale.list_separator().to_string())) +/// A function's argument hint. The name and separators follow the formula syntax; the argument +/// names follow the interface language, all of them or none, so a hint never mixes two languages. +pub(crate) fn signature(name: &str, locale: FormulaLocale, language: Language) -> Option { + let english = crate::formula_bar::function_signature(locale.canonical_name(name))?; + let Some((head, args)) = split_signature(&english) else { return Some(english) }; + let separator = format!("{} ", locale.list_separator()); + let args = match args.iter().map(|arg| translate_argument(arg, language)).collect::>>() { + Some(translated) => translated.join(&separator), + None => args.join(&separator), + }; + Some(format!("{}({args})", locale.function_name(head))) } -pub(crate) fn description(name: &str, locale: FormulaLocale) -> Option { - let canonical = locale.canonical_name(name); - if locale == FormulaLocale::Es - && let Some((_, _, description)) = SPANISH_HELP.iter().find(|(name, ..)| *name == canonical) - { - return Some((*description).into()); +/// A function's description in the interface language (English while untranslated). +pub(crate) fn description(name: &str, locale: FormulaLocale, language: Language) -> Option { + let english = crate::formula_bar::function_description(locale.canonical_name(name))?; + Some(language.function_description(&english).map_or(english, str::to_string)) +} + +/// `SUM(number1, [number2], ...)` -> `SUM` and its arguments as written. +fn split_signature(signature: &str) -> Option<(&str, Vec<&str>)> { + let (head, rest) = signature.split_once('(')?; + let inner = rest.strip_suffix(')')?; + Some((head, if inner.is_empty() { Vec::new() } else { inner.split(", ").collect() })) +} + +/// The catalog names of a signature's arguments (`number` for `[number2]`), without `...`; the +/// function help tables are checked against them. +#[cfg(test)] +pub(crate) fn argument_names(signature: &str) -> Vec<&str> { + split_signature(signature).map(|(_, args)| args.into_iter().map(argument_name).filter(|name| *name != "...").collect()).unwrap_or_default() +} + +/// `[number2]` -> `number`. A bracket can open or close on its own (SWITCH's +/// `[default_or_value2, result2]`, LAMBDA's `[parameter1, ...]`). +fn argument_name(arg: &str) -> &str { + let arg = arg.strip_prefix('[').unwrap_or(arg); + let arg = arg.strip_suffix(']').unwrap_or(arg); + arg.trim_end_matches(|c: char| c.is_ascii_digit()) +} + +/// `[number2]` -> `[número2]`: brackets, index and `...` stay. +fn translate_argument(arg: &str, language: Language) -> Option { + let name = argument_name(arg); + if name == "..." { + return Some(arg.to_string()); } - crate::formula_bar::function_description(canonical) + // `arg` is `[`? + `name` + index + `]`?, so the first match is the name itself. + Some(arg.replacen(name, language.function_argument(name)?, 1)) } /// Avoid suggesting an alias that would call a workbook/table name instead of the function. @@ -138,3 +155,34 @@ pub(crate) fn display_template(text: &str, locale: FormulaLocale, wb: &Workbook, } display(text, locale, wb, sheet) } + +#[cfg(test)] +mod tests { + use super::*; + + /// The hint for `name`, empty when there is none. + fn sig(name: &str, locale: FormulaLocale, language: Language) -> String { + signature(name, locale, language).unwrap_or_default() + } + + /// Hints keep the formula syntax's name and separators and translate the argument names + /// only when the interface language has them all. + #[test] + fn function_help_follows_the_interface_language_and_the_formula_syntax() { + use FormulaLocale::{En, Es}; + // Spanish help over English formulas: the name and `,` stay as typed; brackets, indexes + // and `...` survive. + assert_eq!(sig("IF", En, Language::Es), "IF(prueba_lógica, [valor_si_verdadero], [valor_si_falso])"); + assert_eq!(sig("SUM", En, Language::Es), "SUM(número1, [número2], ...)"); + assert_eq!(description("IF", En, Language::Es).as_deref(), Some("Devuelve un valor según una condición.")); + // Spanish names and separators, from a localized name. + assert_eq!(sig("suma", Es, Language::Es), "SUMA(número1; [número2]; ...)"); + assert_eq!(description("SUMA", Es, Language::Es).as_deref(), Some("Suma los números.")); + // An argument list Spanish covers only in part stays English instead of mixing (the name + // is whatever the Spanish formula table calls SUMIFS). + let sumifs = sig("SUMIFS", Es, Language::Es); + assert!(sumifs.ends_with("(sum_range; criteria_range1; criteria1; ...)"), "{sumifs}"); + 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])"); + } +} diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 98919f8a4..fcc59bf3d 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -12,6 +12,7 @@ use serde::{Deserialize, Serialize}; mod cs; mod de; mod es; +mod es_functions; mod fr; mod ko; mod pt; @@ -132,6 +133,35 @@ impl Language { }; translated.unwrap_or_else(|| text.rsplit_once('|').map_or(text, |(_, label)| label)) } + + /// A worksheet function's description in this language, looked up by the English text of the + /// function catalog; `None` when untranslated, and the help shows the English. + pub fn function_description(self, english: &str) -> Option<&'static str> { + lookup(self.function_help()?.0, english) + } + + /// A function argument name in this language, given as the catalog spells it without + /// brackets or index (`number`, `logical_test`). + pub fn function_argument(self, english: &str) -> Option<&'static str> { + lookup(self.function_help()?.1, english) + } + + /// The function help tables (descriptions, argument names) of the languages that have them. + fn function_help(self) -> Option<(HelpTable, HelpTable)> { + match self { + Self::Es => Some((es_functions::DESCRIPTIONS, es_functions::ARGUMENTS)), + _ => None, + } + } +} + +/// English text and its translation, sorted by the English. +type HelpTable = &'static [(&'static str, &'static str)]; + +/// The translation of `english` in a help table (binary search, so the table must stay sorted). +fn lookup(table: HelpTable, english: &str) -> Option<&'static str> { + let i = table.binary_search_by(|(key, _)| (*key).cmp(english)).ok()?; + table.get(i).map(|(_, translated)| *translated) } /// Deserializes a saved language leniently: an unknown or malformed value (a language removed in a @@ -594,6 +624,27 @@ const JAPANESE: &[(&str, &str)] = &[ mod tests { use super::*; + /// Help tables are sorted, unique, and keyed by text the function catalog still has. + #[test] + fn function_help_is_sorted_and_keyed_by_current_catalog_text() { + // Lookups binary-search the tables. A key that no longer matches the catalog (an English + // description reworded, an argument renamed) would quietly show English again. + let catalog = gridcraft_engine::cmd::formulas::function_list(); + let descriptions: std::collections::HashSet<&str> = catalog.iter().filter_map(|f| f["description"].as_str()).collect(); + let arguments: std::collections::HashSet<&str> = + catalog.iter().filter_map(|f| f["signature"].as_str()).flat_map(crate::formula_locale::argument_names).collect(); + for language in Language::ALL { + let Some((description_table, argument_table)) = language.function_help() else { continue }; + for (table, catalog_text) in [(description_table, &descriptions), (argument_table, &arguments)] { + assert!(table.windows(2).all(|pair| pair[0].0 < pair[1].0), "{language:?} function help is not sorted and unique"); + for (english, translated) in table { + assert!(catalog_text.contains(english), "{language:?} translates {english:?}, which the function catalog doesn't use"); + assert!(!translated.is_empty(), "{language:?}: empty translation of {english:?}"); + } + } + } + } + #[test] fn translations_are_unique_and_preserve_unknown_text() { for (i, (en, ja)) in JAPANESE.iter().enumerate() { diff --git a/crates/ui-egui/src/i18n/es_functions.rs b/crates/ui-egui/src/i18n/es_functions.rs new file mode 100644 index 000000000..da607ed5b --- /dev/null +++ b/crates/ui-egui/src/i18n/es_functions.rs @@ -0,0 +1,35 @@ +//! Spanish help for the worksheet functions. It covers the 10 common functions Spanish had help +//! for (SUM, AVERAGE, IF…); the others keep their English help. + +/// Function descriptions. +pub(super) const DESCRIPTIONS: &[(&str, &str)] = &[ + ("Adds all the numbers in the arguments.", "Suma los números."), + ("Adds the cells that meet a condition.", "Suma las celdas que cumplen un criterio."), + ("Arithmetic mean of the numbers.", "Devuelve el promedio de los números."), + ("Counts the cells that meet a condition.", "Cuenta las celdas que cumplen un criterio."), + ("Counts the numbers.", "Cuenta las celdas que contienen números."), + ( + "Finds a value in the first column of a table and returns the value in the same row of another column.", + "Busca un valor en la primera columna de una tabla.", + ), + ("Returns one value when a condition is TRUE and another when it is FALSE.", "Devuelve un valor según una condición."), + ("Returns the current date.", "Devuelve la fecha actual."), + ("Returns the logical value FALSE.", "Devuelve el valor lógico FALSO."), + ("Returns the logical value TRUE.", "Devuelve el valor lógico VERDADERO."), +]; + +/// Argument names without brackets or index (`number`, not `[number2]`). +pub(super) const ARGUMENTS: &[(&str, &str)] = &[ + ("col_index_num", "indicador_columnas"), + ("criteria", "criterio"), + ("logical_test", "prueba_lógica"), + ("lookup_value", "valor_buscado"), + ("number", "número"), + ("range", "rango"), + ("range_lookup", "ordenado"), + ("sum_range", "rango_suma"), + ("table_array", "matriz"), + ("value", "valor"), + ("value_if_false", "valor_si_falso"), + ("value_if_true", "valor_si_verdadero"), +]; diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index fa683a1f8..3bdd644ad 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1751,6 +1751,8 @@ fn page_layout(app: &mut SheetApp, ui: &mut Ui) { } } +/// The Formulas tab: Insert Function, AutoSum, the function-category menus (names in the formula +/// language, descriptions in the interface language), defined names, auditing and calculation. fn formulas(app: &mut SheetApp, ui: &mut Ui) { let __k = big_button(ui, Icon::Function, "Insert\nFunction", "Insert Function (⇧F3)", false); control_keytip(app, ui, &__k, "dialog:insertFunction"); @@ -1784,7 +1786,7 @@ fn formulas(app: &mut SheetApp, ui: &mut Ui) { let locale = app.ui.language.formula_locale(); let name = app.session.active().map(|d| crate::formula_locale::completion_name(n, locale, &d.wb, d.wb.active_sheet)).unwrap_or(n); - let desc = crate::formula_locale::description(n, locale).unwrap_or_default(); + let desc = crate::formula_locale::description(n, locale, app.ui.language).unwrap_or_default(); if ui.button(name).on_hover_text(desc).clicked() { app.run_or_alert("formulas.insertFunction", json!({"name": n})); ui.close(); diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 64876633d..cafb6058c 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (defined-name length limit counts characters, so long Arabic names are accepted; Table names are limited to 255 characters; right-to-left sheets in `.xls` files open right-to-left; text boxes and shapes keep right-to-left paragraph direction through XLSX save; Save as Web Page and copied HTML keep right-to-left; French and Serbian `context|label` translations no longer show the context) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (function help follows the interface language; defined-name length limit counts characters, so long Arabic names are accepted; Table names are limited to 255 characters; right-to-left sheets in `.xls` files open right-to-left; text boxes and shapes keep right-to-left paragraph direction through XLSX save; Save as Web Page and copied HTML keep right-to-left; French and Serbian `context|label` translations no longer show the context) · **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, @@ -16,8 +16,14 @@ right-to-left sheets and full complex-script support. Part of each. The tables are used by the ribbon, sheet tabs and widgets (`ribbon.rs`, `tabs.rs`, `widgets.rs`), the Format Cells dialog, every dialog title and the shared OK/Cancel buttons; the other dialogs, menus, - context menus, task panes, status messages, command labels and function descriptions are still - English literals. Untranslated labels fall back to English. + context menus, task panes, status messages and command labels are still English literals. + Untranslated labels fall back to English. +- **Function help** (the argument hint, autocomplete, Insert Function and the Formulas-tab + menus) follows the interface language through per-language tables keyed by the catalog's + English text (`i18n/_functions.rs`): descriptions, and argument names from which the + hint is assembled with the formula syntax's function name and separator. Spanish translates + the 10 common functions it had help for (SUM, AVERAGE, IF…); other languages show the English + help. - **About 2,200 user-visible strings** in all (heuristic source scan: ~1,700 UI and command strings plus ~500 function descriptions), so each shipped language covers **~12%** (measured count / estimated total). @@ -89,6 +95,7 @@ language (none so far) and of localized function names. | 2026-10-19 | trivial | Text boxes and shapes keep right-to-left paragraph direction (`a:pPr rtl`) on XLSX save, so Excel-authored Arabic and Hebrew text boxes reopen right to left | | 2026-10-19 | minor | Save as Web Page and copied HTML mark right-to-left sheets `dir="rtl"`, let General-aligned Arabic and Hebrew text align by its own direction (`dir="auto"`), and keep numbers on right-to-left sheets left to right and on the left | | 2026-10-19 | minor | French and Serbian kept the context in three `context\|label` translations ("Options\|Général", "Opcije\|Opšte" for `Options\|General`; also `Options\|Save`, `Sheet Options\|View`). `tr` shows a found translation as it is, so File › Options headings and the Sheet Options "View" checkboxes showed the prefix. Fixed; a test checks that no catalog shows a `\|` | +| 2026-10-15 | minor | Function help in the interface language: argument hints, autocomplete, Insert Function (search, categories) and the Formulas-tab menus read per-language tables of descriptions and argument names keyed by the catalog's English text; Spanish keeps its 10 functions in the new format | | 2026-10-12 | minor | Format Cells looks up its tabs, labels, options and note through `Language::tr` (Fill tab keyed `Format Cells\|Fill`); dialog titles and OK/Cancel too. Labels already in a table (Number, Currency, Fill…) show translated; the rest fall back to English until the tables add them | | 2026-10-12 | minor | Brazilian Portuguese catalog (`i18n/pt.rs`) grows from 377 to 923 entries: every key the other catalogs translate, including the dialog strings #669 starts looking up (~42%) | | 2026-10-11 | minor | German ribbon catalog (`i18n/de.rs`, 266 labels, ~12%); `de` locale tags (de-DE, de-AT, de-CH…) resolve to German. PR #163 (localized German formulas/numbers) still open |