diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 377df81a72..8a59c7e567 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -682,14 +682,22 @@ const MESSAGE_DEPTH: usize = 5; /// Translate a status or error message for display (see the module docs). Unknown messages, and /// parts of them (file names, numbers, a reason with no entry, a JSON object or array such as /// `{"objects": [...]}`, a value the message quotes in `"…"`, `“…”` or backticks, such as a -/// symbol's name), stay as they are. A part that starts with `{` or `[` is translated only -/// when it is a whole message with an exact entry, such as `[x, y] (two numbers)`. +/// symbol's name), stay as they are. A part that starts with `{` is translated only when it is a +/// whole message with an exact entry. A part that starts with `[` is translated when it has an +/// exact entry, such as `[x, y] (two numbers)`, or when it fits a template that starts with `[` +/// and an index of digits, such as an effect's refusal ``[0] `id`: {why}``. pub fn message(lang: Lang, s: &str) -> String { message_at(lang, s, MESSAGE_DEPTH) } fn message_at(lang: Lang, s: &str, depth: usize) -> String { - let Some((forms, caps)) = lang.catalog().message_forms(s) else { return s.to_string() }; + fill_message(lang, s, lang.catalog().message_forms(s), depth) +} + +/// `s` shown through the catalog entry `found` matched for it, its placeholder values translated +/// in turn while `depth` lasts. +fn fill_message(lang: Lang, s: &str, found: Option<(&[String], catalog::Captures<'_, '_>)>, depth: usize) -> String { + let Some((forms, caps)) = found else { return s.to_string() }; let count = caps.iter().find(|(k, _)| *k == "n").and_then(|(_, v)| v.parse::().ok()); let form = count.map_or(forms.len().saturating_sub(1), |n| (lang.0.plural)(n).min(forms.len().saturating_sub(1))); let Some(template) = forms.get(form) else { return s.to_string() }; @@ -697,8 +705,10 @@ fn message_at(lang: Lang, s: &str, depth: usize) -> String { let nested = |v: &str| { if depth <= 1 || quoted(v) { v.to_string() - } else if v.starts_with(['{', '[']) { + } else if v.starts_with('{') { exact_translation(lang.catalog(), v).unwrap_or_else(|| v.to_string()) + } else if v.starts_with('[') { + fill_message(lang, v, lang.catalog().indexed_message_forms(v), depth - 1) } else { message_at(lang, v, depth - 1) } diff --git a/crates/ui-egui/src/i18n/catalog.rs b/crates/ui-egui/src/i18n/catalog.rs index 6e112eb4f5..cbb2c5b702 100644 --- a/crates/ui-egui/src/i18n/catalog.rs +++ b/crates/ui-egui/src/i18n/catalog.rs @@ -262,6 +262,28 @@ impl Catalog { self.templates.iter().find_map(|t| t.captures(s).map(|caps| (t.forms.as_slice(), caps))) } + /// [`Catalog::message_forms`] for a part of a message that starts with `[`, which may be a JSON + /// array: an exact `@msg` entry, else only a template that starts with a literal `[` and a + /// placeholder whose value is an index of ASCII digits, such as ``[{j}] `{_1}`: {why}``. An + /// array such as ``["] `x`: b"]`` fits that template's text, but not its index. + pub fn indexed_message_forms<'a>(&self, s: &'a str) -> Option<(&[String], Captures<'_, 'a>)> { + if let Some(tr) = self.messages.get(s) { + return Some((std::slice::from_ref(tr), Vec::new())); + } + if s.len() > MAX_MESSAGE_LEN { + return None; + } + self.templates.iter().find_map(|t| { + let [Piece::Text(open), Piece::Hole(index), ..] = t.pieces.as_slice() else { return None }; + if open != "[" { + return None; + } + let caps = t.captures(s)?; + let digits = caps.iter().any(|(k, v)| k == index && v.bytes().all(|b| b.is_ascii_digit())); + digits.then_some((t.forms.as_slice(), caps)) + }) + } + /// The translation of a status or error message that has an exact `@msg` entry, as written in /// the catalog (`{{` and `}}` for literal braces); templates are not tried. pub fn exact_message(&self, s: &str) -> Option<&str> { diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index da5a12db2d..0ae36e3699 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -5414,6 +5414,7 @@ imageTrace Outline Kontur @msg plug-in failed: {0} plug-in fejlede: {0} @msg plug-in manifest: {0} plug-in-manifest: {0} @msg plug-in output: {0} plug-in-output: {0} +@msg [{j}] `{_1}`: {why} [{j}] `{_1}`: {why} @msg plug-in parameters: {0} plug-in-parametre: {0} @msg plug-in trapped: {0} plug-in'et blev fanget: {0} @msg print settings are not an object printindstillingerne er ikke et objekt diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 07fdb5afe7..358960bd37 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6706,6 +6706,7 @@ font-technology Variable ცვლადი @msg plug-in failed: {0} პლაგინის შეცდომა: {0} @msg plug-in manifest: {0} პლაგინის მანიფესტი: {0} @msg plug-in output: {0} პლაგინის შედეგი: {0} +@msg [{j}] `{_1}`: {why} [{j}] `{_1}`: {why} @msg plug-in parameters: {0} პლაგინის პარამეტრები: {0} @msg plug-in trapped: {0} პლაგინი ავარიულად შეჩერდა: {0} @msg print settings are not an object ბეჭდვის პარამეტრები ობიექტი არ არის diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index d509f0cc69..b632e5cac3 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -4089,7 +4089,8 @@ fn bracketed_message_parts_translate_only_as_exact_messages() { let point = message(lang, "[x, y] (two numbers)"); assert_ne!(point, "[x, y] (two numbers)", "{code}"); assert!(point.contains("[x, y]"), "{code}: {point}"); - for got in ["[1]", "[1, 2, 3]", r#"{"objects": [1]}"#, r#"[{"a": 1}, "b: c", …"#] { + // ``["] `x`: b"]`` fits the text of the effect refusal ``[{j}] `{_1}`: {why}`` but not its index. + for got in ["[1]", "[1, 2, 3]", r#"{"objects": [1]}"#, r#"[{"a": 1}, "b: c", …"#, r#"["] `x`: b"]"#] { let shown = message(lang, &format!("`center` must be [x, y] (two numbers), not {got}")); assert!(shown.contains(&point) && shown.contains(got), "{code}: {shown}"); let shown = message(lang, &format!("`p` must be an object, not {got}")); @@ -4098,6 +4099,42 @@ fn bracketed_message_parts_translate_only_as_exact_messages() { } } +/// An effect refusal inside another message (`plug-in output: …`, and an importer's `can't open …`) +/// is translated through its template with its reason, keeping the index and the effect id. +#[test] +fn nested_effect_refusals_translate_with_their_reason() { + for code in COMPLETE_MESSAGES { + let lang = Lang::from_code(code).expect("registered"); + let refusal = "[0] `fx`: nothing selected"; + let output = format!("plug-in output: objects[0].effects: {refusal}"); + for shown in [message(lang, refusal), message(lang, &output), message(lang, &format!("can't open `a.svg`: {output}"))] { + assert!(!shown.contains("nothing selected") && shown.contains("[0] `fx`"), "{code}: {shown}"); + } + } + let de = de(); + let refusal = "[0] `fx`: `radius` must be a number"; + assert_eq!(message(de, refusal), "[0] `fx`: `radius` muss eine Zahl sein"); + let output = format!("plug-in output: objects[0].effects: {refusal}"); + assert_eq!(message(de, &output), "Plug-in-Ausgabe: objects[0].effects: [0] `fx`: `radius` muss eine Zahl sein"); + assert_eq!( + message(de, &format!("can't open `a.svg`: {output}")), + "`a.svg` kann nicht geöffnet werden: Plug-in-Ausgabe: objects[0].effects: [0] `fx`: `radius` muss eine Zahl sein" + ); + assert_eq!( + message(de, "plug-in output: objects[12].effects: [3] `fx`: no row for this reason"), + "Plug-in-Ausgabe: objects[12].effects: [3] `fx`: no row for this reason" + ); + let selected = "[1] `fx`: nothing selected"; + assert_eq!( + message(de, &format!("plug-in output: objects[0].effects: {selected}")), + "Plug-in-Ausgabe: objects[0].effects: [1] `fx`: nichts ausgewählt" + ); + // Past the depth limit the refusal stays as it is. + let deep = format!("plug-in output: plug-in output: plug-in output: plug-in output: objects[0].effects: {selected}"); + let shown = message(de, &deep); + assert!(shown.ends_with(selected), "{shown}"); +} + /// The warning for a PDF/X file that leaves out the document's bleed is translated in every catalog, /// alone and in the status bar's note. #[test] diff --git a/docs/development.md b/docs/development.md index 7c22d6ae98..100786bb83 100644 --- a/docs/development.md +++ b/docs/development.md @@ -294,7 +294,10 @@ other `sv-*` locale resolve to it), Georgian (`ka`, complete; `ka-GE` and `ka_GE control channel, MCP and tests read them) and are translated only where the status bar draws them (`i18n::msg`). `@msg` catalog rows hold a whole message or a template such as `Couldn't open {name}: {e}`; `{_1}`, `{_2}` … stand for the format string's `{}`, and the values in the - placeholders are translated in turn (the reason after `: {e}` is often a message too). A literal brace in a + placeholders are translated in turn (the reason after `: {e}` is often a message too). A value that starts with + `{` is translated only when it has an exact row, so a JSON object stays as it is; a value that starts with `[` is + also matched against a template that starts with `[` and an index of digits, such as an effect's refusal + ``[{j}] `{_1}`: {why}``, so its reason is translated while a JSON array stays as it is. A literal brace in a row is written `{{` or `}}`, in the English and in the translation: a message made with `format!` keeps the doubled braces of its format string, and a plain string literal has each of its braces doubled, so the row for `"expected {\"objects\": [...]}"` is `expected {{"objects": [...]}}`. Czech, German, Greek, Spanish, French, Georgian, Hindi, Hungarian, Italian, Japanese, Korean, Polish, Brazilian Portuguese, Russian, Simplified Chinese, Traditional Chinese, Swedish, Ukrainian, Vietnamese, Finnish, Turkish, Catalan, Dutch and Danish diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c040c003ab..4c8b2d43f8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -995,6 +995,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The errors of the parameter checks that many commands share show in the status bar in the interface language: a value that is not a number where a number goes (`object.move` with `"dx": "60 mm"`, and `object.rotate`, `object.setBounds` and the other commands that call `check_numbers`), a missing number or index, `ids` that are not a list of object ids, an object id that is not a non-negative integer or names no object, and a date that is not Unix seconds. Each has a row in every catalog that translates all status and error messages, and in Bulgarian, Danish, Indonesian and Dutch (`shared_param_check_errors_are_translated`). - File Info's (`file.info`) error for a value with control characters shows in the status bar in the interface language for every field: "description has control characters" for Description and Copyright Notice, which take line breaks and tabs, and "title has control characters or line breaks" for the other text fields and each keyword in a list. Each is its own message with a row in every catalog that translates all status and error messages, and the field's key stays as written (`file_info_control_character_refusals_translate_in_every_complete_catalog`, `control_characters_are_refused_with_the_field_rule`). - A parameter schema's errors show in the status bar with what the value must be in the interface language, for every type a schema has (a number with each kind of range and with a unit, an integer and its range, true or false, a string, a list of names, an object id, a list of object ids, a color, a point, an object and a list), in every catalog that translates all status and error messages; the path, the refused value, the bounds, the unit and the names stay as the English gives them. The error for a value that is not one of its names is also translated in Bulgarian, Bosnian, Danish, Indonesian and Dutch (`parameter_schema_expected_phrases_are_translated`, `bracketed_message_parts_translate_only_as_exact_messages`). + - An effect a plug-in's output or an importer plug-in's objects carry that VectorCraft refuses (``[0] `id`: reason``) shows in the status bar with its reason in the interface language inside `plug-in output: …` and `can't open …`, in every catalog that translates all status and error messages; the index and the effect id stay as they are, and a JSON array in a message stays as it is (`nested_effect_refusals_translate_with_their_reason`, `bracketed_message_parts_translate_only_as_exact_messages`). - The untranslated report lists the status and error messages of the effects, swatch libraries, raster sizes and encoders, 3D solids and path operations that a catalog lacks, the messages that a `.map_err` closure or a named error handler returns, and the phrases a parameter schema's error builds for what a value must be (`untranslated_report_checks_every_message_crate_and_schema_phrase`, `message_scan_reads_map_err_closures`, `message_scan_reads_named_map_err_handlers`). - Spanish messages (2026-11-13): the Spanish catalog (`crates/ui-egui/src/i18n/es.tsv`) has an `@msg` row for 114 of the 167 status and error messages that the untranslated report (`untranslated_report`) lists as missing in all 23 complete-message languages: the errors of the image encoders, color profiles, swatch exchange files and color books, 3D and Revolve, path operations, DXF import, the script engine and the parameter checks. Open #7211 and #7378 translate 51 of the others (the composite font and SVG filter errors), and 2 stay in English. The rows keep the placeholders, backticked names, accepted values and limits of their English (`spanish_translates_encoder_profile_swatch_revolve_and_parameter_errors`); native review remains; scores and estimates unchanged. - Export commands run through MCP, the control channel or the CLI refuse a key that no format takes, and warn about a key that only another format takes (`{"format": "pdf", "quality": 80}` writes the PDF with the warning "`quality` was not used: PDF files don't read it"; `an_option_only_another_format_reads_gets_a_note`, `control_export_notes_an_option_the_format_does_not_read`, `convert_warns_about_an_unused_option_only_when_one_was_given`), and a command that writes one format (`document.exportPdf`, `document.exportEps`, `document.exportDxf`) or takes one format's options (`document.pdfSettings`, `pdf.preset.save`) refuses a key that format doesn't take. An unknown key inside `marks`, `bleed`, `compression`, `output`, `advanced`, `security` or `flattener` is refused by its path.