i18n: an effect refusal inside another message is translated with its reason

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.
This commit is contained in:
dotCooCoo
2026-11-15 00:38:32 -08:00
parent df984b2a02
commit c592890527
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
@@ -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> {
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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 ბეჭდვის პარამეტრები ობიექტი არ არის
Can't render this file because it is too large.
+38 -1
View File
@@ -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]
+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
@@ -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.