Merge pull request #7812 from dotCooCoo/fix/a-nested-message-value-that-starts-with-is-not

i18n: an effect refusal inside another message is translated with its reason
This commit is contained in:
BFlat
2026-11-15 20:46:00 +07:00
committed by GitHub
7 changed files with 81 additions and 6 deletions
+14 -4
View File
@@ -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::<u64>().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)
}
+22
View File
@@ -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> {
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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 ბეჭდვის პარამეტრები ობიექტი არ არის
Can't render this file because it is too large.
+38 -1
View File
@@ -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]
+4 -1
View File
@@ -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
+1
View File
@@ -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.