From c592890527ebd2b41924e462ae6c0d0546fc4db2 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 15 Nov 2026 00:38:32 -0800 Subject: [PATCH] i18n: an effect refusal inside another message is translated with its reason MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A message part that starts with `[` was translated only through an exact catalog row, so an effect refusal such as ``[0] `fx`: nothing selected`` inside `plug-in output: objects[0].effects: …` or an importer's `can't open …` did not use the refusal's template. Inside `plug-in output: …` its reason stayed in English in Catalan, Greek, Spanish, Finnish, Russian, Vietnamese, Georgian and Danish. - A part that starts with `[` now also matches a template that starts with `[` and an index of ASCII digits (``[{j}] `{_1}`: {why}``), and its values are translated in turn. A JSON array such as ``["] `x`: b"]`` fits the template's text but not its index, and stays as it is. A part that starts with `{` is still translated only through an exact row. - Georgian and Danish get the refusal's row, which they lacked. Docs: docs/development.md, docs/target-app-parity.md. --- crates/ui-egui/src/i18n.rs | 18 +++++++++++--- crates/ui-egui/src/i18n/catalog.rs | 22 +++++++++++++++++ crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/ka.tsv | 1 + crates/ui-egui/src/i18n/tests.rs | 39 +++++++++++++++++++++++++++++- docs/development.md | 5 +++- docs/target-app-parity.md | 1 + 7 files changed, 81 insertions(+), 6 deletions(-) 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 f2fab1fcfa..caebc75c14 100644 --- a/crates/ui-egui/src/i18n/catalog.rs +++ b/crates/ui-egui/src/i18n/catalog.rs @@ -257,6 +257,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 e13c1f9ae7..266c347a55 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -5390,6 +5390,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 a7b771b8b5..72d8b36f20 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -4611,6 +4611,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 44db5d3dfd..bc15919420 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -3877,7 +3877,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}")); @@ -3886,6 +3887,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 4b3c1ec869..94a325a969 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -979,6 +979,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The errors of the 3D effects (Extrude & Bevel, Revolve, Inflate, Rotate and their Classic forms) show in the status bar in the interface language: the art a 3D effect cannot build a solid from, invalid light, graphics and surface settings, and the notes for an object that draws no 3D faces or more wireframe edges than the limit. Each has a row in every catalog that translates all status and error messages, and the effect's name and the values in the message are translated or kept as written (`three_d_refusals_are_translated_in_every_complete_catalog`, `three_d_refusals_translate_with_their_values`). - 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`). - 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.