From cb06c7b34ec5aae536e4b940ea471a1352f34ca8 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:01:11 -0400 Subject: [PATCH] i18n: missing translations never fail; xtask ci prints one report (#3856) * i18n tests: report missing translations instead of failing The scans for untranslated menu strings, tl! literals, undo labels, menu labels and status messages print what a complete language lacks. A missing translation shows the English text until a translator adds the row, so it no longer fails the gate. * i18n: one untranslated report in xtask ci; drop one-language wording tests `i18n::tests::untranslated_report` lists, per language, the menu strings, tl! literals, undo step names and templates, menu labels, render-site labels, status and error messages, PostScript error meanings and default swatch names its catalog lacks; strings every language lacks are listed once. It never fails on a gap, only on a scan that finds too few strings. `cargo xtask ci` runs it in an `i18n-report` step, and `cargo xtask i18n-report` runs it alone. The five scan tests, the render-site and swatch coverage tests and untranslated_messages.txt fold into the report. Tests that pinned one language's wording are gone; where they also checked code (lookups, plural rules, locale tags, menu and History step names, engine parameter errors), that check stays and compares with the catalog lookup. --- apps/vectorcraft/src/mac_save_format.rs | 8 - crates/ui-egui/src/dialogs/missing_fonts.rs | 21 - crates/ui-egui/src/dialogs/tests_modal.rs | 19 +- crates/ui-egui/src/font_menu.rs | 2 +- crates/ui-egui/src/i18n/bg.tsv | 2 +- crates/ui-egui/src/i18n/cs.tsv | 4 +- crates/ui-egui/src/i18n/de.tsv | 6 +- crates/ui-egui/src/i18n/el.tsv | 2 +- crates/ui-egui/src/i18n/es.tsv | 6 +- crates/ui-egui/src/i18n/fi.tsv | 2 +- crates/ui-egui/src/i18n/fr.tsv | 6 +- crates/ui-egui/src/i18n/hu.tsv | 4 +- crates/ui-egui/src/i18n/it.tsv | 6 +- crates/ui-egui/src/i18n/ja.tsv | 8 +- crates/ui-egui/src/i18n/ko.tsv | 2 +- crates/ui-egui/src/i18n/mod.rs | 6 +- crates/ui-egui/src/i18n/pl.tsv | 4 +- crates/ui-egui/src/i18n/pt-br.tsv | 4 +- crates/ui-egui/src/i18n/ru.tsv | 6 +- crates/ui-egui/src/i18n/sv.tsv | 2 +- crates/ui-egui/src/i18n/tests.rs | 1206 +++------------ crates/ui-egui/src/i18n/uk.tsv | 4 +- .../src/i18n/untranslated_messages.txt | 1293 ----------------- crates/ui-egui/src/i18n/vi.tsv | 2 +- crates/ui-egui/src/i18n/zh-hans.tsv | 4 +- crates/ui-egui/src/i18n/zh-hant.tsv | 4 +- crates/ui-egui/src/menus.rs | 21 +- crates/ui-egui/src/panels/appearance.rs | 10 +- crates/ui-egui/src/panels/character.rs | 8 +- crates/ui-egui/src/panels/history.rs | 28 +- crates/ui-egui/src/panels/layers.rs | 1 - crates/ui-egui/src/tests_nativemenu.rs | 1 - docs/development.md | 30 +- docs/localization-parity.md | 56 +- docs/target-app-parity.md | 4 +- xtask/src/main.rs | 12 + 36 files changed, 389 insertions(+), 2415 deletions(-) delete mode 100644 crates/ui-egui/src/i18n/untranslated_messages.txt diff --git a/apps/vectorcraft/src/mac_save_format.rs b/apps/vectorcraft/src/mac_save_format.rs index 845d44d82..fe2097157 100644 --- a/apps/vectorcraft/src/mac_save_format.rs +++ b/apps/vectorcraft/src/mac_save_format.rs @@ -243,12 +243,4 @@ mod tests { assert_eq!(fileio::format_for_name(&name).map(|f| f.label), Some(label.as_str()), "{name}"); } } - - /// The menu's label is translated in every language with complete menus. - #[test] - fn the_label_is_translated() { - for lang in i18n::Lang::all().filter(|l| l.complete_menus()) { - assert!(i18n::has(lang, "Format:"), "{}", lang.code()); - } - } } diff --git a/crates/ui-egui/src/dialogs/missing_fonts.rs b/crates/ui-egui/src/dialogs/missing_fonts.rs index 9f7f2ad89..45130b92a 100644 --- a/crates/ui-egui/src/dialogs/missing_fonts.rs +++ b/crates/ui-egui/src/dialogs/missing_fonts.rs @@ -736,27 +736,6 @@ mod tests { assert_eq!((d.str("state"), &d.fields["chosen"]), ("done".to_string(), &json!([]))); } - /// The status lines are translated with the reasons in them, and so are the reasons a search - /// failed and a file wasn't copied, which the code holds as constants. - #[test] - fn the_status_lines_read_in_the_interface_language() { - let fr = crate::i18n::Lang::from_code("fr").unwrap(); - let shown = crate::i18n::message(fr, "2 font file(s) are now in /Fonts; 1 couldn't be added: a.otf: larger than 256 MB"); - assert!(shown.contains("se trouvent maintenant dans /Fonts") && shown.contains("a.otf") && shown.contains("plus de 256 Mo"), "{shown}"); - for code in ["de", "es", "fr", "hu", "it", "ka", "pl", "ru", "sv"] { - let lang = crate::i18n::Lang::from_code(code).unwrap(); - for s in [ - "every search thread stopped on an internal error (please report this bug)", - "not a file", - "not a font file", - "the files come to more than 1 GB: add fewer at a time", - "every numbered name for it is taken in the Fonts folder", - ] { - assert_ne!(crate::i18n::message(lang, s), s, "{code}"); - } - } - } - #[test] fn the_web_says_how_many_fonts_are_missing() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); diff --git a/crates/ui-egui/src/dialogs/tests_modal.rs b/crates/ui-egui/src/dialogs/tests_modal.rs index 6463b29a0..dd5d01b80 100644 --- a/crates/ui-egui/src/dialogs/tests_modal.rs +++ b/crates/ui-egui/src/dialogs/tests_modal.rs @@ -236,21 +236,6 @@ fn form_dialog_axes_are_dropdowns() { } } -/// The axis choices are translated in the "axis" context: in Japanese the plain Horizontal and -/// Vertical are the type orientations (horizontal and vertical writing). -#[test] -fn axis_choices_read_as_axes_in_every_complete_language() { - use crate::i18n::{LANGUAGES, Lang, tr, tr_ctx}; - let ja = Lang::from_code("ja").unwrap(); - assert_eq!(tr_ctx(ja, "axis", "Horizontal"), "水平方向"); - assert_ne!(tr_ctx(ja, "axis", "Horizontal"), tr(ja, "Horizontal")); - for l in LANGUAGES.iter().filter(|l| l.complete_menus) { - for s in ["Horizontal", "Vertical", "Both"] { - assert!(l.source.lines().any(|line| line.starts_with(&format!("axis\t{s}\t"))), "{}: no axis row for {s}", l.code); - } - } -} - /// Offset Path previews on the canvas; Cancel rolls the preview back. #[test] fn offset_path_previews() { @@ -713,7 +698,7 @@ fn simplify_dialog_show_original_path_outlines_the_paths_before_the_preview() { #[test] fn printer_mark_labels_are_distinct_from_language_names() { let cs = crate::i18n::Lang::from_code("cs").unwrap(); - assert_eq!(save_pdf::choice_label(cs, "marks.kind", "Japanese"), "Japonské"); - assert_eq!(save_pdf::choice_label(cs, "language", "Japanese"), "Japonština"); + assert_eq!(save_pdf::choice_label(cs, "marks.kind", "Japanese"), crate::i18n::tr_ctx(cs, "mark-style", "Japanese")); + assert_eq!(save_pdf::choice_label(cs, "language", "Japanese"), crate::i18n::tr(cs, "Japanese")); assert_eq!(save_pdf::choice_label(crate::i18n::Lang::EN, "marks.kind", "Japanese"), "Japanese"); } diff --git a/crates/ui-egui/src/font_menu.rs b/crates/ui-egui/src/font_menu.rs index b3682e448..9011b8fd6 100644 --- a/crates/ui-egui/src/font_menu.rs +++ b/crates/ui-egui/src/font_menu.rs @@ -865,7 +865,7 @@ mod tests { assert_eq!(super::script_label(3), "Thai"); let zh = crate::i18n::Lang::from_code("zh-hans").unwrap(); crate::i18n::set_current(zh); - assert_eq!(super::script_label(1), "汉字"); + assert_eq!(super::script_label(1), crate::i18n::tr_ctx(zh, "font-script", "Japanese")); crate::i18n::set_current(crate::i18n::Lang::EN); assert_eq!(super::script_label(1), "Japanese"); } diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 18bd32815..4dd899671 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1350,7 +1350,7 @@ font-technology Variable Променливи Clear History Изчистване на историята Set History Limit… Задаване на ограничение на историята… History States Състояния на историята -# History panel: the names of undo steps (`every_undo_label_is_translated`). +# History panel: the names of undo steps (`untranslated_report` lists any that are missing). Apply Data Set Прилагане на набор от данни Cancel Pattern Editing Отказ от редактирането на мостра Copy Appearance Копиране на външния вид diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 97bb00cb4..b2fe622cb 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -13,7 +13,7 @@ # režim míchání (blending mode), přednastavení (preset), rozložit (expand), uvolnit (release), # vektorizace obrazu (Image Trace). Counts without a plural row read "Label: {n}". # -# Coverage: every menu string, tl! literal, plural, status and error message (enforced in tests: +# Coverage: every menu string, tl! literal, plural, status and error message (`untranslated_report` lists gaps: # `complete_menus` and `COMPLETE_MESSAGES`). Left in English on purpose (`MENU_KEEP_AS_IS` in the # tests): the product name, language names, OpenType, the built-in workspace names and the # bracketed perspective grid preset names. Keep placeholders, ellipses and the leading/trailing @@ -1537,7 +1537,7 @@ lighting Light Světlo Clear History Vymazat historii Set History Limit… Nastavit limit historie… Open a document to see its history. Otevřete dokument, abyste viděli jeho historii. -# History panel: the names of undo steps (`every_undo_label_is_translated`). +# History panel: the names of undo steps (`untranslated_report` lists any that are missing). Apply Data Set Použít sadu dat Cancel Pattern Editing Zrušit úpravy vzorku Copy Appearance Kopírovat vzhled diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index efad1b1f1..58cc7a7e8 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -15,7 +15,7 @@ # error messages keep the case of their English first word, as they are often shown after "…: ". # # Coverage: the whole interface — every menu string and every `tl!` literal (`complete_menus`, -# tested) — and the status and error messages. Left in English on purpose (`MENU_KEEP_AS_IS` in the +# `untranslated_report` lists gaps) — and the status and error messages. Left in English on purpose (`MENU_KEEP_AS_IS` in the # tests): the product name, language names, OpenType, the built-in workspace names and the bracketed # perspective grid preset names; `de-DE`, `de-AT`, `de-CH` and every other `de-*` locale resolve here. # Photoshop-style raster effects (Effect menu, their dialogs). @@ -1371,7 +1371,7 @@ file-filter Templates Vorlagen Clear History Protokoll löschen Set History Limit… Protokollgrenze festlegen… History States Protokollschritte -# History panel: the names of undo steps (`every_undo_label_is_translated`). +# History panel: the names of undo steps (`untranslated_report` lists any that are missing). Apply Data Set Datensatz anwenden Area Type Flächentext Bloat Aufblasen @@ -3678,7 +3678,7 @@ theme Appearance Erscheinungsbild # Status and error messages (`@msg`), translated where the status bar shows them; the message itself # stays English for agents and tests. `{_1}`, `{_2}` … stand for the `{}` of the format string, in order -# (`complete_languages_translate_every_message` lists any that are missing). Command ids, parameter +# (`untranslated_report` lists any that are missing). Command ids, parameter # names and values in backticks or code (`colorModel rgb`, `document.export`) stay as they are. @msg no library `{id}` keine Bibliothek `{id}` @msg select some type first wählen Sie zuerst Text aus diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index dc90ab98a..720ec174a 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1313,7 +1313,7 @@ font-technology Variable Μεταβλητές Clear History Εκκαθάριση ιστορικού Set History Limit… Ορισμός ορίου ιστορικού… History States Καταστάσεις ιστορικού -# History panel: the names of undo steps (`every_undo_label_is_translated`). +# History panel: the names of undo steps (`untranslated_report` lists any that are missing). Apply Data Set Εφαρμογή συνόλου δεδομένων Cancel Pattern Editing Ακύρωση επεξεργασίας μοτίβου Copy Appearance Αντιγραφή εμφάνισης diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 26cf7caa8..17402bdbd 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -9,7 +9,7 @@ # sentences address the user as "tú". Shortcut keys keep their key-cap names (Shift, Alt, Ctrl, Esc). # # Coverage: the whole interface — every menu string and every `tl!` literal (`complete_menus`, -# tested). Left in English on purpose (`MENU_KEEP_AS_IS` in the tests): the product name, language +# `untranslated_report` lists gaps). Left in English on purpose (`MENU_KEEP_AS_IS` in the tests): the product name, language # names, OpenType, the built-in workspace names and the bracketed perspective grid preset names. # Photoshop-style raster effects (Effect menu, their dialogs). Glowing Edges… Bordes resplandecientes… @@ -1385,7 +1385,7 @@ font-technology Variable Variables Clear History Borrar historial Set History Limit… Definir límite de historial… History States Estados de historial -# History panel: the names of undo steps (`every_undo_label_is_translated`). +# History panel: the names of undo steps (`untranslated_report` lists any that are missing). Apply Data Set Aplicar conjunto de datos Cancel Pattern Editing Cancelar edición de motivo Copy Appearance Copiar apariencia @@ -3754,7 +3754,7 @@ axis Both Ambos # Status and error messages (`@msg`), translated where the status bar shows them; the message itself # stays English for agents and tests. `{_1}`, `{_2}` … stand for the `{}` of the format string, in order -# (`complete_languages_translate_every_message` lists any that are missing). Command ids, parameter +# (`untranslated_report` lists any that are missing). Command ids, parameter # names and values in backticks or code (`colorModel rgb`, `document.export`) stay as they are. @msg no library `{id}` no hay ninguna biblioteca `{id}` @msg select some type first selecciona primero algún texto diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index ba49690d0..52e89e144 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -1511,7 +1511,7 @@ lighting Light Valo Clear History Tyhjennä historia Set History Limit… Aseta historian raja… Open a document to see its history. Avaa asiakirja, niin näet sen historian. -# History panel: the names of undo steps (`every_undo_label_is_translated`). +# History panel: the names of undo steps (`untranslated_report` lists any that are missing). Apply Data Set Käytä tietojoukkoa Cancel Pattern Editing Peruuta kuvion muokkaus Copy Appearance Kopioi ulkoasu diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index b845bf226..84a3df76d 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -10,7 +10,7 @@ # « », the typographic apostrophe ’. Shortcut keys use their French key-cap names (Maj, Ctrl, Alt, Échap). # # Coverage: the whole interface — every menu string and every `tl!` literal (`complete_menus`, -# tested). Left in English on purpose (`MENU_KEEP_AS_IS` in the tests): the product name, language +# `untranslated_report` lists gaps). Left in English on purpose (`MENU_KEEP_AS_IS` in the tests): the product name, language # names, OpenType, the built-in workspace names and the bracketed perspective grid preset names. # Photoshop-style raster effects (Effect menu, their dialogs). Glowing Edges… Contour lumineux… @@ -1384,7 +1384,7 @@ font-technology Variable Variables Clear History Effacer l’historique Set History Limit… Définir la limite de l’historique… History States États de l’historique -# History panel: the names of undo steps (`every_undo_label_is_translated`). +# History panel: the names of undo steps (`untranslated_report` lists any that are missing). Apply Data Set Appliquer le jeu de données Cancel Pattern Editing Annuler la modification du motif Copy Appearance Copier l’aspect @@ -3751,7 +3751,7 @@ axis Both Les deux # Status and error messages (`@msg`), translated where the status bar shows them; the message itself # stays English for agents and tests. `{_1}`, `{_2}` … stand for the `{}` of the format string, in order -# (`complete_languages_translate_every_message` lists any that are missing). Command ids, parameter +# (`untranslated_report` lists any that are missing). Command ids, parameter # names and values in backticks or code (`colorModel rgb`, `document.export`) stay as they are. @msg no library `{id}` aucune bibliothèque `{id}` @msg select some type first sélectionnez d’abord du texte diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 760888747..39a5bf50f 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -18,7 +18,7 @@ # English first word, as they are often shown after "…: ". # # Coverage: the whole interface (every menu string and `tl!` literal, `complete_menus`) and the -# status and error messages (`COMPLETE_MESSAGES`), enforced by the tests. Left in English on +# status and error messages (`COMPLETE_MESSAGES`), gaps listed by `untranslated_report`. Left in English on # purpose (`MENU_KEEP_AS_IS`): the product name, language names, OpenType, the built-in workspace # names and the bracketed perspective grid preset names; `hu-HU` and every other `hu-*` locale # resolve here. @@ -1777,7 +1777,7 @@ file-filter Templates Sablonok Clear History Előzmények törlése Set History Limit… Előzménykorlát beállítása… History States Előzmények lépésszáma -# History panel: the names of undo steps (`every_undo_label_is_translated`). +# History panel: the names of undo steps (`untranslated_report` lists any that are missing). Apply Data Set Adatkészlet alkalmazása Cancel Pattern Editing Mintázatszerkesztés megszakítása Copy Appearance Megjelenés másolása diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 66aa996b9..d1041e47e 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -9,7 +9,7 @@ # user as "tu". Shortcut keys keep their key-cap names (Shift, Alt, Ctrl, Esc). # # Coverage: the whole interface — every menu string and every `tl!` literal (`complete_menus`, -# tested). Left in English on purpose (`MENU_KEEP_AS_IS` in the tests): the product name, language +# `untranslated_report` lists gaps). Left in English on purpose (`MENU_KEEP_AS_IS` in the tests): the product name, language # names, OpenType, the built-in workspace names and the bracketed perspective grid preset names. # Photoshop-style raster effects (Effect menu, their dialogs). Glowing Edges… Bordi luminosi… @@ -1395,7 +1395,7 @@ file-filter Templates Modelli Clear History Cancella cronologia Set History Limit… Imposta limite cronologia… History States Stati cronologia -# History panel: the names of undo steps (`every_undo_label_is_translated`). +# History panel: the names of undo steps (`untranslated_report` lists any that are missing). Apply Data Set Applica set di dati Area Type Testo in area Bloat Gonfia @@ -3794,7 +3794,7 @@ axis Both Entrambi # Status and error messages (`@msg`), translated where the status bar shows them; the message itself # stays English for agents and tests. `{_1}`, `{_2}` … stand for the `{}` of the format string, in order -# (`complete_languages_translate_every_message` lists any that are missing). Command ids, parameter +# (`untranslated_report` lists any that are missing). Command ids, parameter # names and values in backticks or code (`colorModel rgb`, `document.export`) stay as they are. @msg no library `{id}` nessuna libreria `{id}` @msg select some type first seleziona prima del testo diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index e254f244a..705d85b91 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -11,7 +11,7 @@ Smoothness 滑らかさ # # Coverage: the whole interface — every menu string and every `tl!` literal (`complete_menus`, -# tested) — and the status and error messages (`@msg` rows at the end, `COMPLETE_MESSAGES`, tested). +# `untranslated_report` lists gaps) — and the status and error messages (`@msg` rows at the end, `COMPLETE_MESSAGES`, `untranslated_report` lists gaps). # Left in English on purpose (`MENU_KEEP_AS_IS` in the tests): the product name, language # names, OpenType, the built-in workspace names and the bracketed perspective grid preset names. About VectorCraft VectorCraft について @@ -1639,7 +1639,7 @@ font-technology Variable バリアブル Clear History ヒストリーを消去 Set History Limit… ヒストリーの上限を設定… History States ヒストリー数 -# History panel: the names of undo steps (`every_undo_label_is_translated`). +# History panel: the names of undo steps (`untranslated_report` lists any that are missing). Apply Data Set データセットを適用 Area Type エリア内文字 Bloat 膨張 @@ -3807,7 +3807,7 @@ theme Appearance 外観 # Status and error messages (`@msg`), translated where the status bar shows them; the message itself # stays English for agents and tests. `{_1}`, `{_2}` … stand for the `{}` of the format string, in order -# (`complete_languages_translate_every_message` lists any that are missing). Command ids, parameter +# (`untranslated_report` lists any that are missing). Command ids, parameter # names and values in backticks or code (`colorModel rgb`, `document.export`) stay as they are. # Japanese: no final 。 (these are status lines, and reasons that follow a colon), unless the English ends # with a period; a space between Japanese text and an adjacent Latin word, number or {placeholder}. @@ -4654,7 +4654,7 @@ effect Vertical 垂直 Define a variable first 先に変数を定義してください Text テキスト Visibility 表示状態 -# tested). Left in English on purpose (`MENU_KEEP_AS_IS` in the tests): the product name, language +# `untranslated_report` lists gaps). Left in English on purpose (`MENU_KEEP_AS_IS` in the tests): the product name, language # Shared panel docking Activate Panel パネルをアクティブにする Change Panel Layout パネルの配置を変更 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index bb83ca1e5..f89c88519 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -1634,7 +1634,7 @@ font-technology Variable 가변 Clear History 히스토리 지우기 Set History Limit… 히스토리 한도 설정… History States 히스토리 상태 -# History panel: the names of undo steps (`every_undo_label_is_translated`). +# History panel: the names of undo steps (`untranslated_report` lists any that are missing). Apply Data Set 데이터 세트 적용 Area Type 영역 문자 Bloat 부풀리기 diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 78b8e2c98..690b716d5 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -9,8 +9,8 @@ //! English text, clean-room, see `zh-hant.tsv`). //! 2. Add one row to [`LANGUAGES`] (code, native name, catalog, plural rule). //! -//! That is all: the Preferences dropdown, the system-locale match and the catalog tests (parse, -//! placeholders, plural forms, menu coverage) pick it up from the registry. +//! That is all: the Preferences dropdown, the system-locale match, the catalog tests (parse, +//! placeholders, plural forms) and the untranslated report pick it up from the registry. //! //! # Looking strings up //! - [`t`] / the `tl!` macro: a plain string in the language the UI is drawn in. [`tr`] takes the @@ -41,7 +41,7 @@ pub struct LangInfo { pub source: &'static str, /// Plural form index for a count (English: 0 = one, 1 = other; Chinese: always 0). pub plural: fn(u64) -> usize, - /// Must the catalog cover every menu string? (checked by the tests) + /// Does the catalog aim to cover every menu string? The untranslated report lists what it lacks. pub complete_menus: bool, catalog: OnceLock, } diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index b9544c2dc..780029dfd 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -17,7 +17,7 @@ # # Plurals: one (1), few (2–4, 22–24, … but not 12–14), many (0, 5–21, 25–31, …). # -# Coverage: every menu string, tl! literal, plural, status and error message (enforced in tests: +# Coverage: every menu string, tl! literal, plural, status and error message (`untranslated_report` lists gaps: # `complete_menus` and `COMPLETE_MESSAGES`). Left in English on purpose (`MENU_KEEP_AS_IS` in the # tests): the product name, language names, OpenType, the built-in workspace names and the # bracketed perspective grid preset names. Keep placeholders, ellipses and the leading/trailing @@ -1451,7 +1451,7 @@ lighting Light Światło Clear History Wyczyść historię Set History Limit… Ustaw limit historii… Open a document to see its history. Otwórz dokument, aby zobaczyć jego historię. -# History panel: the names of undo steps (`every_undo_label_is_translated`). +# History panel: the names of undo steps (`untranslated_report` lists any that are missing). Apply Data Set Zastosuj zestaw danych Area Type Tekst akapitowy Cancel Pattern Editing Anuluj edycję wzorka diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 8e53d6734..02c175ede 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -19,7 +19,7 @@ # commands is kept. # # Coverage: the whole interface (every menu string and every `tl!` literal, `complete_menus`, -# tested) and the status and error messages (`@msg` rows, `COMPLETE_MESSAGES`, tested). Left in +# `untranslated_report` lists gaps) and the status and error messages (`@msg` rows, `COMPLETE_MESSAGES`, `untranslated_report` lists gaps). Left in # English on purpose (`MENU_KEEP_AS_IS` in the tests): the product name, language names, OpenType, # the built-in workspace names and the bracketed perspective grid preset names. # @@ -1191,7 +1191,7 @@ Clear History Limpar Histórico Set History Limit… Definir Limite do Histórico… History States Estados do Histórico -# History panel: the names of undo steps (`every_undo_label_is_translated`). +# History panel: the names of undo steps (`untranslated_report` lists any that are missing). Apply Data Set Aplicar Conjunto de Dados Cancel Pattern Editing Cancelar Edição de Padrão Copy Appearance Copiar Aparência diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index ddf58952a..ec56a4d4f 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -7,7 +7,7 @@ # Перо, Прямое выделение). Shortcut keys keep their key-cap names (Shift, Alt, Ctrl, Esc). # # Coverage: the whole interface — every menu string and every `tl!` literal (`complete_menus`, -# tested). Left in English on purpose (`MENU_KEEP_AS_IS` in the tests): the product name, language +# `untranslated_report` lists gaps). Left in English on purpose (`MENU_KEEP_AS_IS` in the tests): the product name, language # names, OpenType, the built-in workspace names and the bracketed perspective grid preset names. # Photoshop-style raster effects (Effect menu, their dialogs). Glowing Edges… Светящиеся края… @@ -1393,7 +1393,7 @@ file-filter Templates Шаблоны Clear History Очистить историю Set History Limit… Задать предел истории… History States Состояния истории -# History panel: the names of undo steps (`every_undo_label_is_translated`). +# History panel: the names of undo steps (`untranslated_report` lists any that are missing). Apply Data Set Применить набор данных Area Type Текст в области Bloat Раздувание @@ -3790,7 +3790,7 @@ axis Both Оба # Status and error messages (`@msg`), translated where the status bar shows them; the message itself # stays English for agents and tests. `{_1}`, `{_2}` … stand for the `{}` of the format string, in order -# (`complete_languages_translate_every_message` lists any that are missing). Command ids, parameter +# (`untranslated_report` lists any that are missing). Command ids, parameter # names and values in backticks or code (`colorModel rgb`, `document.export`) stay as they are. @msg no library `{id}` нет библиотеки `{id}` @msg select some type first сначала выделите текст diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 81b59697d..f3fe9bfed 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -1352,7 +1352,7 @@ font-technology Variable Variabla Clear History Rensa historik Set History Limit… Ange historikgräns… History States Historiktillstånd -# History panel: the names of undo steps (`every_undo_label_is_translated`). +# History panel: the names of undo steps (`untranslated_report` lists any that are missing). Apply Data Set Använd datauppsättning Cancel Pattern Editing Avbryt mönsterredigering Copy Appearance Kopiera utseende diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index e0ebdf7e3..7c670f122 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -59,6 +59,53 @@ fn tags_map_to_languages() { assert_eq!(lang_from_tag("_"), None); } +/// Italian, Brazilian Portuguese, Finnish, Turkish and Polish resolve from their locale tags and +/// preferences, and each language names itself in its own language. +#[test] +fn languages_resolve_from_their_tags_and_name_themselves() { + let lang = |code: &str| Lang::from_code(code).expect("registered"); + for (code, tags) in [ + ("it", &["it", "it_IT.UTF-8", "it-CH", "it_SM"][..]), + ("pt-br", &["pt-br", "pt_BR.UTF-8", "pt-BR", "pt_BR"]), + ("fi", &["fi", "fi-FI", "fi_FI.UTF-8", "FI-fi"]), + ("tr", &["tr", "tr-TR", "tr_TR.UTF-8", "TR-tr", "tr-CY"]), + ("pl", &["pl", "pl-PL", "pl_PL.UTF-8", "PL-pl"]), + ] { + for tag in tags { + assert_eq!(lang_from_tag(tag), Some(lang(code)), "{tag}"); + } + } + // Filipino (`fil`) and Swedish in Finland (`sv-FI`) don't pick Finnish. + assert_ne!(lang_from_tag("fil"), Some(lang("fi"))); + assert_ne!(lang_from_tag("sv-FI"), Some(lang("fi"))); + for (pref, code) in [("vi", "vi"), ("VI", "vi"), ("fi", "fi"), ("FI", "fi"), ("tr", "tr"), ("TR", "tr"), ("pt-br", "pt-br"), ("pt-BR", "pt-br")] { + assert_eq!(Lang::from_pref(pref), lang(code), "{pref}"); + } + for (code, name) in [("vi", "Tiếng Việt"), ("fi", "Suomi"), ("tr", "Türkçe"), ("pt-br", "Português (Brasil)")] { + assert_eq!(lang(code).name(), name); + } +} + +/// Each language counts with its own plural rule: the form a count picks (`one`, then `few`, then +/// `other`/`many`). +#[test] +fn each_language_counts_with_its_plural_rule() { + const COUNTS: [u64; 8] = [0, 1, 2, 3, 5, 11, 21, 22]; + for (codes, forms) in [ + (&["de", "es", "it", "pt-br", "fi"][..], [1, 0, 1, 1, 1, 1, 1, 1]), + (&["fr"], [0, 0, 1, 1, 1, 1, 1, 1]), + (&["ja", "ka", "hu", "ko", "vi", "tr"], [0; 8]), + (&["ru", "uk"], [2, 0, 1, 1, 2, 2, 0, 1]), + (&["cs"], [2, 0, 1, 1, 2, 2, 2, 2]), + (&["pl"], [2, 0, 1, 1, 2, 2, 2, 1]), + ] { + for code in codes { + let rule = Lang::from_code(code).expect("registered").0.plural; + assert_eq!(COUNTS.map(rule), forms, "{code}"); + } + } +} + #[test] fn candidates_walk_from_specific_to_general() { assert_eq!(candidates("pt_BR.UTF-8"), ["pt-br", "pt"]); @@ -115,11 +162,12 @@ fn a_language_set_by_another_test_leaves_this_one_in_english() { #[test] fn lookups_fall_back_to_english() { assert_eq!(tr(Lang::EN, "Layers"), "Layers"); - assert_eq!(tr(ZH(), "Layers"), "圖層"); + let layers = ZH().catalog().plain("Layers"); + assert_eq!(tr(ZH(), "Layers"), layers.unwrap_or("Layers")); assert_eq!(tr(ZH(), "no such label"), "no such label"); - assert_eq!(tr_id(ZH(), "no.such.id", "Layers"), "圖層"); - assert_eq!(tr_ctx(ZH(), "no such context", "Layers"), "圖層"); - assert!(has(ZH(), "Layers")); + assert_eq!(tr_id(ZH(), "no.such.id", "Layers"), tr(ZH(), "Layers")); + assert_eq!(tr_ctx(ZH(), "no such context", "Layers"), tr(ZH(), "Layers")); + assert_eq!(has(ZH(), "Layers"), layers.is_some()); assert!(!has(Lang::EN, "Layers")); } @@ -147,8 +195,9 @@ fn plurals_and_placeholders() { assert_eq!(trn(Lang::EN, 1, "{n} item", "{n} items"), "1 item"); assert_eq!(trn(Lang::EN, 0, "{n} item", "{n} items"), "0 items"); assert_eq!(trn(Lang::EN, 7, "{n} item", "{n} items"), "7 items"); - assert_eq!(trn(ZH(), 1, "{n} item", "{n} items"), "1 個項目"); - assert_eq!(trn(ZH(), 7, "{n} item", "{n} items"), "7 個項目"); + // A catalog row's form takes the count; without a row the English form does. + let zh_items = ZH().catalog().plural("{n} item", "{n} items", 0).unwrap_or("{n} items"); + assert_eq!(trn(ZH(), 7, "{n} item", "{n} items"), zh_items.replace("{n}", "7")); assert_eq!(fmt("{b} before {a}", &[("a", "x"), ("b", "y"), ("c", "z")]), "y before x"); assert_eq!(fmt("{missing}", &[]), "{missing}"); assert_eq!(placeholders("a {x} b {y} {"), ["x", "y"]); @@ -432,61 +481,6 @@ fn no_translated_word_mixes_latin_cyrillic_and_greek_letters() { assert!(bad.is_empty(), "words that mix scripts:\n{}", bad.join("\n")); } -/// Every menu string (top-level titles, submenu names, item labels, section headers, UI and -/// engine command labels and menu paths) has an entry in each language that claims complete menus. -#[test] -fn complete_languages_translate_every_menu_string() { - let strings = crate::menus::menu_strings(); - assert!(strings.len() > 500, "menu scan found only {} strings", strings.len()); - for l in LANGUAGES.iter().filter(|l| l.complete_menus) { - let cat = l.catalog(); - // Languages that keep the product, workspace and perspective preset names in English. - let kept = |s: &str| KEEPS_MENU_NAMES.contains(&l.code) && MENU_KEEP_AS_IS.contains(&s); - let missing: Vec<_> = strings.iter().filter(|s| !kept(s) && cat.plain(s).is_none()).collect(); - assert!(missing.is_empty(), "{}: {} untranslated menu strings: {missing:#?}", l.code, missing.len()); - } -} - -/// Every `tl!("literal")` in the shell has an entry in each language that claims complete menus -/// (so a new label can't ship untranslated by accident). Literals that are deliberately shown as -/// they are (names, units) are listed in `KEEP_AS_IS`. -#[test] -fn every_tl_literal_is_translated() { - const KEEP_AS_IS: &[&str] = &[]; - let literals = tl_literals(); - assert!(literals.len() > 300, "scan found only {} literals", literals.len()); - for l in LANGUAGES.iter().filter(|l| l.complete_menus) { - let cat = l.catalog(); - let missing: Vec<_> = literals.iter().filter(|s| !KEEP_AS_IS.contains(&s.as_str()) && cat.plain(s).is_none()).collect(); - assert!(missing.is_empty(), "{}: {} untranslated tl! strings: {missing:#?}", l.code, missing.len()); - } -} - -/// Every undo step name the engine, the tools and the shell record ([`undo_labels`]), every -/// effect's name without its ellipsis, and the Liquify tools' step names (`{} Tool`), has a row in -/// each language marked `complete_menus`: its plain row, or the row of the same name with an -/// ellipsis (`panels::history::step_name`). Every template a step name is made from with -/// `Session::edit_named` has a plain row in each, which `panels::history::entry_name` fills with the -/// name. No other step name is built with `format!`. -#[test] -fn every_undo_label_is_translated() { - let scan = undo_scan(); - let mut labels = scan.labels; - assert!(labels.len() > 300, "scan found only {} undo labels", labels.len()); - labels.extend(vectorcraft_effects::effect_catalog().iter().map(|e| e.label.trim_end_matches('…').to_string())); - let liquify = ["warp", "twirl", "pucker", "bloat", "scallop", "crystallize", "wrinkle"]; - labels.extend(liquify.iter().filter_map(|k| vectorcraft_tools::distort::liquify::LiquifyKind::parse(k)).map(|k| format!("{} Tool", k.label()))); - assert_eq!(scan.formatted.iter().collect::>(), ["{} Tool"], "step names built with format! (make them with Session::edit_named)"); - assert!(scan.templates.len() >= 3, "scan found only the templates {:?}", scan.templates); - for l in LANGUAGES.iter().filter(|l| l.complete_menus) { - let lang = Lang::from_code(l.code).expect("registered"); - let missing: Vec<_> = labels.iter().filter(|s| crate::panels::history::step_name(lang, s).is_none()).collect(); - assert!(missing.is_empty(), "{}: {} untranslated undo labels: {missing:#?}", l.code, missing.len()); - let missing: Vec<_> = scan.templates.iter().filter(|t| l.catalog().plain(t).is_none()).collect(); - assert!(missing.is_empty(), "{}: {} untranslated undo label templates: {missing:#?}", l.code, missing.len()); - } -} - /// Every `tl!("…")` literal in this crate's sources (test modules aside). pub fn tl_literals() -> std::collections::BTreeSet { let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); @@ -855,17 +849,22 @@ fn string_literals(e: &str, keep: impl Fn(&str, &str) -> bool) -> Vec { out } -/// `VECTORCRAFT_I18N_DUMP= cargo test -p vectorcraft-ui-egui dump_source_strings` writes every -/// English source string a complete catalog has to cover, one per line, for translators. +/// For translators, `cargo test -p vectorcraft-ui-egui dump_source_strings` writes every English +/// interface string to `VECTORCRAFT_I18N_DUMP=`, and every status and error message as a +/// catalog template to `VECTORCRAFT_I18N_DUMP_MESSAGES=`, one per line. #[test] fn dump_source_strings() { - let Ok(path) = std::env::var("VECTORCRAFT_I18N_DUMP") else { return }; - let mut all = crate::menus::menu_strings(); - all.extend(tl_literals()); - all.extend(undo_labels()); - all.extend(undo_scan().templates); - let text: String = all.iter().map(|s| format!("{}\n", s.replace('\\', "\\\\").replace('\n', "\\n").replace('\t', "\\t"))).collect(); - std::fs::write(path, text).expect("write dump"); + if let Ok(path) = std::env::var("VECTORCRAFT_I18N_DUMP") { + let mut all = crate::menus::menu_strings(); + all.extend(tl_literals()); + all.extend(undo_labels()); + all.extend(undo_scan().templates); + let text: String = all.iter().map(|s| format!("{}\n", s.replace('\\', "\\\\").replace('\n', "\\n").replace('\t', "\\t"))).collect(); + std::fs::write(path, text).expect("write dump"); + } + if let Ok(path) = std::env::var("VECTORCRAFT_I18N_DUMP_MESSAGES") { + std::fs::write(path, message_literals().iter().map(|m| format!("{m}\n")).collect::()).expect("write dump"); + } } fn cs() -> Lang { @@ -968,524 +967,188 @@ fn toggled_labels() -> Vec { labels } -/// Czech, German, Spanish, Italian, Japanese and Brazilian Portuguese cover every menu label, the Show/Hide pairs -/// and the canvas context menu included (panels and dialogs not yet). +/// Prints, per language, the strings its catalog has no row for: menu strings (titles, submenus, +/// items, headers, command labels and menu paths), `tl!` literals, undo step names and their +/// templates, the menu labels with the Show/Hide pairs and the context menu, render-site labels, +/// status and error messages, PostScript error meanings and default swatch names. The interface +/// shows a missing string in English, so a gap never fails this test; its assertions catch a scan +/// that stopped finding strings. `cargo xtask ci` and `cargo xtask i18n-report` run it with +/// `--nocapture`. #[test] -fn menu_catalogs_translate_every_menu_label() { +fn untranslated_report() { + use std::collections::{BTreeMap, BTreeSet}; + // For each kind of string, the languages checked and what each one lacks. + let mut gaps: Vec<(&str, BTreeMap<&str, BTreeSet>)> = Vec::new(); + let mut add = |code: &'static str, what: &'static str, missing: BTreeSet| { + let at = gaps.iter().position(|(w, _)| *w == what).unwrap_or_else(|| { + gaps.push((what, BTreeMap::new())); + gaps.len() - 1 + }); + gaps[at].1.insert(code, missing); + }; + + let strings = crate::menus::menu_strings(); + assert!(strings.len() > 500, "menu scan found only {} strings", strings.len()); + let literals = tl_literals(); + assert!(literals.len() > 300, "scan found only {} tl! literals", literals.len()); + // Undo step names: the recorded names, every effect's name without its ellipsis, and the + // Liquify tools' step names (`{} Tool`). A name's row is its plain row or the row of the same + // name with an ellipsis (`panels::history::step_name`); a template made with + // `Session::edit_named` needs a plain row, which `panels::history::entry_name` fills. + let scan = undo_scan(); + let mut undo = scan.labels; + assert!(undo.len() > 300, "scan found only {} undo labels", undo.len()); + undo.extend(vectorcraft_effects::effect_catalog().iter().map(|e| e.label.trim_end_matches('…').to_string())); + let liquify = ["warp", "twirl", "pucker", "bloat", "scallop", "crystallize", "wrinkle"]; + undo.extend(liquify.iter().filter_map(|k| vectorcraft_tools::distort::liquify::LiquifyKind::parse(k)).map(|k| format!("{} Tool", k.label()))); + assert_eq!(scan.formatted.iter().collect::>(), ["{} Tool"], "step names built with format! (make them with Session::edit_named)"); + assert!(scan.templates.len() >= 3, "scan found only the templates {:?}", scan.templates); + let messages = message_literals(); + assert!(messages.len() > 300, "scan found only {} messages", messages.len()); + let (swatches, groups) = vectorcraft_color::default_swatches(vectorcraft_color::cms::Model::Rgb); + // The greys' `K=` values read the same in any language. + let swatch_names: BTreeSet = swatches + .iter() + .map(|s| s.name.clone()) + .chain(groups.iter().flat_map(|g| std::iter::once(g.name.clone()).chain(g.swatches.iter().map(|s| s.name.clone())))) + .chain([vectorcraft_color::swatch::REGISTRATION.to_string()]) + .filter(|n| !n.starts_with("K=")) + .collect(); + + for l in LANGUAGES.iter().filter(|l| l.complete_menus) { + let cat = l.catalog(); + let lang = Lang::from_code(l.code).expect("registered"); + // Languages that keep the product, workspace and perspective preset names in English. + let kept = |s: &str| KEEPS_MENU_NAMES.contains(&l.code) && MENU_KEEP_AS_IS.contains(&s); + add(l.code, "menu strings", strings.iter().filter(|s| !kept(s) && cat.plain(s).is_none()).cloned().collect::>()); + add(l.code, "tl! strings", literals.iter().filter(|s| cat.plain(s).is_none()).cloned().collect::>()); + add(l.code, "undo labels", undo.iter().filter(|s| crate::panels::history::step_name(lang, s).is_none()).cloned().collect::>()); + add(l.code, "undo label templates", scan.templates.iter().filter(|t| cat.plain(t).is_none()).cloned().collect::>()); + let (entries, _) = parse_entries(l.source); + let rows: BTreeSet<(&str, &str)> = entries.iter().map(|(ctx, src, _)| (ctx.as_str(), src.as_str())).collect(); + let render_site = RENDER_SITE_ROWS.iter().filter(|row| !rows.contains(row)).map(|(ctx, src)| format!("{ctx}: {src}")); + add(l.code, "render-site labels", render_site.collect()); + add(l.code, "swatch names", swatch_names.iter().filter(|n| cat.contextual("swatch", n).is_none()).cloned().collect::>()); + } + + // The menu labels as the menus show them, with what menu strings don't hold: the labels + // toggling items switch to, the canvas context menu, the macOS menu bar's own labels and its + // Settings submenu's Preferences pages. let labels = menu_labels(); - // What the menus show (recent files and fonts, user presets and libraries show their names). - let shown = crate::menus::menu_strings(); let language_name = |l: &str| Lang::all().any(|lang| lang.name() == l); // Font names and sizes, and installed plug-ins' own names, are not interface text. let interface = |(label, id, p): &(&str, &str, serde_json::Value)| { *id != "text.setStyle" && !crate::menus::lists_plugin(id, p) - && shown.contains(*label) + && strings.contains(*label) && !MENU_KEEP_AS_IS.contains(label) && !language_name(label) }; - let mut all: Vec = labels.iter().filter(|l| interface(l)).map(|(l, ..)| l.to_string()).collect(); - all.extend(toggled_labels()); - all.extend(crate::menus::CONTEXT_LABELS.iter().map(|l| l.to_string())); - // The macOS menu bar's own labels, and its Settings submenu's Preferences pages. - all.extend(crate::native_menu::MAC_LABELS.iter().map(|l| l.to_string())); - all.extend(vectorcraft_engine::cmd::prefscmds::PREF_CATEGORIES.iter().map(|l| l.to_string())); - all.sort(); - all.dedup(); + let mut shown: BTreeSet = labels.iter().filter(|l| interface(l)).map(|(l, ..)| l.to_string()).collect(); + shown.extend(toggled_labels()); + shown.extend(crate::menus::CONTEXT_LABELS.iter().map(|l| l.to_string())); + shown.extend(crate::native_menu::MAC_LABELS.iter().map(|l| l.to_string())); + shown.extend(vectorcraft_engine::cmd::prefscmds::PREF_CATEGORIES.iter().map(|l| l.to_string())); for code in KEEPS_MENU_NAMES { let lang = Lang::from_code(code).expect("registered"); - let missing: Vec<&String> = all.iter().filter(|l| !has(lang, l)).collect(); - assert!(missing.is_empty(), "{code}: untranslated menu labels: {missing:?}"); - for keep in MENU_KEEP_AS_IS { - assert!(labels.iter().any(|(l, ..)| l == keep), "`{keep}` isn't a menu label"); - assert!(!has(lang, keep), "{code}: `{keep}` is both kept and translated"); + // Labels the menu strings above already listed aren't listed twice. + let missing = shown.iter().filter(|l| !has(lang, l) && (!lang.complete_menus() || !strings.contains(*l))); + add(code, "menu labels", missing.cloned().collect::>()); + } + + for code in COMPLETE_MESSAGES { + let lang = Lang::from_code(code).expect("registered"); + let (entries, _) = parse_entries(lang.0.source); + let rows: BTreeSet<&str> = entries.iter().filter(|(ctx, _, _)| ctx == "@msg").map(|(_, src, _)| src.as_str()).collect(); + add(code, "messages", messages.iter().filter(|m| !rows.contains(m.as_str())).cloned().collect::>()); + let meanings = vectorcraft_eps::POSTSCRIPT_ERRORS.iter().filter(|(_, m)| message(lang, m) == *m).map(|(_, m)| m.to_string()); + add(code, "PostScript error meanings", meanings.collect()); + } + + // A string that every language checked lacks is listed once, under "every language". + let mut by_language: BTreeMap<&str, Vec<(&str, Vec<&String>)>> = BTreeMap::new(); + let mut everywhere = vec![]; + for (what, langs) in &gaps { + let first = langs.values().next(); + let common: BTreeSet<&String> = first.into_iter().flatten().filter(|m| langs.values().all(|set| set.contains(*m))).collect(); + if !common.is_empty() { + everywhere.push((*what, langs.len(), common.iter().copied().collect::>())); + } + for (code, missing) in langs { + let rest: Vec<&String> = missing.iter().filter(|m| !common.contains(m)).collect(); + if !rest.is_empty() { + by_language.entry(code).or_default().push((*what, rest)); + } } } - assert_eq!(tr(cs(), "File"), "Soubor"); - assert_eq!(tr(Lang::from_code("ja").expect("ja"), "File"), "ファイル"); - // Italian: Italy, Switzerland and San Marino share the one catalog. - for tag in ["it", "it_IT.UTF-8", "it-CH", "it_SM"] { - assert_eq!(lang_from_tag(tag), Lang::from_code("it"), "{tag}"); + let total: usize = gaps.iter().flat_map(|(_, langs)| langs.values()).map(BTreeSet::len).sum(); + let mut out = format!("Untranslated strings, shown in English until a catalog row is added: {total} missing rows\n"); + let list = |out: &mut String, what: &str, missing: &[&String]| { + *out += &format!(" {what} ({}):\n", missing.len()); + for m in missing { + *out += &format!(" {}\n", m.replace('\n', "\\n")); + } + }; + if !everywhere.is_empty() { + out += "\nevery language:\n"; + for (what, langs, missing) in &everywhere { + list(&mut out, &format!("{what}, all {langs} languages checked"), missing); + } } - assert_eq!(tr(Lang::from_code("pt-br").expect("pt-br"), "File"), "Arquivo"); - assert_eq!(tr(es(), "File"), "Archivo"); - assert_eq!(tr(de(), "File"), "Datei"); - assert_eq!(tr(it(), "Edit"), "Modifica"); + for (code, kinds) in &by_language { + out += &format!("\n{code}:\n"); + for (what, missing) in kinds { + list(&mut out, what, missing); + } + } + println!("{out}"); +} + +/// The names [`MENU_KEEP_AS_IS`] lists are menu labels, and no catalog that keeps them in English +/// translates them. +#[test] +fn kept_menu_names_are_menu_labels_left_in_english() { + let labels = menu_labels(); + for keep in MENU_KEEP_AS_IS { + assert!(labels.iter().any(|(l, ..)| l == keep), "`{keep}` isn't a menu label"); + for code in KEEPS_MENU_NAMES { + assert!(!has(Lang::from_code(code).expect("registered"), keep), "{code}: `{keep}` is both kept and translated"); + } + } +} + +/// Georgian interface text has no capitals: no row uses Mtavruli. +#[test] +fn georgian_rows_have_no_mtavruli() { + let (entries, _) = parse_entries(ka().0.source); + let caps: Vec<_> = entries.iter().filter(|(_, _, tr)| tr.chars().any(|c| matches!(c, '\u{1C90}'..='\u{1CBF}'))).collect(); + assert!(caps.is_empty(), "Mtavruli in ka.tsv: {caps:?}"); } fn de() -> Lang { Lang::from_code("de").expect("de registered") } -fn es() -> Lang { - Lang::from_code("es").expect("es registered") -} - -fn fr() -> Lang { - Lang::from_code("fr").expect("fr registered") -} - -fn it() -> Lang { - Lang::from_code("it").expect("it registered") -} - -fn ru() -> Lang { - Lang::from_code("ru").expect("ru registered") -} - fn ka() -> Lang { Lang::from_code("ka").expect("ka registered") } -/// Georgian uses verbal nouns for commands, genitive + "ხელსაწყო" for tools, one count-neutral -/// plural form (nouns stay singular after a number), and translates the reason inside a message. -#[test] -fn georgian_reads_as_georgian() { - for (en, want) in [ - ("Artboard Tool", "სამუშაო დაფის ხელსაწყო"), - ("Swatches", "ფერის ნიმუშები"), - ("Pathfinder", "ტრაექტორიების ოპერაციები"), - ("Stroke", "კონტური"), - ("Fill", "შევსება"), - ("Direct Selection Tool", "პირდაპირი მონიშვნის ხელსაწყო"), - ("Save As…", "შენახვა როგორც…"), - ("Undo", "დაბრუნება"), - ("File", "ფაილი"), - ] { - assert_eq!(tr(ka(), en), want); - } - for n in [0, 1, 2, 5, 21] { - assert_eq!(trn(ka(), n, "{n} Layer", "{n} Layers"), format!("შრე: {n}")); - } - assert_eq!(tr_ctx(ka(), "axis", "Both"), "ორივე"); - assert_eq!(tr_id(ka(), "pathfinder.add", "Add"), "გაერთიანება"); - let nested = "command `object.group` is not available right now: nothing selected"; - assert_eq!(message(ka(), nested), "ბრძანება `object.group` ახლა ხელმისაწვდომი არ არის: არაფერია მონიშნული"); - // No Mtavruli: Georgian UI text has no capitals. - let (entries, _) = parse_entries(ka().0.source); - let caps: Vec<_> = entries.iter().filter(|(_, _, tr)| tr.chars().any(|c| matches!(c, '\u{1C90}'..='\u{1CBF}'))).collect(); - assert!(caps.is_empty(), "Mtavruli in ka.tsv: {caps:?}"); -} - fn hu() -> Lang { Lang::from_code("hu").expect("hu registered") } -/// Hungarian uses its vector-illustration vocabulary, drops the word "tool" from tool names, keeps -/// a count in the singular (one plural form), gives dialog buttons a verb while the File menu's -/// Close keeps its noun through an `@id` row, and translates the reason inside a message too. -#[test] -fn hungarian_reads_as_hungarian() { - for (en, want) in [ - ("Artboard Tool", "Rajztábla"), - ("Swatches", "Színminták"), - ("Pathfinder", "Útvonalkezelő"), - ("Stroke", "Körvonal"), - ("Fill", "Kitöltés"), - ("Direct Selection Tool", "Közvetlen kijelölés"), - ("Save As…", "Mentés másként…"), - ("Undo", "Visszavonás"), - ("Close", "Bezár"), - ] { - assert_eq!(tr(hu(), en), want); - } - assert_eq!(tr_id(hu(), "file.close", "Close"), "Bezárás"); - assert_eq!(tr_id(hu(), "pathfinder.add", "Add"), "Egyesítés"); - for n in [0, 1, 3, 21] { - assert_eq!(trn(hu(), n, "{n} Layer", "{n} Layers"), format!("{n} réteg")); - } - let nested = "command `object.group` is not available right now: nothing selected"; - assert_eq!(message(hu(), nested), "a parancs jelenleg nem érhető el: `object.group`: nincs kijelölés"); -} - fn ko() -> Lang { Lang::from_code("ko").expect("ko registered") } -/// Korean uses the vector-illustration vocabulary its users know, has no plural forms, and -/// translates the reason inside a message too. -#[test] -fn korean_reads_as_korean() { - for (en, want) in [ - ("Artboard Tool", "아트보드 도구"), - ("Swatches", "견본"), - ("Pathfinder", "패스파인더"), - ("Stroke", "선"), - ("Fill", "칠"), - ("Direct Selection Tool", "직접 선택 도구"), - ("Save As…", "다른 이름으로 저장…"), - ("Undo", "실행 취소"), - ("Close", "닫기"), - ] { - assert_eq!(tr(ko(), en), want); - } - for n in [0, 1, 3, 21] { - assert_eq!(trn(ko(), n, "{n} Layer", "{n} Layers"), format!("레이어 {n}개")); - } - let nested = "command `object.group` is not available right now: nothing selected"; - assert_eq!(message(ko(), nested), "명령 `object.group`은(는) 지금 사용할 수 없습니다: 선택된 항목이 없습니다"); -} - fn vi() -> Lang { Lang::from_code("vi").expect("vi registered") } -/// Vietnamese has one plural form and covers the complete interface and messages. -#[test] -fn vietnamese_reads_as_vietnamese() { - assert_eq!(vi().name(), "Tiếng Việt"); - assert!(vi().complete_menus()); - assert_eq!(Lang::from_pref("vi"), vi()); - assert_eq!(Lang::from_pref("VI"), vi()); - for (en, want) in [ - ("Artboard Tool", "Công cụ Bảng vẽ"), - ("Swatches", "Mẫu màu"), - ("Stroke", "Nét"), - ("Fill", "Tô"), - ("Direct Selection Tool", "Công cụ Chọn trực tiếp"), - ("Save As…", "Lưu thành…"), - ("Undo", "Hoàn tác"), - ("Close", "Đóng"), - ] { - assert_eq!(tr(vi(), en), want); - } - for n in [0, 1, 3, 21] { - assert_eq!(trn(vi(), n, "{n} Layer", "{n} Layers"), format!("{n} lớp")); - } - let nested = "command `object.group` is not available right now: nothing selected"; - assert_eq!(message(vi(), nested), "lệnh `object.group` hiện không khả dụng: chưa chọn gì"); -} - -fn fi() -> Lang { - Lang::from_code("fi").expect("fi registered") -} - -/// Finnish has one/other plurals (partitive after numbers other than 1) and covers the complete -/// interface and messages. -#[test] -fn finnish_reads_as_finnish() { - assert_eq!(fi().name(), "Suomi"); - assert!(fi().complete_menus()); - assert_eq!(Lang::from_pref("fi"), fi()); - assert_eq!(Lang::from_pref("FI"), fi()); - for tag in ["fi", "fi-FI", "fi_FI.UTF-8", "FI-fi"] { - assert_eq!(lang_from_tag(tag), Some(fi()), "{tag}"); - } - // Filipino (`fil`) and Swedish in Finland (`sv-FI`) don't pick Finnish. - assert_ne!(lang_from_tag("fil"), Some(fi())); - assert_ne!(lang_from_tag("sv-FI"), Some(fi())); - for (en, want) in [ - ("Artboard Tool", "Piirtoalustatyökalu"), - ("Swatches", "Värimallit"), - ("Stroke", "Viiva"), - ("Fill", "Täyttö"), - ("Direct Selection Tool", "Suoravalintatyökalu"), - ("Save As…", "Tallenna nimellä…"), - ("Undo", "Kumoa"), - ("Close", "Sulje"), - ] { - assert_eq!(tr(fi(), en), want); - } - for (n, layer) in [(0, "tasoa"), (1, "taso"), (3, "tasoa"), (21, "tasoa")] { - assert_eq!(trn(fi(), n, "{n} Layer", "{n} Layers"), format!("{n} {layer}")); - } - let nested = "command `object.group` is not available right now: nothing selected"; - assert_eq!(message(fi(), nested), "komento `object.group` ei ole nyt käytettävissä: mitään ei ole valittu"); -} - -/// The Turkish catalog registers under `tr`, claims complete menus and messages, and passes -/// locale resolution, vocabulary and count formatting checks. -#[test] -fn turkish_reads_as_turkish() { - assert_eq!(tr_lang().name(), "Türkçe"); - assert!(tr_lang().complete_menus()); - assert_eq!(Lang::from_pref("tr"), tr_lang()); - assert_eq!(Lang::from_pref("TR"), tr_lang()); - for tag in ["tr", "tr-TR", "tr_TR.UTF-8", "TR-tr", "tr-CY"] { - assert_eq!(lang_from_tag(tag), Some(tr_lang()), "{tag}"); - } - for (en, want) in [ - ("Artboard Tool", "Çalışma Yüzeyi Aracı"), - ("Swatches", "Renk Örnekleri"), - ("Stroke", "Kontur"), - ("Fill", "Dolgu"), - ("Direct Selection Tool", "Doğrudan Seçim Aracı"), - ("Save As…", "Farklı Kaydet…"), - ("Undo", "Geri Al"), - ("Close", "Kapat"), - ] { - assert_eq!(tr(tr_lang(), en), want); - } - for n in [0, 1, 3, 21] { - assert_eq!(trn(tr_lang(), n, "{n} Layer", "{n} Layers"), format!("{n} Katman")); - } - let nested = "command `object.group` is not available right now: nothing selected"; - assert_eq!(message(tr_lang(), nested), "`object.group` komutu şu anda kullanılamıyor: hiçbir şey seçilmedi"); -} - -/// The [`Lang`] handle for the Turkish catalog. -fn tr_lang() -> Lang { - Lang::from_code("tr").expect("tr registered") -} - -/// Ambiguous English terms keep their intended Vietnamese meaning across UI contexts. -#[test] -fn vietnamese_domain_terms_are_consistent() { - for (en, want) in [ - ("All Scripts", "Tất cả hệ chữ"), - ("Pointillize", "Hiệu ứng chấm"), - ("Object Highlighting", "Đánh dấu đối tượng"), - ("Color Theme", "Bộ màu"), - ("Color Themes", "Bộ màu"), - ("Asset Export", "Xuất asset"), - ("Envelope Distort", "Biến dạng Envelope"), - ("Move Spine Point", "Di chuyển điểm trục"), - ("Dashed Line", "Đường nét đứt"), - ("Eyedropper Tool", "Công cụ Ống hút màu"), - ("Character Alignment", "Căn chỉnh ký tự"), - ] { - assert_eq!(tr(vi(), en), want, "{en}"); - } - assert_eq!(tr(vi(), "Appearance"), "Diện mạo"); - assert_eq!(tr_ctx(vi(), "theme", "Appearance"), "Giao diện"); - assert_eq!(message(vi(), "no saved themes"), "chưa có bộ màu nào được lưu"); - assert_eq!(message(vi(), r#"choice must be "merge" or "add", not `foo`"#), r#"lựa chọn phải là "merge" hoặc "add", không phải `foo`"#); - assert_eq!(message(vi(), "select a blend"), "hãy chọn một đối tượng hòa trộn"); - assert_eq!(message(vi(), "select a repeat"), "hãy chọn một đối tượng lặp"); - assert_eq!(message(vi(), "Replace Spine: select a blend and a path"), "Thay thế trục: chọn một đối tượng hòa trộn và một đường dẫn"); -} - -/// Each catalog listed here names a blend's spine with one term, in one of the forms listed for -/// it, in every row whose English text names the spine: the spine commands' labels and the Replace -/// Spine message. A catalog missing from the list is not checked. -#[test] -fn each_catalog_names_the_blend_spine_with_one_term() { - const SPINE_TERMS: &[(&str, &[&str])] = &[ - ("bg", &["оста"]), - ("cs", &["páteř"]), - ("de", &["achse"]), - ("el", &["κεντρικής καμπύλης"]), - ("es", &["eje"]), - ("fi", &["selkäran"]), - ("fr", &["dorsale"]), - ("hu", &["gerinc"]), - ("it", &["dorsale"]), - ("ja", &["スパイン"]), - ("ko", &["스파인"]), - ("pl", &["grzbiet"]), - ("pt-br", &["coluna"]), - ("ru", &["остов"]), - ("sv", &["ryggrad"]), - ("uk", &["вісь", "осі"]), - ("vi", &["trục"]), - ("zh-hans", &["脊线"]), - ("zh-hant", &["脊線"]), - ]; - let mut bad = Vec::new(); - for (code, terms) in SPINE_TERMS { - let lang = Lang::from_code(code).expect("language registered"); - let (entries, _) = parse_entries(lang.0.source); - let rows: Vec<_> = entries.iter().filter(|(_, src, _)| src.to_lowercase().contains("spine")).collect(); - assert!(!rows.is_empty(), "{code}: no row names the spine"); - for (ctx, src, tr) in rows { - if !terms.iter().any(|t| tr.to_lowercase().contains(t)) { - bad.push(format!("{code}: {ctx} {src:?} → {tr:?}")); - } - } - } - assert!(bad.is_empty(), "rows that name the spine with another term:\n{}", bad.join("\n")); -} - fn uk() -> Lang { Lang::from_code("uk").expect("uk registered") } -fn pt_br() -> Lang { - Lang::from_code("pt-br").expect("pt-br registered") -} - -/// Brazilian Portuguese covers the complete interface and messages, with two plural forms like -/// English (zero takes the plural). -#[test] -fn brazilian_portuguese_reads_as_portuguese() { - assert_eq!(pt_br().name(), "Português (Brasil)"); - assert!(pt_br().complete_menus()); - assert_eq!(Lang::from_pref("pt-br"), pt_br()); - assert_eq!(Lang::from_pref("pt-BR"), pt_br()); - for tag in ["pt-br", "pt_BR.UTF-8", "pt-BR", "pt_BR"] { - assert_eq!(lang_from_tag(tag), Some(pt_br()), "{tag}"); - } - for (en, want) in [ - ("Artboard Tool", "Ferramenta Mesa de Trabalho"), - ("Swatches", "Amostras"), - ("Pathfinder", "Localizador de Caminhos"), - ("Stroke", "Traçado"), - ("Fill", "Preenchimento"), - ("Direct Selection Tool", "Ferramenta Seleção Direta"), - ("Save As…", "Salvar Como…"), - ("Undo", "Desfazer"), - ("Close", "Fechar"), - ] { - assert_eq!(tr(pt_br(), en), want, "{en}"); - } - assert_eq!(trn(pt_br(), 1, "{n} Layer", "{n} Layers"), "1 Camada"); - assert_eq!(trn(pt_br(), 0, "{n} Layer", "{n} Layers"), "0 Camadas"); - assert_eq!(trn(pt_br(), 3, "{n} Layer", "{n} Layers"), "3 Camadas"); - let nested = "command `object.group` is not available right now: nothing selected"; - assert_eq!(message(pt_br(), nested), "o comando `object.group` não está disponível agora: nada selecionado"); -} - -/// Spanish uses the vector-illustration vocabulary its users know, has two plural forms like -/// English, and reads the same in the menus and in the panels. -#[test] -fn spanish_reads_as_spanish() { - for (en, want) in [ - ("Artboard Tool", "Herramienta Mesa de trabajo"), - ("Swatches", "Muestras"), - ("Pathfinder", "Buscatrazos"), - ("Stroke", "Trazo"), - ("Fill", "Relleno"), - ("Direct Selection Tool", "Herramienta Selección directa"), - ("Save As…", "Guardar como…"), - ("Undo", "Deshacer"), - ] { - assert_eq!(tr(es(), en), want); - } - assert_eq!(trn(es(), 1, "{n} Layer", "{n} Layers"), "1 capa"); - assert_eq!(trn(es(), 0, "{n} Layer", "{n} Layers"), "0 capas"); - assert_eq!(trn(es(), 3, "{n} Layer", "{n} Layers"), "3 capas"); -} - -/// German uses the vector-illustration vocabulary its users know, has two plural forms like -/// English (zero takes the plural), translates the reason inside a message too, and tells the -/// interface theme ("Erscheinungsbild") from the Appearance panel ("Aussehen"). -#[test] -fn german_reads_as_german() { - for (en, want) in [ - ("Artboard Tool", "Zeichenflächen-Werkzeug"), - ("Swatches", "Farbfelder"), - ("Pathfinder", "Pathfinder"), - ("Stroke", "Kontur"), - ("Fill", "Fläche"), - ("Direct Selection Tool", "Direktauswahl-Werkzeug"), - ("Save As…", "Speichern unter…"), - ("Undo", "Rückgängig"), - ] { - assert_eq!(tr(de(), en), want); - } - assert_eq!(trn(de(), 1, "{n} Layer", "{n} Layers"), "1 Ebene"); - assert_eq!(trn(de(), 0, "{n} Layer", "{n} Layers"), "0 Ebenen"); - assert_eq!(trn(de(), 3, "{n} Layer", "{n} Layers"), "3 Ebenen"); - assert_eq!(tr_ctx(de(), "axis", "Both"), "Beide"); - assert_eq!(tr(de(), "Appearance"), "Aussehen"); - assert_eq!(tr_ctx(de(), "theme", "Appearance"), "Erscheinungsbild"); - let nested = "command `object.group` is not available right now: nothing selected"; - assert_eq!(message(de(), nested), "der Befehl `object.group` ist derzeit nicht verfügbar: nichts ausgewählt"); - // Text styles are "…format" in German: the kind is joined to it ("Neues Zeichenformat erstellen"). - assert_eq!(fmt(tr(de(), "Create New {kind} Style"), &[("kind", tr(de(), "Character"))]), "Neues Zeichenformat erstellen"); - assert_eq!(fmt(tr(de(), "Show {kind} Brushes"), &[("kind", tr(de(), "Calligraphic"))]), "Kalligrafiepinsel einblenden"); -} - -/// French uses the vector-illustration vocabulary its users know, puts zero in the singular (« 0 -/// calque »), and reads the same in the menus and in the panels. -#[test] -fn french_reads_as_french() { - for (en, want) in [ - ("Artboard Tool", "Outil Plan de travail"), - ("Swatches", "Nuancier"), - ("Pathfinder", "Pathfinder"), - ("Stroke", "Contour"), - ("Fill", "Fond"), - ("Direct Selection Tool", "Outil Sélection directe"), - ("Save As…", "Enregistrer sous…"), - ("Undo", "Annuler"), - ] { - assert_eq!(tr(fr(), en), want); - } - assert_eq!(trn(fr(), 0, "{n} Layer", "{n} Layers"), "0 calque"); - assert_eq!(trn(fr(), 1, "{n} Layer", "{n} Layers"), "1 calque"); - assert_eq!(trn(fr(), 2, "{n} Layer", "{n} Layers"), "2 calques"); -} - -/// Italian uses the vector-illustration vocabulary its users know, has two plural forms like -/// English (zero takes the plural), and reads the same in the menus and in the panels. -#[test] -fn italian_reads_as_italian() { - for (en, want) in [ - ("Artboard Tool", "Strumento Tavola da disegno"), - ("Swatches", "Campioni"), - ("Pathfinder", "Elaborazione tracciati"), - ("Stroke", "Traccia"), - ("Fill", "Riempimento"), - ("Direct Selection Tool", "Strumento Selezione diretta"), - ("Save As…", "Salva con nome…"), - ("Undo", "Annulla"), - ] { - assert_eq!(tr(it(), en), want); - } - assert_eq!(trn(it(), 1, "{n} Layer", "{n} Layers"), "1 livello"); - assert_eq!(trn(it(), 0, "{n} Layer", "{n} Layers"), "0 livelli"); - assert_eq!(trn(it(), 3, "{n} Layer", "{n} Layers"), "3 livelli"); -} - -/// Russian uses the vector-illustration vocabulary its users know, has the three plural forms -/// (one/few/many), and reads the same in the menus and in the panels. -#[test] -fn russian_reads_as_russian() { - for (en, want) in [ - ("Artboard Tool", "Инструмент «Монтажная область»"), - ("Swatches", "Образцы"), - ("Pathfinder", "Обработка контуров"), - ("Stroke", "Обводка"), - ("Fill", "Заливка"), - ("Direct Selection Tool", "Инструмент «Прямое выделение»"), - ("Save As…", "Сохранить как…"), - ("Undo", "Отменить"), - ] { - assert_eq!(tr(ru(), en), want); - } - assert_eq!(trn(ru(), 1, "{n} Layer", "{n} Layers"), "1 слой"); - assert_eq!(trn(ru(), 2, "{n} Layer", "{n} Layers"), "2 слоя"); - assert_eq!(trn(ru(), 5, "{n} Layer", "{n} Layers"), "5 слоёв"); - assert_eq!(trn(ru(), 11, "{n} Layer", "{n} Layers"), "11 слоёв"); - assert_eq!(trn(ru(), 21, "{n} Layer", "{n} Layers"), "21 слой"); - assert_eq!(trn(ru(), 22, "{n} Layer", "{n} Layers"), "22 слоя"); -} - -#[test] -fn ukrainian_reads_as_ukrainian() { - for (en, want) in [ - ("Artboard Tool", "Інструмент «Монтажна область»"), - ("Swatches", "Зразки"), - ("Pathfinder", "Обробка контурів"), - ("Stroke", "Обведення"), - ("Fill", "Заливка"), - ("Direct Selection Tool", "Інструмент «Пряме виділення»"), - ("Save As…", "Зберегти як…"), - ("Undo", "Скасувати"), - ] { - assert_eq!(tr(uk(), en), want); - } - assert_eq!(tr_ctx(uk(), "axis", "Both"), "Обидві"); - for (n, word) in [ - (0, "шарів"), - (1, "шар"), - (2, "шари"), - (5, "шарів"), - (11, "шарів"), - (21, "шар"), - (22, "шари"), - (111, "шарів"), - (112, "шарів"), - (121, "шар"), - (124, "шари"), - ] { - assert_eq!(trn(uk(), n, "{n} Layer", "{n} Layers"), format!("{n} {word}")); - } - let nested = "command `object.group` is not available right now: nothing selected"; - assert_eq!(message(uk(), nested), "команда `object.group` зараз недоступна: нічого не виділено"); -} - /// Every Ukrainian plural has all three forms; exercise different endings through real lookups. #[test] fn ukrainian_plurals_have_three_forms() { @@ -1556,34 +1219,6 @@ fn czech_plurals_have_three_forms() { assert!(count >= 13, "Czech plural rows missing"); } -/// Czech uses the vector-illustration vocabulary its users know, has three plural forms -/// (one/few/other), and translates the reason inside a message too. -#[test] -fn czech_reads_as_czech() { - for (en, want) in [ - ("Artboard Tool", "Nástroj Pracovní plocha"), - ("Swatches", "Vzorník"), - ("Pathfinder", "Operace s cestami"), - ("Stroke", "Tah"), - ("Fill", "Výplň"), - ("Direct Selection Tool", "Nástroj Přímý výběr"), - ("Save As…", "Uložit jako…"), - ("Undo", "Zpět"), - ] { - assert_eq!(tr(cs(), en), want); - } - for (n, word) in [(0, "vrstev"), (1, "vrstva"), (2, "vrstvy"), (4, "vrstvy"), (5, "vrstev"), (11, "vrstev")] { - assert_eq!(trn(cs(), n, "{n} Layer", "{n} Layers"), format!("{n} {word}")); - } - assert_eq!(tr_ctx(cs(), "axis", "Both"), "Obě"); - // "Add" is Unite in Effect › Pathfinder (an `@id` row for the effect id); "Přidat" elsewhere. - assert_eq!(tr(cs(), "Add"), "Přidat"); - assert_eq!(tr_id(cs(), "pathfinder.add", "Add"), "Sjednotit"); - assert_eq!(tr_id(cs(), "pathfinder.intersect", "Intersect"), "Protnout"); - let nested = "command `object.group` is not available right now: nothing selected"; - assert_eq!(message(cs(), nested), "příkaz `object.group` nyní není k dispozici: nic není vybráno"); -} - #[test] fn russian_plurals_have_three_forms() { let forms: Vec = [0, 1, 2, 4, 5, 11, 21, 22, 25, 111, 121].into_iter().map(plural_east_slavic).collect(); @@ -1700,43 +1335,6 @@ fn package_status_message_templates_parse() { assert!(Catalog::parse("@msg\tPackaged {n} file(s) in {folder}{note}\tbad\n").message("Packaged 1 file(s) in /a b").is_none()); } -/// Each language that translates status and error messages ([`COMPLETE_MESSAGES`]) has an `@msg` -/// row for every message in the sources that reach the status bar, so a new one can't ship -/// untranslated by accident (`VECTORCRAFT_I18N_DUMP_MESSAGES=` lists them). A message listed -/// in `untranslated_messages.txt` passes whether or not it has rows -/// (`VECTORCRAFT_I18N_DUMP_UNTRANSLATED=` writes that file's header and the messages in the -/// sources that lack a row). -#[test] -fn complete_languages_translate_every_message() { - let messages = message_literals(); - assert!(messages.len() > 300, "scan found only {} messages", messages.len()); - if let Ok(path) = std::env::var("VECTORCRAFT_I18N_DUMP_MESSAGES") { - std::fs::write(path, messages.iter().map(|m| format!("{m}\n")).collect::()).expect("write dump"); - } - let listed: std::collections::HashSet<&str> = UNTRANSLATED_MESSAGES.lines().filter(|l| !l.is_empty() && !l.starts_with('#')).collect(); - let mut untranslated = std::collections::BTreeSet::new(); - let mut unlisted = vec![]; - for code in COMPLETE_MESSAGES { - let lang = Lang::from_code(code).expect("registered"); - let (entries, _) = parse_entries(lang.0.source); - let rows: std::collections::HashSet<&str> = entries.iter().filter(|(ctx, _, _)| ctx == "@msg").map(|(_, src, _)| src.as_str()).collect(); - let missing: Vec<&str> = messages.iter().map(String::as_str).filter(|m| !rows.contains(m)).collect(); - untranslated.extend(missing.iter().copied()); - unlisted.push((code, missing.into_iter().filter(|m| !listed.contains(m)).collect::>())); - } - if let Ok(path) = std::env::var("VECTORCRAFT_I18N_DUMP_UNTRANSLATED") { - let header = UNTRANSLATED_MESSAGES.lines().take_while(|l| l.starts_with('#')); - std::fs::write(path, header.chain(untranslated).map(|l| format!("{l}\n")).collect::()).expect("write dump"); - } - for (code, missing) in unlisted { - assert!(missing.is_empty(), "{code}: {} untranslated messages: {missing:#?}", missing.len()); - } -} - -/// The messages [`complete_languages_translate_every_message`] lets through without rows (see the -/// file's header). -const UNTRANSLATED_MESSAGES: &str = include_str!("untranslated_messages.txt"); - /// The scan reads the message of an error made with one of the engine's [`ERROR_HELPERS`] or with /// an error enum's variant: a literal or a `format!` string, on the call's line or wrapped over /// several lines, in the same template form as the catalogs' rows. It does not read a call whose @@ -1787,79 +1385,15 @@ fn width(p: &Value, cmd: &str, key: &str) -> Result { let elsewhere = source_messages(code, &[]); assert!(elsewhere.len() == 2 && elsewhere.iter().all(|m| m.contains("nested") || m.contains("manifest")), "{elsewhere:#?}"); // A message read this way matches its row: `{key}` keeps its name. - let cs = Lang::from_code("cs").expect("registered"); - assert_eq!(message(cs, "`width` must be a number"), "`width` musí být číslo"); -} - -/// The parameter errors of Align, Distribute Spacing, Reflect and Shear are translated whole in each -/// language of [`COMPLETE_MESSAGES`]. -#[test] -fn align_reflect_and_shear_param_errors_are_translated() { - use serde_json::json; - let mut s = vectorcraft_engine::Session::new(); - s.execute("file.new", &json!({})).unwrap(); - for x in [0, 50] { - s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 10, "height": 10})).unwrap(); - } - s.execute("select.all", &json!({})).unwrap(); - for (cmd, p) in [ - ("object.align", json!({})), - ("object.align", json!({"horizontal": "middle"})), - ("object.align", json!({"vertical": "centre"})), - ("object.align", json!({"horizontal": "left", "to": "page"})), - ("object.align", json!({"horizontal": "left", "bounds": "visual"})), - ("object.distributeSpacing", json!({"axis": "diagonal"})), - ("object.distributeSpacing", json!({"spacing": "5"})), - ("object.reflect", json!({"axis": "diagonal"})), - ("object.shear", json!({"axis": "diagonal"})), - ] { - let e = s.execute(cmd, &p).unwrap_err(); - let vectorcraft_engine::EngineError::BadParams { msg, .. } = &e else { panic!("{cmd} {p}: {e}") }; - for code in COMPLETE_MESSAGES { - let shown = message(Lang::from_code(code).unwrap(), &e.to_string()); - assert!(!shown.contains(msg.as_str()), "{code}: {shown}"); - } - } -} - -/// The error of `brush.move` for a brush dragged onto a brush of another type in the Brushes panel, -/// whose text the scan above doesn't read, is translated whole in each language of -/// [`COMPLETE_MESSAGES`] and in Bulgarian, Danish, Indonesian and Dutch. -#[test] -fn brush_move_param_error_is_translated() { - use serde_json::json; - let mut s = vectorcraft_engine::Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let e = s.execute("brush.move", &json!({"name": "3 pt. Round", "type": "scatter"})).unwrap_err(); - let vectorcraft_engine::EngineError::BadParams { msg, .. } = &e else { panic!("{e}") }; - for code in COMPLETE_MESSAGES.iter().chain(&["bg", "da", "id", "nl"]) { - let shown = message(Lang::from_code(code).unwrap(), &e.to_string()); - assert!(!shown.contains(msg.as_str()) && !shown.contains("invalid parameters") && shown.contains("`brush.move`"), "{code}: {shown}"); - } -} - -/// The warning that a data set with no value for a bound variable gives, which the status bar -/// shows and the scan above doesn't read, is translated in each language of [`COMPLETE_MESSAGES`], -/// with the data set's name kept. -#[test] -fn the_data_set_applied_nothing_warning_is_translated() { - use serde_json::json; - let mut s = vectorcraft_engine::Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("dataset.import", &json!({"csv": "name,Name\nRow 7,Ann\n"})).unwrap(); - let v = s.execute("dataset.select", &json!({"name": "Row 7"})).unwrap(); - let warning = v["warning"].as_str().unwrap(); - for code in COMPLETE_MESSAGES { - let shown = message(Lang::from_code(code).unwrap(), warning); - assert!(shown.contains("Row 7") && !shown.contains("was applied"), "{code}: {shown}"); - } + let c = Catalog::parse("@msg\t`{key}` must be a number\t`{key}` ok\n"); + assert_eq!(c.message("`width` must be a number").map(|m| m.1), Some(vec![("key", "width")])); } /// The parameter errors of the blend spine commands (Move Spine Point, Reshape Spine Segment, Add /// Spine Point and Delete Spine Point, which Direct Selection, the Pen and the Anchor Point tool -/// send) are translated whole in each language of [`COMPLETE_MESSAGES`] and in Bulgarian. +/// send) say what is wrong. #[test] -fn blend_spine_param_errors_are_translated() { +fn blend_spine_param_errors_say_what_is_wrong() { use serde_json::json; use vectorcraft_engine::{EngineError, Session}; let mut s = Session::new(); @@ -1884,7 +1418,6 @@ fn blend_spine_param_errors_are_translated() { let other = rectangle(&mut s, 0, 600); let b = blend(&mut s, 0); s.execute("select.set", &json!({"ids": [b]})).unwrap(); - let mut errors = Vec::new(); for (cmd, p, want) in [ ("object.blend.spine.moveAnchor", json!({}), "missing number `anchor`"), ("object.blend.spine.moveAnchor", json!({"anchor": -1, "x": 0, "y": 0}), "index must be ≥ 0"), @@ -1900,143 +1433,17 @@ fn blend_spine_param_errors_are_translated() { ] { let e = s.execute(cmd, &p).unwrap_err(); assert!(matches!(&e, EngineError::BadParams { msg, .. } if msg == want), "{cmd} {p}: {e}"); - errors.push(e); } s.execute("select.none", &json!({})).unwrap(); let e = s.execute("object.blend.spine.moveAnchor", &json!({"anchor": 0, "x": 0, "y": 0})).unwrap_err(); assert!(matches!(&e, EngineError::BadParams { msg, .. } if msg == "select a blend or give `id`"), "{e}"); - errors.push(e); - // No command leaves a blend with a pinned spine of one point, so this one is built here. - errors.push(EngineError::BadParams { cmd: "object.blend.spine.addAnchor".into(), msg: "the spine has no segments".into() }); - let mut untranslated = Vec::new(); - for e in &errors { - let EngineError::BadParams { msg, .. } = e else { panic!("{e}") }; - for code in COMPLETE_MESSAGES.iter().chain(&["bg"]) { - let shown = message(Lang::from_code(code).unwrap(), &e.to_string()); - if shown.contains(msg.as_str()) { - untranslated.push(format!("{code}: {shown}")); - } - } - } - assert!(untranslated.is_empty(), "untranslated spine errors:\n{}", untranslated.join("\n")); -} - -/// Objects on Path's refusals of a path inside a compound path, blend, envelope, repeat or -/// Intertwine group, of an object inside a blend, envelope, repeat or Intertwine group, and of a -/// clipping path, whose text the scan above doesn't read, are translated whole in each language of -/// [`COMPLETE_MESSAGES`]. -#[test] -fn objects_on_path_refusals_are_translated() { - use serde_json::json; - use vectorcraft_engine::Session; - let mut s = Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let rectangle = - |s: &mut Session, x: u64| s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 40, "height": 40})).unwrap()["id"].clone(); - let path = s.execute("shape.line", &json!({"x1": 0, "y1": 300, "x2": 300, "y2": 300})).unwrap()["id"].clone(); - let object = rectangle(&mut s, 0); - let (member, hole) = (rectangle(&mut s, 100), rectangle(&mut s, 110)); - s.execute("select.set", &json!({"ids": [member, hole]})).unwrap(); - s.execute("object.compoundPath.make", &json!({})).unwrap(); - let (art, mask) = (rectangle(&mut s, 200), rectangle(&mut s, 210)); - s.execute("select.set", &json!({"ids": [art, mask]})).unwrap(); - s.execute("object.clippingMask.make", &json!({})).unwrap(); - let source = rectangle(&mut s, 300); - s.execute("select.set", &json!({"ids": [source]})).unwrap(); - s.execute("object.repeat.grid", &json!({})).unwrap(); - for (p, ids) in [(&member, &object), (&mask, &object), (&path, &source), (&path, &mask)] { - let e = s.execute("object.objectsOnPath.attach", &json!({"path": p, "ids": [ids]})).unwrap_err(); - let vectorcraft_engine::EngineError::BadParams { msg, .. } = &e else { panic!("{e}") }; - for code in COMPLETE_MESSAGES { - let shown = message(Lang::from_code(code).unwrap(), &e.to_string()); - assert!(!shown.contains(msg.as_str()), "{code}: {shown}"); - } - } -} - -/// The parameter error of Save as Template for a `.ait` with `aiVersion` 7 is translated whole in -/// each language of [`COMPLETE_MESSAGES`]. -#[test] -fn legacy_ait_param_error_is_translated() { - use serde_json::json; - let mut s = vectorcraft_engine::Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let e = s.execute("file.saveAsTemplate", &json!({"format": "ait", "aiVersion": "7"})).unwrap_err(); - let vectorcraft_engine::EngineError::BadParams { msg, .. } = &e else { panic!("{e}") }; - for code in COMPLETE_MESSAGES { - let shown = message(Lang::from_code(code).unwrap(), &e.to_string()); - assert!(!shown.contains(msg.as_str()), "{code}: {shown}"); - } -} - -/// The error of New Brush and Bristle Brush Options for a bristle brush name of more than 31 -/// characters, whose text the scan above doesn't read, is translated whole in each language of -/// [`COMPLETE_MESSAGES`] and in Bulgarian, with the name and both counts kept. -#[test] -fn bristle_brush_name_limit_error_is_translated() { - use serde_json::json; - let mut s = vectorcraft_engine::Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let name = "b".repeat(32); - let e = s.execute("brush.new", &json!({"type": "bristle", "name": name})).unwrap_err(); - let vectorcraft_engine::EngineError::BadParams { msg, .. } = &e else { panic!("{e}") }; - for code in COMPLETE_MESSAGES.iter().chain(&["bg"]) { - let shown = message(Lang::from_code(code).unwrap(), &e.to_string()); - let kept = shown.contains(&format!("`{name}`")) && shown.contains("31") && shown.contains("32"); - assert!(!shown.contains(msg.as_str()) && kept, "{code}: {shown}"); - } -} - -/// The errors for a parameter key a command doesn't take (#1442), whose text the scan above -/// doesn't read, are translated whole in each language of [`COMPLETE_MESSAGES`]. -#[test] -fn unknown_parameter_errors_are_translated() { - use serde_json::json; - for (cmd, p) in [("file.new", json!({"widht": 300})), ("select.all", json!({"zzz": 1})), ("shape.line", json!({"colour": "red"}))] { - let e = vectorcraft_engine::check_params(cmd, &p).unwrap_err(); - let vectorcraft_engine::EngineError::BadParams { msg, .. } = &e else { panic!("{cmd} {p}: {e}") }; - for code in COMPLETE_MESSAGES { - let shown = message(Lang::from_code(code).unwrap(), &e.to_string()); - assert!(!shown.contains("unknown parameter") && shown.contains(cmd), "{code}: {shown} ({msg})"); - } - } -} - -/// The error for an effect `color` the effect can't draw is translated whole in each language of -/// [`COMPLETE_MESSAGES`] and in Bulgarian. -#[test] -fn effect_color_param_error_is_translated() { - use serde_json::json; - let p = json!({"effect": "stylize.dropShadow", "params": {"color": "not-a-color"}}); - let e = vectorcraft_engine::check_params("effect.apply", &p).unwrap_err(); - let vectorcraft_engine::EngineError::BadParams { msg, .. } = &e else { panic!("{e}") }; - for code in COMPLETE_MESSAGES.iter().chain(&["bg"]) { - let shown = message(Lang::from_code(code).unwrap(), &e.to_string()); - assert!(!shown.contains(msg.as_str()) && shown.contains("`color`") && shown.contains("effect.apply"), "{code}: {shown}"); - } -} - -/// The error for a Levels or Curves `channel` the effect doesn't have, whose text the scan above -/// doesn't read, is translated whole in each language of [`COMPLETE_MESSAGES`] and in Bulgarian. -#[test] -fn effect_channel_param_error_is_translated() { - use serde_json::json; - let p = json!({"effect": "adjust.levels", "params": {"channel": "alpha"}}); - let e = vectorcraft_engine::check_params("effect.apply", &p).unwrap_err(); - let vectorcraft_engine::EngineError::BadParams { msg, .. } = &e else { panic!("{e}") }; - for code in COMPLETE_MESSAGES.iter().chain(&["bg"]) { - let shown = message(Lang::from_code(code).unwrap(), &e.to_string()); - assert!(!shown.contains(msg.as_str()) && shown.contains("`channel`") && shown.contains("effect.apply"), "{code}: {shown}"); - } } /// The status bar's line for an EPS file whose PostScript stops on an error, opened partly or -/// refused, and the refusal's dialog keep the error's name as PostScript spells it and show what -/// it means in each language of [`COMPLETE_MESSAGES`] and in Bulgarian; each meaning in -/// [`vectorcraft_eps::POSTSCRIPT_ERRORS`] has a row in those catalogs. +/// refused, and the refusal's dialog keep the error's name as PostScript spells it and its meaning, +/// which each language shows as its catalog translates the meaning. #[test] -fn postscript_error_meanings_are_translated() { - let languages = || COMPLETE_MESSAGES.iter().chain(&["bg"]).map(|code| (*code, Lang::from_code(code).unwrap())); +fn postscript_errors_keep_their_name_and_meaning() { let meaning = "a value has the wrong type for the operator"; for (body, opens) in [("0 0 10 10 rectfill 1 (a) add", true), ("1 (a) add", false)] { let bytes = format!("%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n{body}\n%%EOF\n").into_bytes(); @@ -2049,75 +1456,23 @@ fn postscript_error_meanings_are_translated() { } for text in shown_texts { assert!(text.contains(&format!("(typecheck in `add`; {meaning})")), "{text}"); - for (code, lang) in languages() { + for lang in Lang::all() { let shown = message(lang, &text); - let ok = shown.contains("typecheck") && shown.contains("`add`") && shown.contains(&message(lang, meaning)); - assert!(ok && !shown.contains(meaning) && !shown.contains("PostScript error"), "{code}: {shown}"); + let meaning_shown = shown.contains(&message(lang, meaning)) || shown.contains(meaning); + let ok = shown.contains("typecheck") && shown.contains("`add`") && meaning_shown; + assert!(ok, "{}: {shown}", lang.code()); } } } - for (code, lang) in languages() { - let missing: Vec<_> = vectorcraft_eps::POSTSCRIPT_ERRORS.iter().filter(|(_, m)| message(lang, m) == *m).collect(); - assert!(missing.is_empty(), "{code}: {missing:?}"); - } -} - -/// The notes of an EPS or `.ai` whose layers weren't read, which the importer pushes as warnings -/// (the scan above doesn't read them), are translated whole, reason included, as the status bar -/// shows them after opening (#2035). -#[test] -fn unread_layers_notes_are_translated() { - for note in [ - "the file's layers weren't read (its editing data is for a 600 × 400 pt page, not the file's 612 × 792 pt page): it comes in as its page, in one layer", - "the file's layers weren't read from its editing data (its editing data is for a 100 × 100 pt page, the file's 1000 × 1000 pt page at 1:10): they come from its PDF part", - "the file's layers weren't read from its editing data (its editing data is damaged): they come from its PDF part", - "the file's layers weren't read from its editing data (its zlib compression is damaged): they come from its PDF part", - "the file's layers weren't read from its editing data (its Zstandard compression is damaged): they come from its PDF part", - "the file's layers weren't read from its editing data (it decompresses to more than 256 MB): they come from its PDF part", - "the file's variables and data sets weren't read (variables: 2, data sets: 3): the Variables panel doesn't list them", - ] { - let status = format!("Opened with 1 note(s): {note}"); - for code in COMPLETE_MESSAGES.iter().chain(&["pt-br"]) { - let shown = message(Lang::from_code(code).unwrap(), &status); - assert!( - !shown.contains("weren't read") - && !shown.contains("its editing data") - && !shown.contains("is damaged") - && !shown.contains("decompresses") - && !shown.contains("Opened with"), - "{code}: {shown}" - ); - } - } -} - -/// The parameter error of `select.key` for an id that isn't a selected top-level object is -/// translated whole in each language of [`COMPLETE_MESSAGES`]. -#[test] -fn select_key_param_error_is_translated() { - use serde_json::json; - let mut s = vectorcraft_engine::Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let rectangle = |s: &mut vectorcraft_engine::Session, x: u64| { - s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone() - }; - let [a, b, unselected] = [0, 50, 100].map(|x| rectangle(&mut s, x)); - s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); - let e = s.execute("select.key", &json!({"id": unselected})).unwrap_err(); - let vectorcraft_engine::EngineError::BadParams { msg, .. } = &e else { panic!("{e}") }; - for code in COMPLETE_MESSAGES { - let shown = message(Lang::from_code(code).unwrap(), &e.to_string()); - assert!(!shown.contains(msg.as_str()), "{code}: {shown}"); - } } /// The errors of the parameter checks that many commands share (a number given as text, a missing /// number or index, a fraction for a whole number, `ids` that aren't a list of object ids, an -/// object id that is negative or names no object, a date that isn't Unix seconds) are translated -/// whole by their own rows in each language of [`COMPLETE_MESSAGES`], in Bulgarian, and in Danish, -/// Indonesian and Dutch. The value in the message, such as a quoted `"60 mm"`, is shown as given. +/// object id that is negative or names no object, a date that isn't Unix seconds) fill their +/// templates, and each language shows the value in the message, such as a quoted `"60 mm"`, as +/// given: in its row's translation, or in English where it has none. #[test] -fn shared_param_check_errors_are_translated() { +fn shared_param_check_errors_show_their_values_as_given() { use serde_json::json; use vectorcraft_engine::EngineError; let mut s = vectorcraft_engine::Session::new(); @@ -2138,24 +1493,21 @@ fn shared_param_check_errors_are_translated() { ("edit.clear", json!({"ids": [99999]}), "no such object id {value}", &[("value", "99999")]), ("file.new", json!({"created": "yesterday"}), "{key} must be Unix seconds (an integer) or null", &[("key", "created")]), ]; - let mut untranslated = Vec::new(); for (cmd, p, template, values) in cases { let e = s.execute(cmd, &p).unwrap_err(); assert!(matches!(&e, EngineError::BadParams { msg, .. } if *msg == fmt(template, values)), "{cmd} {p}: {e}"); - for code in COMPLETE_MESSAGES.iter().chain(&["bg", "da", "id", "nl"]) { - let lang = Lang::from_code(code).unwrap(); + for lang in Lang::all() { let (entries, _) = parse_entries(lang.0.source); let row = entries.iter().find(|(ctx, src, _)| ctx == "@msg" && src == template); let shown = message(lang, &e.to_string()); - if shown.contains("invalid parameters") || !row.is_some_and(|(_, _, tr)| shown.contains(&fmt(tr, values))) { - untranslated.push(format!("{code}: {shown}")); - } + let as_given = shown.contains(&fmt(template, values)) || row.is_some_and(|(_, _, tr)| shown.contains(&fmt(tr, values))); + assert!(as_given, "{}: {shown}", lang.code()); } } - assert!(untranslated.is_empty(), "untranslated parameter check errors:\n{}", untranslated.join("\n")); } -/// Languages whose catalogs cover every status and error message. +/// Languages whose catalogs translate status and error messages; [`untranslated_report`] lists +/// the messages each one lacks. const COMPLETE_MESSAGES: &[&str] = &["cs", "de", "el", "es", "fr", "hu", "it", "ja", "ka", "ko", "pl", "pt-br", "ru", "sv", "uk", "vi", "zh-hans", "zh-hant", "fi", "tr"]; @@ -2322,59 +1674,29 @@ fn as_template(s: &str) -> String { out } -/// Render-site labels keep English fallback, placeholders and Czech grammar independent of -/// the plain language-name key. No global language changes: other UI tests may run alongside. +/// Render-site labels looked up in a context fall back to the plain row and then to English, and +/// their templates fill in English as in any language. #[test] -fn czech_render_site_follow_up() { - for (source, expected) in [ - ("All readable files", "Všechny čitelné soubory"), - ("All placeable files", "Všechny soubory pro umístění"), - ("SVG Compressed", "Komprimované SVG"), - ("PDF-compatible .ai", ".ai kompatibilní s PDF"), - ("VectorCraft Template", "Šablona VectorCraft"), - ] { - assert_eq!(tr_ctx(cs(), "file-filter", source), expected); - assert_eq!(tr_ctx(Lang::EN, "file-filter", source), source); +fn render_site_labels_fall_back_to_english() { + for (_, source) in RENDER_SITE_ROWS.iter().filter(|(ctx, _)| *ctx == "file-filter") { + assert_eq!(tr_ctx(Lang::EN, "file-filter", source), *source); } for source in ["SVG", "PNG", "VectorCraft", "unregistered filter"] { - assert_eq!(tr_ctx(cs(), "file-filter", source), source); + assert_eq!(tr_ctx(cs(), "file-filter", source), tr(cs(), source)); } - assert_eq!(tr_ctx(cs(), "diagnostics", "render"), "vykreslení"); - assert_eq!(tr_ctx(cs(), "diagnostics", "ui"), "rozhraní"); - assert_eq!(fmt(tr(cs(), "Preset {number}"), &[("number", "12")]), "Přednastavení 12"); assert_eq!(fmt(tr(Lang::EN, "Preset {number}"), &[("number", "12")]), "Preset 12"); - for (kind, expected) in [("Linear", "Lineární přechod"), ("Radial", "Kruhový přechod"), ("Freeform", "Volný přechod")] { - assert_eq!(fmt(tr(cs(), "{kind} Gradient"), &[("kind", tr_ctx(cs(), "gradient-kind", kind))]), expected); + for kind in ["Linear", "Radial", "Freeform"] { assert_eq!(fmt(tr(Lang::EN, "{kind} Gradient"), &[("kind", tr_ctx(Lang::EN, "gradient-kind", kind))]), format!("{kind} Gradient")); } - assert_eq!(tr(cs(), "Japanese"), "Japonština"); - assert_eq!(tr_ctx(cs(), "mark-style", "Japanese"), "Japonské"); assert_eq!(tr_ctx(Lang::EN, "mark-style", "Japanese"), "Japanese"); - assert_eq!(tr(cs(), "PRs"), "PR"); - for (n, expected) in [(0, "0 commitů"), (1, "1 commit"), (2, "2 commity"), (4, "4 commity"), (5, "5 commitů")] { - assert_eq!(trn(cs(), n, "{n} commit", "{n} commits"), expected); - assert_eq!(trn(cs(), n, "{n} PR", "{n} PRs"), format!("{n} PR")); + for n in [0, 1, 2, 5] { + let commits = if n == 1 { "commit" } else { "commits" }; + assert_eq!(trn(Lang::EN, n, "{n} commit", "{n} commits"), format!("{n} {commits}")); } } -/// The labels in [`RENDER_SITE_ROWS`], which the UI looks up through a variable or a context that -/// the literal scans above don't read, have a row in every complete catalog. -#[test] -fn complete_catalogs_translate_render_site_labels() { - for l in LANGUAGES.iter().filter(|l| l.complete_menus) { - let (entries, _) = parse_entries(l.source); - let rows: std::collections::HashSet<(&str, &str)> = entries.iter().map(|(ctx, src, _)| (ctx.as_str(), src.as_str())).collect(); - let missing: Vec<_> = RENDER_SITE_ROWS.iter().filter(|row| !rows.contains(row)).collect(); - assert!(missing.is_empty(), "{}: {} missing rows: {missing:#?}", l.code, missing.len()); - } - // German's plain "Light" row is Hell (bright), and its plain "Regular" row stays in English. - assert_eq!(tr_ctx(de(), "lighting", "Light"), "Licht"); - assert_eq!(tr_ctx(de(), "effect", "Regular"), "Regelmäßig"); - assert_eq!(tr_ctx(Lang::from_code("ja").unwrap(), "mark-style", "Japanese"), "日本式"); -} - -/// (context, English source) of the labels [`complete_catalogs_translate_render_site_labels`] -/// requires: the file dialogs' filter names, the status bar's timings, New Document's preset +/// (context, English source) of the labels [`untranslated_report`] lists for a language without +/// their rows: the file dialogs' filter names, the status bar's timings, New Document's preset /// name, the contributor tooltips, the gradient kind in a swatch's description, the printer mark /// styles and the label of the 3D gizmo's light. const RENDER_SITE_ROWS: &[(&str, &str)] = &[ @@ -2426,47 +1748,3 @@ fn polish_plurals_have_three_forms() { } assert!(count >= 13, "Polish plural rows missing"); } - -/// Polish uses its vector-illustration vocabulary (obszar kompozycji, ścieżka, obrys…), resolves -/// every `pl-*` locale, counts with one/few/many and translates the reason inside a message too. -#[test] -fn polish_reads_as_polish() { - for tag in ["pl", "pl-PL", "pl_PL.UTF-8", "PL-pl"] { - assert_eq!(lang_from_tag(tag), Some(pl()), "{tag}"); - } - for (en, want) in [ - ("File", "Plik"), - ("Artboard Tool", "Narzędzie Obszar kompozycji"), - ("Swatches", "Próbki"), - ("Pathfinder", "Odnajdywanie ścieżek"), - ("Stroke", "Obrys"), - ("Fill", "Wypełnienie"), - ("Workspace", "Obszar roboczy"), - ] { - assert_eq!(tr(pl(), en), want); - } - for (n, word) in [(1, "warstwa"), (3, "warstwy"), (5, "warstw"), (22, "warstwy"), (21, "warstw")] { - assert_eq!(trn(pl(), n, "{n} Layer", "{n} Layers"), format!("{n} {word}")); - } - assert_eq!(tr_ctx(pl(), "axis", "Both"), "Obie"); - assert_eq!(tr_id(pl(), "pathfinder.add", "Add"), "Połącz"); - let nested = "command `object.group` is not available right now: nothing selected"; - assert_eq!(message(pl(), nested), "polecenie `object.group` jest teraz niedostępne: nic nie zaznaczono"); -} - -/// Japanese names every default swatch and colour group the Swatches panel shows (the greys' -/// `K=` values read the same in any language); a name the user gave a swatch has no row. -#[test] -fn japanese_names_the_default_swatches() { - let ja = Lang::from_code("ja").unwrap().catalog(); - let (swatches, groups) = vectorcraft_color::default_swatches(vectorcraft_color::cms::Model::Rgb); - let names = swatches - .iter() - .map(|s| s.name.clone()) - .chain(groups.iter().flat_map(|g| std::iter::once(g.name.clone()).chain(g.swatches.iter().map(|s| s.name.clone())))) - .chain([vectorcraft_color::swatch::REGISTRATION.to_string()]); - let missing: Vec = names.filter(|n| !n.starts_with("K=") && ja.contextual("swatch", n).is_none()).collect(); - assert!(missing.is_empty(), "{missing:?}"); - assert_eq!(ja.contextual("swatch", "White"), Some("ホワイト")); - assert_eq!(ja.contextual("swatch", "My Red"), None); -} diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index c80a66f53..644bea1f4 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -7,7 +7,7 @@ # монтажна область, шар, обведення, заливка, контур, опорна точка, зразки, пензель, виділення. # Shortcut key names (Shift, Alt, Ctrl, Esc), command ids and placeholders remain unchanged. # -# Coverage: every menu string, tl! literal, plural, status and error message (enforced in tests). +# Coverage: every menu string, tl! literal, plural, status and error message (`untranslated_report` lists gaps). # Product, language, built-in workspace and perspective preset names follow MENU_KEEP_AS_IS. # Keep placeholders, ellipses and leading/trailing spaces of fragments when editing. Glowing Edges… Сяйливі краї… @@ -1389,7 +1389,7 @@ file-filter Templates Шаблони Clear History Очистити історію Set History Limit… Задати межу історії… History States Стан історії -# History panel: the names of undo steps (`every_undo_label_is_translated`). +# History panel: the names of undo steps (`untranslated_report` lists any that are missing). Apply Data Set Застосувати набір даних Area Type Текст в області Bloat Роздування diff --git a/crates/ui-egui/src/i18n/untranslated_messages.txt b/crates/ui-egui/src/i18n/untranslated_messages.txt deleted file mode 100644 index e526314c8..000000000 --- a/crates/ui-egui/src/i18n/untranslated_messages.txt +++ /dev/null @@ -1,1293 +0,0 @@ -# Status and error messages that at least one language in `COMPLETE_MESSAGES` (tests.rs) has no -# `@msg` row for yet, one per line as the message scan writes it (`{}` as `{_1}`), sorted. The list -# also holds the messages that open pull requests add without those rows, so those pull requests -# don't fail when they merge. `complete_languages_translate_every_message` lets these through; every -# other message needs a row in each complete catalog. A pull request that adds a message's rows -# removes its line here. The test ignores a line whose message no source contains. -# `VECTORCRAFT_I18N_DUMP_UNTRANSLATED=` writes this header and the messages in the sources -# that lack a row. -A profile supports one Revolve effect. Edit the existing effect's options instead. -CSS options: {e} -CSV has a header but no data rows -CSV is empty -CSV is larger than {MAX_CSV_BYTES} bytes -CSV is not UTF-8 -CSV would be larger than {MAX_CSV_BYTES} bytes -Choose original artwork, not a dimension's generated paths -Create needs fills, strokes or both -DXF options: {e} -DXF scale must be a positive number up to {_1}, not {scale} -DXF {field} `{s}`: {allowed} -Dimension is locked or hidden -Dimension mode does not match its source -Draw Inside needs exactly one selected path, compound path or text -EPS flattener: {e} -EPS options: {e} -EPS {field} `{s}`: {allowed} -Export for Screens writes png, png8, jpg, webp, gif, tiff, bmp, tga, psd, svg, svgz, pdf, eps, ai or vectorcraft -HTML Text applies to object slices of type only -Identify Objects By is XML ID: the name must be a valid XML ID (start with a letter, underscore or colon; no spaces) -Invalid dimension mode -Invalid dimension offset -Invalid point -JSON recovery transport exceeds 8 MiB; use the binary adapter -PDF content is incomplete or exceeds the reader's limits; no document was imported -PDF flattener: {e} -PDF options: {e} -SVG is not UTF-8, UTF-16 or ISO 8859-1 -Save writes one artboard: name one, or export several with document.export -Select a dimension first -Select at most 1000 segments at a time -Select dimensions to update -Select dimensions to update (at most 1000) -Select one or more paths first -Select only dimension groups -Select only dimensions -Shaper needs 1–64 source paths -Switch x/y is for scatter graphs -Text options: {e} -`align` is start, center or end -`anchors` must be an array of [subpath, anchor] pairs -`asset` is the asset id (see assets.list) -`at` must be a byte offset -`autoLeading` must be a number -`background` must be "", "matte" or #rrggbb -`background` must be a colour -`burasagari` must be "none", "standard" or "forced" -`charAlign` must be "romanBaseline", "emBoxTop", "emBoxCenter", "emBoxBottom", "icfTop" or "icfBottom" -`charDirection` must be "default", "leftToRight" or "rightToLeft" -`color` needs a `rule` to make the theme from -`colors` is empty -`columnType` is vertical, uniform, repeating or sliding -`commands` must not be empty -`corners` must list anchor indices (0, 1, 2…), not {v} -`dataBase64` is larger than the module limit -`dataBase64` is not valid base64 -`depth` and `childLimit` slice the summary: pass `summary: true` -`design` is a design name, or null for plain columns -`design` is a design name, or null for the default marker -`destinationFolder` is required when destination is `folder` -`destination` must be `none`, `saveAndClose` or `folder` -`digits` must be "default", "arabic", "hindi" or "farsi" -`dimImages` is a percentage or false -`direction` must be "auto", "leftToRight" or "rightToLeft" -`each` is the value one design stands for, more than 0 -`ecc` must be "L", "M", "Q" or "H" -`event` must be one of {_1} -`files` must be 1 to 1000 paths of font files the last text.findFontFiles search found -`folder` must be an absolute folder path -`fontAxes` must be an object of axis tags and values, such as {"wght"} -`fractions` is chop or scale -`from` and `to` must differ -`from` has an empty frame -`from` must be area type or type on a path -`guide` must be a guide index (guide.list) -`hAlign` must be default, left, center or right -`id` must be a node id -`id` must be an object id, not {v} -`ids` must name Objects on Path groups or objects in them -`ids` takes at most {MAX_INSPECT_IDS} ids -`imageMap` must be none, rectangle or polygon -`in` must be [x, y] -`index` must be a point index -`index` must be a whole number or null -`indices` must be a non-empty array of item indices -`items` is a list of {kind, item} -`items` must be an array of {id, anchors} -`items` must be an array of {{id, corners?}}, not {v} -`kashida` must be "none", "short", "medium", "long" or "stylistic" -`kind` is one of {_1} -`kind` must be "round", "invertedRound" or "chamfer", not {v} -`kind` must be `text`, `visibility`, `image` or `graph` -`kind` must be a string -`kind` must be image, noImage or htmlText -`kinsoku` must be "none", "hard" or "soft" -`label` must be a non-empty name of at most 200 characters -`leadingModel` must be "romanBaseline" or "emBoxTop" -`line` must be true or false -`mode` must be "add", "subtract", "intersect" or "exclude" -`mojikumi` must be "none" or "lineEndHalf" -`name` is empty -`name` must be 1-64 printable characters -`names` must be a list of names -`newName` is empty -`new` is a design name, or true -`params` must be an object -`path` must be a folder -`path` must be a path object -`pivot` must be a whole number from 0 to 8 -`points` must be an array of point indices -`position` is above or below -`pressure` must be a number, not {f} -`quietZone` must be a whole number of modules from 0 to 100 -`rect` must be [x, y, width, height] with a positive size -`reversed` must be true or false -`rotate` must be a finite number -`rule` must be nonZero or evenOdd -`scale` must be a number in (0, 100] -`script` must be a string -`seriesIndexes` is a non-empty list of series indexes -`seriesIndexes` needs a `type` or a `valueAxis` -`shift` must be a number of points -`showCenter` must be true or false -`singleWordJustification` must be "fullJustify", "alignLeft", "alignCenter" or "alignRight" -`size` is a percentage of the base text's size, 1 to 100 -`size` must be a positive number -`source` and `target` are the same object -`source` must be `folder` or `datasets` -`spacing` must be a number -`starts` must be an array of anchor indices (or nulls) -`stop` must be a stop index -`strokeMode` applies to strokes (stroke: true) -`style` must be roman or japanese -`symbol` must be a symbol name -`target` must be "object" or "contents", not {v} -`text` is required -`tint` applies to global and spot colours -`tint` goes with a global or spot `swatch` -`tint` must be a number (%) -`tint` needs a global or spot colour -`to` is already threaded -`to` is the design's new name -`to` must be a closed path, area type or type on a path other than `from` -`to` must be a whole number -`to` takes a single `index` -`useLab` must be true or false -`vAlign` must be default, top, middle, baseline or bottom -`values` must be an object -`version`, `description` or `author` is too long -`warnings` must be a list of strings -`{_1}` can't be edited or removed -`{_1}` is a live effect: apply it with effect.apply {{"effect"}"}} -`{_1}` is a {_2}, not an exporter -`{_1}` is an exporter: export with document.export {{"format"}"}} -`{_1}` is built in and always listed -`{_1}` is not a font file the last text.findFontFiles search found -`{_1}` would be overwritten by the package: choose a different folder or name -`{file}` has no art to place -`{file}` is over {_1} MB -`{f}` belongs to a VectorCraft that is running -`{f}` is the copy of a document open now -`{id}` is no writable format (see document.formats) -`{id}` isn't a loaded library -`{key}` has no colours -`{key}` is a design name -`{key}` is a design name or a list of them -`{key}` is longer than {MAX_NAME} characters -`{key}` is out of range -`{key}` must be [min, desired, max] or {{min?, desired?, max?}}, in percent -`{key}` must be a non-negative integer -`{key}` must be a number, not {v} -`{key}` must be a profile name or null -`{key}` must be an appearance item index (paint order) or null -`{key}` must be an array of asset ids (see assets.list) -`{key}`: not a colour: {v} -`{k}.family` is required -`{k}` is a number 0..1 -`{k}` is true or false -`{k}` isn't an export setting: {_1} -`{k}` must be a byte offset -`{k}` must be a number -`{k}` must be a number of points -`{k}` must be a percentage 0..100 -`{k}` must be a string -`{k}` must be a whole number -`{k}` must be one of {_1} -`{k}` must be {what} -`{k}` must be {what}, not {v} -`{l}` isn't a global or spot colour -`{m}` isn't a bitmap: use clipboard.importSvg, importPdf or importText -`{name}` can't be deleted -`{name}` can't be duplicated -`{name}` can't be edited -`{name}` can't be renamed -`{name}` can't name a folder -`{name}` has no art to place -`{name}` has no text to place -`{name}` in the Word document is not UTF-8 or UTF-16 text -`{name}` in the Word document is not well-formed XML: {e} -`{name}` in the Word document nests elements more than {MAX_NESTING} deep -`{name}` is a built-in preset and can't change: save it under another name -`{name}` is a built-in preset and is read-only: save it under another name -`{name}` is a built-in preset and stays -`{name}` is a built-in preset: save under another name -`{name}` is a built-in profile and stays -`{name}` is a tint of `{base}`: edit `{base}` (a tint swatch only takes a new name) -`{name}` is a working space: choose another with edit.colorSettings first -`{name}` is built in and stays -`{name}` takes a paint of its own kind (colour, gradient or pattern) -`{name}`: Minimum must not exceed Desired, nor Desired Maximum -`{n}` can't be deleted -`{n}` can't be merged -`{n}` can't be moved -`{n}` isn't a solid colour -`{n}`: colour groups hold solid colours only -`{tag}` is not an axis tag: a tag is four characters, such as "wght" -`{to}` is empty or already used -`{what}` is longer than {MAX_TEXT} bytes -`{what}` must be text -a DXF drawing has one origin: pass useArtboards: true for one drawing per artboard -a brush named `{nn}` exists -a container of the listed objects is gone -a content stream can't be read -a content stream isn't a stream -a design holds at most {_1} objects -a design needs a name -a dictionary grew too large -a document keeps at most {MAX_VIEWS} views -a document keeps at most {_1} graph designs -a document keeps at most {_1} saved selections -a document links at most {MAX_SVG_SCRIPTS} JavaScript files -a graph has at most {_1} series, so it transposes with at most that many categories -a layer can't be merged into its own sublayer -a layer's form box isn't an array -a layer's form can't be read -a layer's form has no box -a layer's form has no dictionary -a name names one asset: give multiple: false, or one object -a page can't be read -a page object is used by something else -a page thumbnail must be RGB pixels, 1 to {THUMBNAIL_SIZE} a side (got {w} × {h}) -a pattern named `{n}` exists -a plug-in with id {_1} is already installed -a preset named `{n}` exists -a profile is already called {n} -a right-to-left character moves only up and down (dy) -a row can't go into itself -a series goes on the left or the right value axis -a start must be an anchor index ≥ 0 or null -a style named `{name}` already exists -a swatch needs a colour, gradient or pattern -a swatch or color group named `{n}` exists -a symbol named `{n}` already exists -a theme has 1 to {THEME_SIZE} colours -a theme is already called {name} (replace: "{name}" overwrites it) -a view named `{name}` exists -a view named `{new}` exists -a {_1} series can't go in a {_2} graph -aiVersion {v}: "pdf" (PDF-compatible, the default) or "7" (legacy .ai, version 7) -already editing an opacity mask -already editing another object's opacity mask -already in pattern editing mode -an artboard is listed twice -an empty artboard has no width or height to scale to -an exporter declares 1 to {MAX_EXTENSIONS} `extensions` -an image of {_1} MB, at most {_2} MB -an image of {width} × {height} pixels is too large to draw -an index must be a whole number -an invisible text form can't be read -an object is missing -an object's header is missing -an overprinting form can't be read -anchor `smooth` must be true or false -anchor `{key}` must be [x, y] -anchor `{key}` must be a number -angles must be a list as long as pins (degrees or null) -appearance must be flat|center|edge -apply is "fill" or "stroke", not `{other}` -area must be a closed polygon of 3 to 100000 [x, y] points -area type needs a path that encloses an area -arrowAlign must be extend|tip, got {o} -arrowScale must be [start, end] -art, guides, slices or the rulers' zero point lie beyond the standard canvas (±4,000,000 pt): move them nearer the origin first -artboard is a 0-based index -artboard must be a 0-based index -artboard {i} doesn't print -artboardLayout must be {layout?, columns?, spacing?, rightToLeft?} -artboardLayout.columns must be 1 or more -artboardLayout.layout must be gridByRow, gridByColumn, row or column -artboardLayout.rightToLeft must be true or false -artboardLayout.spacing must be -{MAX_SPACING}–{MAX_SPACING} pt -artboardLayout.spacing must be 0–{MAX_SPACING} pt -artboards must be a non-empty list -artboards must be a number -asset `{_1}` has no art to export -at most {MAX_KEYWORDS} keywords -at most {MAX_MATCHES} matches are kept -at most {MAX_QUEUE} files -at most {MAX_UNGROUPED} parameters outside groups: give the others a `group` -at must be [x, y] -at/rect lie outside the canvas -attribute must be size|leading|tracking|kerning|baselineShift -attrs must be an object -axis must be "vertical", "horizontal" or an angle in degrees, not `{given}` -backgroundContents must be transparent or white -bad AES key -bad `runs`: {e} -bad arrowScale -bad base64 -bad color {c} -bad colour -bad colour in `colors` -bad colour {v} -bad colour {v} in groupColors -bad cross-reference entry -bad cross-reference table -bad current colour {v} -bad dataBase64 -bad edit -bad fill colour -bad fill {v} -bad fit: {e} -bad model {other} (keep, new or document) -bad new colour {v} -bad object -bad object number -bad offset -bad path data: {e} -bad reference -bad trailer size -bad {k} colour -batches cannot nest -bind something to a variable first -bleed must be 0–{MAX_BLEED} pt: a number, [top, bottom, left, right] or {{top, bottom, left, right}} -brush must be a name or null -by must be a number -bytes must be an array of integers -bytes must be integers from 0 to 255 -can't add page thumbnails: {why} -can't edit the written PDF: {why} -can't encode the input -can't encrypt the PDF: {why} -can't linearise the PDF: {why} -can't open `{_1}`: {_2} -can't open `{_1}`: {e} -can't open `{name}`: not a format VectorCraft reads (see document.formats) -can't place `{name}`: not a format VectorCraft reads (see document.formats) -can't read back the written PDF: {e} -can't write .{e} images: use .png, .jpg, .gif, .webp, .tif or .bmp -can't write PDF layers or overprint: {why} -can't write PDF layers, overprint or invisible text: {why} -can't write the file's copyright: {why} -can't write the file's modification date: {why} -can't write the pages' user units: {why} -cannot distort a zero-size or non-finite bounding box -cannot move an object into itself -canvas must be standard or infinite, not {c} -canvas must be standard or infinite, not {v} -case must be upper|lower|title|sentence -category `{c}`: one of {_1} -center must be [x, y] -choose appearance and/or size -clip must be true or false -clippingMask must be true or false -close current work before restoring -closed must be a boolean -color.profile: no RGB or CMYK profile is called “{profile}” -colorMode must be rgb or cmyk, not `{m}` -colorModel must be {own} (the document's mode), grayscale or bitmap -colour groups can't go into colour groups -colour, mode and spot apply to solid-colour swatches only -column {column}: `{cell}` is a `%` column of graph data files, which are not read -column {column}: `{cell}` names `{name}`, which is a {_1} variable -column {column}: `{cell}` names no variable -column {column}: the variable name is longer than {MAX_NAME} characters -command must be a shape.* command -commands[{i}]: missing `command` -commands[{i}]: unknown command `{id}` -composer must be "singleLine" or "everyLine" -copy needs path: the file to copy into the document's folder -copy takes images only: relink placed documents where their files are -copying a file isn't available on the web -copyrightStatus must be unknown, copyrighted or publicDomain -copyrightUrl must be a URL (no spaces, at most 2048 characters) -corners must be 4 [x, y] points (TL, TR, BR, BL) -counterPer `{c}`: item or file -crop `{_1}`: only PDF pages have art, trim, bleed and media boxes; use "crop" or "bounding" -crop rectangle doesn't overlap the image -cross-reference and object streams aren't supported -damaged compressed data: {e} -dash lengths must be numbers, got {v} -dash must be a list of lengths or null, got {v} -data key `{k}`: letters, digits, -, _ and . (not name or vc-…) -data must be an object of key: value, not {v} -data set `{name}` holds a value for `{var}`, which the library doesn't declare -data set `{name}` holds two values for `{var}` -data set `{name}` is declared twice -data takes at most {MAX_DATA} keys at once -dataset `{name}` already exists -dataset `{to}` already exists -date `{d}`: YYYY-MM-DD -decoded data is too large -define a variable first -detect must be true or false -direct-select anchor points of a path -distance must be > 0 -dx and dy give the arrow's direction -dx and dy must be numbers -dxf fitTo must be [width, height], positive -dxf must be an object of DXF import options -dxf scale must be a positive number up to {_1}, not {s} -dxf unit `{u}`: a unit such as mm, cm, in or pt -dxf: {e} -each format is an object {format, scale?, suffix?} -each item needs an id -each match needs `kind` -each match needs a `name` -each of `fonts` is {family, style?}; 1 to 1000 of them -each of `items` is {kind, item} -each of `items` is {{id, corners?}} with an object id, not {it} -each row needs `from` -each stop needs a position -each subpath needs an anchors array -easingType must be easeIn, easeOut, easeInOut or easeOutIn -editingData must be true or false -editingData must be true, false or "always" -edits overlap -empty first subpath -empty header in column {_1} -empty second subpath -empty subpath -encoding `{_1}`: utf8 or utf16 -ends must be two of "first" and "last" -expected {"dataBase64"} -expected {"objects"} -export `{name}`: {e} -exportText must be editable or appearance -exportType must be movieClip or graphic, not `{s}` -extend needs id -extension {e} is listed twice -extension {e}: use 1-16 lower-case letters and digits, without the dot -features must be a list of tags -fidelity must be 0..100 -files must be {name, dataBase64} -finish editing the pattern first -fit must be none|autoHeight|shrinkText, got {id} -flattener: {e} -flattenerPreset `{name}`: high, medium, low or a saved one (see flattener.presets.list) -flipProfile must be along|across, got {o} -folders `{f}`: none, rows, format, item or itemFormat -folders deeper than {MAX_BATCH_DEPTH} levels -font family `{family}` is not available -font family `{tf}` is not available -format `{f}`: pdf or postscript -format `{f}`: text or json -format is `{_1}` -formats must be an array of rows, not {v} -frequency must be between 1 and 300 lines per inch -from and to must be finite -from must be [x0, y0, x1, y1] -give `before` or `after`, not both -give `char` or `text` -give `colors`, or `color` and `rule` -give `event`, such as onclick -give `file`, the JavaScript file's path or URL -give `fill` and/or `stroke` -give `fill`, `stroke` or both -give `fonts`, or open a document -give `id` (no text is being edited) -give `id` or `guide`, not both -give `matches`: [{kind, name, target}] -give `name` (or edit a pattern) -give `name` or `names` -give `path` (a .wasm file) or `dataBase64` (the module) -give `path`, `data` or `dataBase64` -give `path`, `data`, `dataBase64` or `csv` -give `path`, `xml` or `dataBase64` -give `script`, the JavaScript the event runs ("" removes the event) -give `text` (the reading) or `remove` -give a mode or r/g/b, c/m/y/k, gray or tint adjustments -give a name, or select a stroke with a saved profile -give a pattern image id from links.patternImages -give a pattern name or select an object painted with a pattern -give at least one of `name`, `kind`, `text` -give at least one of clip, invert, disabled, linked -give color, none, swatch or gradient -give dataBase64 -give either at (a click) or area (a path drawn around the overlaps) -give formats or a preset, not both -give newName or delete -give offset or by -give one of `to`, `rect` or `at` -give one of assets, layers, selection or fullDocument -give origin: [x, y], or reset: true -give overprintFill, overprintStroke, showCenter, imageMap, url or note -give path (a file) or folder -give path, or name and dataBase64 -give paths or files -give rows, rowHeight, columns or columnWidth -give svg, or dataBase64 of an SVG or SVGZ file -give text -give the asset a name -give the preset a `name` -give the preset a name -give the preset a name (1–64 characters) -give the settings to change, e.g. {"formats"}]} -give the style a name -give two or more style `names` -give two or more swatches -give {key, value} or {values} -global must be true or false -gradient must be "objects" or "mesh" -grays must be "profile" or "black", not `{g}` -gridColors must be a preset name or two colours -gridSize must be small, medium or large -guide {g} is not selected -handle must be 0..3 -hiddenObjects {v}: "layer" (the default), "omit" or "visible" -highlight a layer to merge into -highlight two or more rows in one layer or group -highlight two or more rows to merge -id must be a dimension id -image has no bounds -image {_1} is linked already -imageMap `{m}`: none, client or server -index out of range -instances must be 1..1000 -instruction budget -invalid `color` -invalid anchor index {v}: expected a non-negative integer -invalid anchor reference {v}: expected [subpath, anchor] -invalid angle -invalid brush: {e} -invalid color -invalid colour {c} -invalid delta -invalid id {_1}: use 1-64 characters from A-Z a-z 0-9 . _ - -invalid object id {value}: expected a non-negative integer -invalid parameter name {k} -invalid point -invalid size -invalid t -invalid tolerance -its metadata has no description -its metadata has no direct length -join two different paths -joining different subpaths requires two open endpoints -joining must leave at least two anchors -justify must be auto|left|center|right|justifyAll -kbps must be a positive number, not {kbps} -kerning must be a number or "auto" -keywords must be a list of words or a comma-separated string -keywords must be at most {MAX_KEYWORD} characters, without control characters -kind must be 1, 2 or 3, or give a preset `name` (see perspective.presets.list) -kind must be corner or smooth -kind must be graphic, fillColor, strokeColor, charStyle or paraStyle -knockout must be "on", "off", "neutral" or a bool, not {v} -layer `{name}` has no art to export -layer must name a layer or sublayer -layers can't be intertwined: select the objects in them -layers go only at the top level or in layers, objects in layers and groups -layers must be true or false -layout must be gridByRow, gridByColumn, row or column -leading must be a number or "auto" -left/right must be non-negative widths -length must be greater than zero -level `{t}`: 2 or 3 -line {line}: `name` is longer than {MAX_NAME} characters -line {line}: dataset `{name}` appears twice -line {line}: more than {MAX_CSV_COLS} columns -line {line}: unclosed quote -line {line}: value for `{var}` must match its variable kind -lineEndings `{other}`: lf or crlf -loading profiles from disk isn't available in the browser -manifest is not UTF-8 -manifest is too long (limit {MAX_MANIFEST_BYTES} bytes) -manifest is {_1} bytes (limit {MAX_MANIFEST_BYTES}) -manifest: {e} -margin: a {m} pt margin leaves no imageable area on the paper -matrix must be finite and invertible, depth finite -matte: {e} -member is for order front or back -memory budget (the selection is too large to hand over) -missing `at` [x, y] -missing `attrs` -missing `color` or `opacity` -missing `colors` -missing `commands` -missing `effect` id -missing `family` -missing `find` -missing `group` -missing `id` -missing `id`: give one, or select one type object -missing `index` (a point index, or null to clear) -missing `index` (a stop index, or null to clear) -missing `index` (an item index or null) -missing `item` -missing `library` -missing `library` (see graphicStyle.libraries) -missing `library` (see swatch.library.list) -missing `map` (rows [{from, to}] or {current}) -missing `matrix` [a,b,c,d,e,f] -missing `mode` -missing `path` -missing `path` (a path object id) -missing `rule` -missing `source` id -missing `start` (the byte offset of a character) -missing `target` id -missing `text` -missing `to` -missing `to` (an index) -missing `type` -missing `value` -missing `{key}` -missing `{k}` -missing anchor -missing anchors -missing anchors [[subpath, anchor]…] -missing array `{key}` -missing artboard_index -missing case -missing color -missing color (null clears it) -missing commands -missing destination folder -missing first subpath -missing from -missing group -missing id -missing id or ids -missing ids -missing indexes -missing integer `{key}` -missing matrix [a,b,c,d,e,f] -missing name -missing op -missing or invalid `color` -missing or unknown `tool` -missing page -missing point -missing point [x,y] -missing point `{key}` ([x, y]) -missing points -missing points [[x,y]…] -missing segment -missing stops -missing subpaths -missing target -missing to -missing x, y -mode is set, add or toggle, not `{other}` -mode must be "cmyk" or "rgb" -mode must be area|onPath, got {o} -mode must be independent, aligned or mirrored -module is {_1} bytes (limit {_2}) -more than 10000 linked files: package dependencies in smaller parts -more than 256 commands -more than {MAX_BATCH_FILES} data sets -more than {MAX_BATCH_FILES} files -more than {MAX_CSV_ROWS} data rows -more than {MAX_IMPORT} presets -moved must be [[x,y]…] -need `anchor` or `segment` + `t` -need at least one anchor -need at least one point -need at least two points -needs points -nineSlice must be true or false -nineSliceGuides must be [left, top, right, bottom], each 0..1, with left + right < 1 and top + bottom < 1 -no PDF preset named `{n}` -no active data set -no appearance item {index} -no artboard {_1} -no artboard {artboard} (0-based; the document has {_1}) -no asset {id} -no asset {id} (see assets.list) -no assets to export -no assets yet: collect art for export first (assets.add) -no brush named `{n}` -no character at byte {start} -no colour -no colour group `{group}` -no colour group `{name}` -no data sets -no dataset named `{from}` -no dataset named `{name}` -no datasets -no design named `{name}` -no design named `{n}` -no design named `{old}` -no encoder for {_1} yet -no export preset `{name}` (see document.exportPresets.list) -no folder: pass `path` or set Preferences › Plug-ins & Scratch Disks › Additional Plug-ins Folder -no fonts to look for: they are all available -no formats to export -no graphic style `{n}` in `{_1}` -no graphic style library `{key}` (see graphicStyle.libraries) -no guide selected -no guide selected (or give an index) -no guide {i} -no item at index {_1} -no item at index {from} -no layer -no layer {id} (see layer.list) -no layers to export -no library `{key}` -no library `{key}` under Other Libraries (see graphicStyle.libraries, category "loaded") -no library `{key}` under Other Libraries (see swatch.library.list, category "loaded") -no metafile format -no object {_1} -no objects -no parent layer -no pattern image with that id -no perspective grid preset named `{b}` (see perspective.presets.list) -no perspective grid preset named `{n}` -no point selected (give `index`) -no preset called `{from}` (see file.newPresets) -no preset called `{name}` (see file.newPresets) -no preset named `{n}` -no print preset named `{b}` (see print.presets.list) -no print preset named `{name}` (presets: {_1}) -no print preset named `{n}` -no profile named `{name}` -no readable files in the folder -no recovery copy `{f}` (see file.recovery.list) -no rows -no saved PDF preset named `{name}` -no saved export preset `{name}` -no saved preset called `{name}` -no saved preset named `{name}` -no saved presets to export (name [Default] in `names` to export it) -no saved presets to export (name built-in ones in `names`) -no saved print preset named `{name}` -no saved profile called {name} -no saved selection `{name}` -no saved theme called {name} (see colorTheme.list) -no series {i} -no slice {n} in the image (see slice.list) -no source art -no stop {i} (the gradient has {n}) -no style `{name}` -no such assets (see assets.list) -no such frame anchor, or the frame would reach past the canvas -no such object id {value} -no such objects -no such rows -no swatch `{keep}` -no swatch `{n}` -no swatch `{n}` (swatches and colour groups move separately) -no swatch library `{key}` (see swatch.library.list) -no swatch library `{key}` (see swatch.library.list; "document": the document's swatches) -no swatch library `{key}` (see swatch.library.list; "{DOCUMENT_SWATCHES}": the document's swatches) -no swatch or colour group `{n}` -no swatch or colour group `{n}` in `{_1}` -no symbol named `{name}` -no text objects selected -no user library folder here (save with a path, or without one to get the data) -no variable named `{from}` -no variable named `{name}` -no variable named `{var}` -no variable named `{v}` -no view named `{name}` -node {_1} already has the text variable `{_2}` -node {_1} is not a graph -node {_1} is not an image -node {_1} is not type -not JSON: {e} -not a PDF presets file (format `{_1}`) -not a PDF presets file: {e} -not a WebAssembly binary (missing \0asm header) -not a Word document: its main part is not a WordprocessingML document -not a Word document: its package has no main document part -not a closed path -not a flattener presets file (format `{_1}`) -not a flattener presets file: {e} -not a gradient mesh or mesh envelope -not a layer or group -not a library file: {e} -not a mesh envelope point -not a perspective grid presets file (format `{_1}`) -not a perspective grid presets file: {e} -not a print presets file (format `{_1}`) -not a print presets file: {e} -not a valid SVGZ file: {e} -not an RTF file: it doesn't start with not an RTF file: it doesn't start with {\rtf -not an image VectorCraft reads (see document.formats) -not area type (text in a frame) outside perspective -not editing an opacity mask -nothing prints -nothing to bind -nothing to change -nothing to change: give dx, dy, hScale, vScale or rotation -nothing to crop -nothing to export -nothing to export: the document has no visible art -nothing to mark -nothing to print: every artboard picked is blank -nothing to print: the document has no art that prints -object is not a path -object transparency cannot be sampled onto highlighted characters; disable Transparency in Eyedropper Options or select the whole object -object {_1} has lost its file's contents: relink it -object {_1} has no size -object {_1} is not a placed document -object {_1} is not an Intertwine group -object {_1} is not an image -object {_1} is not an image or a linked file -object {_1} is not an image or a placed document -object {_1} is not in this Intertwine group -object {_1} needs a 3D effect at index {index} -object {id} has anchors 0–{_1} for `corners`, not {past} -objects must be none, move or copy -objects were left out -offset out of range -only an exporter declares `extensions` -only groups, layers and type have a Contents (Characters) row -only groups, layers, type and images have a Contents (Characters) row -only interior mesh lines can be deleted -only solid colours can be added (not gradients or patterns) -only solid-colour swatches can be spot colours -only this Affinity file's embedded preview could be read: use File › Open to see it with its warning, or export SVG or PDF from Affinity first -only this Affinity file's embedded preview could be read; use File › Open to see it with its warning -open a document to look for its linked files -options must be an object (see file.formatOptions) -order must be leftToRight or rightToLeft -orientation must be portrait or landscape, not `{o}` -outlineText {v}: true or false -output intent profile: {e} -output size ({what} is {len} bytes, limit {max}) -output.destination: no RGB or CMYK profile is called “{name}” -output.destination: {_1} files are CMYK, and “{name}” is an RGB profile -output.destination: {e} -output.inks: no ink is set to print -output.outputIntent: no profile is called “{name}”, and a {std} output intent embeds its profile -output.outputIntent: no profile is called “{name}”: a {std} output intent embeds a profile, or names a registered printing condition (outputConditionId and registry) -output.outputIntent: {std} files print in CMYK, and “{name}” is an RGB profile -output.outputIntent: “{name}” is a grey profile, which can't be a {std} output intent -overlap: {o} pt is half a tile or more -overlapping Shaper paths must be in the same layer or group -overlapping edits -page must be a whole number from 1 -page thumbnail: {e} -page {page} of the PDF has no objects -page {page}: `{file}` has {n} artboard(s) -page {page}: `{name}` has {n} page(s) -pages must be a range such as "2-3" or a page number, not {v} -pages: {e} -parallel line styles apply to shape strokes, not character strokes -parameter `{k}`: bad range -parameter `{k}`: choices must be 1-64 non-empty, printable strings -parameter `{k}`: default is not one of the options -parameter `{k}`: {what} -parameter `{name}`: {what} -parameters must be an object -params must be an object -pass newName, registration and/or exportType -pass newName, registration, exportType and/or nineSlice -passwords can have up to {MAX_PASSWORD} bytes -path has no open end -paths must be strings -pattern image pixels are missing -pattern placement traversal limit exceeded -pdfCompatible: false needs every artboard to keep the editable artwork -pickColorFrom is "artwork" or "swatches", not {v} -pins and moved must be non-empty lists of the same length -pins must be [[x,y]…] -pins must be finite -place is above, below or inside, not `{other}` -plane must be left|right|ground -plane must be left|right|ground|none -plug-in parameters must be a JSON object -point is outside the mesh -points must be [t, left, right] with non-negative widths -points must be a non-empty [[x,y,pressure?]…] list -points must be a non-empty list of finite [x,y] -points must be an array or null -points must be finite -points must be finite [x,y] pairs -points must contain 1–4096 coordinates -position must be normal, superscript or subscript -position must be topLeft, topRight, bottomLeft or bottomRight -positions must be between 0 and 100000 pt -ppi must be a positive number, not {ppi} -ppi must be between 1 and 2400 -preserve `{v}` is not transforms|bounds|fileDimensions|fit|fill -preserveShape must be clippingMask|transparency -preset `{id}`: mobile or density -preset is required -preset must be double|triple|road|none -preset must be standard, diffuse, topLeft or right -preset must be the name of a print preset -previewMode must be default, pixel or overprint -print settings: {e} -procedures nest too deeply -procedures nested too deeply -quotes must be {double?, single?} -quotes.{k} must be an opening and a closing quote, e.g. "“”" -rasterEffectsPpi must be 1–2400 (72 screen, 150 medium, 300 high) -rating must be a whole number from 0 to {MAX_RATING} -rect and {k} contradict each other: give the area or the artboards -rect applies to raster formats, not {_1} -rect is [x, y, width, height] in points -rect must be [x, y, width, height] -rect must be [x, y, width, height] with a positive width and height -rect needs finite numbers and a positive width and height -redraw needs id -redraw within must be a finite distance of 0 or more -registration must be 0..8 -release a layer or a group -replace keeps the replaced object's place and transform: drop template, at and rect -replace: select the one object to replace -replacement must be a PNG -resolution must be a number or screen|medium|high -resolution must be between 1 and 2400 ppi -rotation must be a number of degrees -rotation must be finite -rows and cols must be 1..50 -rows and columns must be 1..500 -save the document first: Package collects a saved document -save the document first: copy puts the file in its folder -scale `{s}`: a factor such as 2x, a width (100w), a height (100h) or a resolution (72ppi) -scale must be a number above 0, at most 64 -scale must be non-zero -scope must be selection or all (got {other}) -scribble is too long -segment does not exist -select a Shaper group -select a blend to set easing or color shift -select a graph -select a group or layer with visible, unlocked members -select a layer -select a path or compound path -select a text object -select an image (or an Image Trace group) -select an image, or pass id -select an object (or pass `id`) -select an object with a freeform gradient -select an object with an opacity mask -select area type -select area type (text in a frame) -select area type and closed paths (at least one area type object) -select artwork to add its colours -select artwork to make a group of its colours -select artwork to recolour, or give a colour `group` -select objects or give ids -select objects to align to the guide -select one Intertwine group -select one image or pass id -select paths -select paths or compound paths -select rows or objects to collect -select something to export -select something to export (or turn off selectedOnly) -select text to redefine the style from -select the art and, on top of it, the mask object -select the art for the design -select the art for the pattern tile -select the art to add first -select the art to collect (or give its ids) -select the content and a path on top -select the images to relink, or pass ids -select the images, or pass ids -select the linked images or placed documents, or pass ids -select the object to copy from -select the objects (or give their ids) -select the objects to attach to the path -select the objects to bind -select two or more objects to intertwine -select two or more slices to combine -select type on a path -selected anchor no longer exists -selected object is not a path -selection `{m}`: one or each -selection has no length to scale -settings is an object of document.exportForScreens params -settings must be an object -show `{show}`: all, missing, modified or embedded -show must be true or false -size must be "small", "medium", "large" or a number of points over 0 up to {_1} -size must be positive -smallCaps must be true or false -smooth must be true or false -sort `{x}`: name, kind or status -source paths are too large for Shaper -spot, mode and group apply to solid colours only -steps must be 1..1000 -steps must be 1..{MAX_STEPS} -stop editing the opacity mask first -strokeMode must be within|along|across, got {m} -strokeOptions must be an object of stroke.set options -strokeWidth must be a positive number of pixels -subLines contains an invalid lane -subLines is missing -subLines must be a list or null -subLines must contain at most {MAX_SUB_LINES} valid lanes -swatchConflict must be "merge" or "add", not {v} -text must be {characterSet?, platform?, removeLineReturns?, removeParagraphReturns?, replaceSpaces?} -text transform is collapsed or unrepresentable -text.replaceSpaces must be a whole number from 2 to 100 -text.{key} `{v}`: use "{_1}" or "{_2}" -textureImage: give {path} or {name, dataBase64} of an image file, or a texture as effect.list gives it -textureImage: {e} -that makes more than {MAX_PIECES} slices -that object is not in the overlap -the Affinity document has no page with a size -the RTF file has too many paragraphs or formatting changes to place as type (at most {MAX_PARAGRAPHS} paragraphs and {MAX_RUNS} runs) -the RTF file is too large to place as type (at most {_1} MB) -the SVG has no drawable objects -the SVGZ file unpacks to more than {_1} MB -the Word document has too much text or formatting to place as type (at most {MAX_PARAGRAPHS} paragraphs, {MAX_RUNS} runs and {_1} MB of text) -the active paint is not a gradient -the artboards don't fit on the canvas: fewer artboards, less spacing or a smaller size -the character's size can't be measured -the compound path has no path to put type on -the container of the listed objects is gone -the copy `{user_id}` can't be read as a library -the copy would leave the canvas -the data isn't an EMF or WMF picture -the data isn't an EPS or PostScript picture -the design is too big to draw at every data point of this graph -the design is too big to draw in every column of this graph -the dictionary stack overflowed -the editing data's hash has no place -the envelope's content has no bounds -the envelope's mesh is malformed -the file holds no `variableSets` element -the file is larger than {_1} MB -the file is too large -the file was updated or encrypted already -the first character of a line has no character before it to kern: it moves only up and down (dy) -the graphic can't be read: {e} -the graphic is empty -the graphic's data is damaged -the graphic's document has no artboard -the image has no embedded pixels -the image is empty -the image is fully transparent -the image is not on an artboard; pass rect -the image's pixels are missing -the image's pixels aren't in the document -the image's pixels can't be read -the image's pixels can't be read: {e} -the job would print more than {MAX_PAGES} pages -the joined anchor has no distinct neighbors to define a smooth direction -the layout moved -the library has a group `{name}` already -the library has no group `{name}` -the library has no item `{item}` -the library holds {MAX_GROUPS} groups already -the library holds {MAX_ITEMS} items of this kind already -the module imports `{_1}.{_2}`, but plug-ins get no host imports (no WASI, files or network) -the module must export its linear memory as `memory` -the name is empty -the object has no fill, stroke or effect -the object has no graphic style (pass `name`) -the object is hidden or locked -the object is outside the isolated group -the objects don't match the plan -the objects to change are no longer in the document -the open and permissions passwords must differ -the operand stack overflowed -the page has no area -the paint behind the proxy is not a gradient -the parent must be a layer or sublayer -the path can't be inside one of the objects to attach -the path is empty -the picture has no objects -the program runs too long -the program uses too much memory -the rectangle must have a width and a height -the rectangle needs a size and must lie on the canvas -the runtime panicked -the selected art has no width -the selection draws nothing -the selection has no area -the selection has no art to export -the selection has no bounds -the selection has no graphic style -the slice would leave the canvas -the text file is too large to place as type (at most {_1} MB) -the text is empty -the text is over {_1} MB -the top object must be a path -the trailer has no size -the type object is flattened to nothing -the value of axis `{tag}` must be a number or null -the variable library declares more than {MAX_LIBRARY_DATASETS} data sets -the variable library declares more than {MAX_LIBRARY_ENTITIES} entities -the variable library declares more than {MAX_LIBRARY_VARIABLES} variables -the variable library declares no variables -the variable library is larger than {MAX_LIBRARY_BYTES} bytes -the variable library is not UTF-8 -the variable library is not well-formed XML: {e} -the variable library nests elements more than {MAX_LIBRARY_NESTING} deep -the variable library's document type declaration holds something other than entities of plain text -the variable library's entities expand to more than {MAX_LIBRARY_BYTES} bytes -the written PDF has no catalog -the written PDF has no cross-reference table -the written PDF's metadata can't be edited -there is already a design named `{name}` -there is already a design named `{to}` -there is no library: create one first -these paths are too degenerate for Shaper -thumbnail: no page {n} (the PDF has {_1}) -thumbnail: no page {v} (the {what} has {count}) -tileRange of {what}: {e} -time budget -title can't be empty -to must be fill or stroke -to must be smooth or corner -too large for a zip archive: package to a folder -too many Shaper edits -too many dots for this size: lower the frequency -too many overlapping paths for Shaper -too many saved clips -too many saved graphics states -transparentImages {v}: "flatten" (the default) or "omit" -two saved selections would be named `{_1}` -type dimension cannot be represented with this text transform -type dimension must be finite -type objects can't be isolated -unexpected host trap -unexpected resources -unknown CMYK profile `{name}` -unknown File Info field `{k}` -unknown OpenType feature `{t}` (liga, calt, dlig, smcp, frac, onum, tnum, ordn, swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs; prefix - to turn off) -unknown PDF preset `{name}` (presets: {_1}) -unknown Warp style -unknown align `{other}` -unknown alignToPath `{a}` -unknown arrowhead {v} -unknown artboardLayout key `{k}` (layout, columns, spacing, rightToLeft) -unknown attribute `{k}` -unknown axis `{other}` (vertical|horizontal|free) -unknown blend mode `{b}` -unknown blend mode {b} -unknown brush type `{o}` -unknown category `{c}` -unknown category `{c}` ({_1}) -unknown character `{c}` -unknown effect `{e}` -unknown end {e} -unknown flattenerPreset `{name}` (High Resolution, Medium Resolution, Low Resolution or a saved one: see flattener.presets.list) -unknown format `{f}` (vcswatches, gpl, ase or css) -unknown from {f} -unknown graph type `{t}` -unknown grid colours `{name}` (one of {_1}) -unknown gridType `{t}` -unknown highlight `{v}` -unknown intent `{i}` -unknown knockout state {k} -unknown language `{l}` (one of {_1}) -unknown method `{m}` ({_1}) -unknown mode `{other}` (gray, rgb, hsb, lab, cmyk or web) -unknown mode `{other}` (mono|cmyk) -unknown mode `{other}` (rgb|cmyk|gray|global) -unknown mode `{other}` (set, add, subtract) -unknown mode `{o}` -unknown mode {m} -unknown mode {other} -unknown model `{other}` -unknown model `{other}` (keep, new or document) -unknown op {o} -unknown order `{o}`: toggle, front or back -unknown overprints `{v}` (preserve, simulate or discard) -unknown pieLegend `{v}` (none, standard or wedges) -unknown piePosition `{v}` (even, ratio or stacked) -unknown pieSort `{v}` (none, all or first) -unknown plate `{n}` -unknown preference `{k}` -unknown preset `{name}` (see imageTrace.presets) -unknown profile {id} (see stroke.widthProfile.list) -unknown proof target `{t}` -unknown quotes key `{k}` (double, single) -unknown resolution `{v}` -unknown rule `{rule}` ({_1}) -unknown rule `{r}` (see color.harmony) -unknown setting `{k}` -unknown shape `{v}` (circle|ellipse|square|diamond|line) -unknown spacing `{o}` (smooth|steps|distance) -unknown tileType `{t}` ({_1}) -unknown units -unknown units `{u}` -unknown value axis `{v}` (left, right or both) -unknown variation `{v}` (tintsShades|warmCool|vividMuted) -unknown warp style `{s}` -unknown {key} `{v}` (none, short or full) -unknown {key} profile `{n}` -unknown {k} key `{key}` (size, position) -unreadable object -unsupported stored image format -use .{ext} to export the stored samples without conversion -use paths or compound paths -useArtboards must be true or false, not {v} -value for `{var}` is longer than {MAX_GRAPH_CSV} bytes -value for `{var}` must be true or false -value for `{var}` must match its variable kind -variable `{name}` has no `trait` -variable `{name}` is already defined -variable `{name}` is declared twice -variable `{to}` already exists -vc_abi_version returned {v}; this host implements version {ABI_VERSION} -vc_alloc returned 0 (out of memory) -vc_alloc returned a block outside memory -vc_run returned error code {packed} -view is required -view must be one of {_1} -warpStyle can only edit an applied Warp effect -width factors must be finite -width must be positive -width out of range -width/height must be > 0 -width/height must be positive and within the canvas -x and y must be finite numbers -x and y must be given together -zoom must be positive -{DEFAULT_PRESET} is protected and stays -{DEFAULT_PRESET} is protected: save the settings under another name -{_1} MB of document, at most {_2} MB -{_1} MB of images, at most {_2} MB -{_1} bytes of RGBA data for a {width} × {height} image -{_1} can't be read, or changed since it was read: relink or update it first -{_1} can't go into {into} -{_1} files can't be opened yet -{_1} files can't be password-protected: remove the passwords or choose no standard -{_1} files can't be {_2} -{_1} files can't carry editing data: turn off preserveEditing -{_1} files can't have PDF layers: turn off createLayers -{_1} files need a creation date, and this platform has no clock to give one -{_1} is not a file -{_1} is {_2} bytes (limit {_3}) -{_1} isn't a path -{_1} objects (limit {MAX_OBJECTS}) -{_1} options: {e} -{_1} paints nothing to rasterize -{_1} passwords can have up to {MAX_LEGACY_PASSWORD} ASCII characters (PDF 1.7 and later take any) -{_1} with linked images or an image map writes several files: use document.export -{_1} writes one file per artboard here: use document.export, or name one artboard -{_1} × {_2} pixels is too large for {_3} (at most {max} pixels a side): lower the scale -{at}: {msg} -{doc} has no folder -{file} isn't linked -{folder}: {e} -{g} is not a Live Paint group -{id} is not a slice -{key} must be 0–{MAX_ADD_AROUND} pt -{key} must be Unix seconds (an integer) or null -{key} must be [x,y] -{key} must be a percentage {min}..100 -{key} must be a positive number, not {v} -{key} must be an array of non-negative integer object ids -{key} must be one of {_1} -{key} must be {_1}, got {s} -{kind} files have no transparency, and the file would have {what}: flatten it first (object.flattenTransparency) -{kind} images can't be opened (see document.formats) -{k} `{v}` is not one of the choices -{k} has control characters{_1} -{k} is too long (at most {MAX_TEXT} characters) -{k} must be > 0 -{k} must be a number (pt) or a length such as "210 mm" -{k} must be a number from {min} to {max} -{k} must be a number or "auto" -{k} must be a string -{k} must be text -{k} must be true or false -{k} must be {{size?, position?}} -{k}: can't read `{s}` as a length -{name} `{s}`: {ids} -{name} is built in and can't be deleted -{name} must be {lo}–{hi}{unit} (got {v}) -{name}: only this Affinity file's embedded preview could be read; use File › Open to see it with its warning, or export SVG or PDF from Affinity before placing -{name}: {e} -{native}; its embedded preview can't be read either ({e}) -{std} files are CMYK, and the file would have colours in {_1} -{std} files embed their fonts, and the file would have one that isn't -{target} is the file this document was read from, and reading it left things out ({what}): writing over it would lose them for good. Write another file instead, or pass acknowledgeLoss: true to replace it anyway -{ty} brushes need selected art (or params.{k}) -{v} is not an asset id -{what} can't be intertwined -{what} doesn't fit the paper with its bleed and marks -{what} points outside memory -{what} would print more than {MAX_COPIES_PER_SHEET} copies on a sheet: scale it up -{what} would print on more than {MAX_TILES} tiles: scale it down diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 7a7399d9e..f7a8bebdb 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1607,7 +1607,7 @@ file-filter Templates Mẫu Clear History Xóa lịch sử Set History Limit… Đặt giới hạn lịch sử… History States Trạng thái lịch sử -# History panel: the names of undo steps (`every_undo_label_is_translated`). +# History panel: the names of undo steps (`untranslated_report` lists any that are missing). Apply Data Set Áp dụng tập dữ liệu Area Type Chữ vùng Bloat Phình diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 23de0cc5a..4b249bc7a 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1328,7 +1328,7 @@ file-filter Templates 模板 Clear History 清除历史记录 Set History Limit… 设置历史记录上限… History States 历史记录步数 -# History panel: the names of undo steps (`every_undo_label_is_translated`). +# History panel: the names of undo steps (`untranslated_report` lists any that are missing). Apply Data Set 应用数据集 Bloat 膨胀 Cancel Pattern Editing 取消图案编辑 @@ -3595,7 +3595,7 @@ imageTrace Outline 轮廓 Use Default 使用默认值 # Status and error messages (`@msg`), translated where the status bar shows them; the message itself # stays English for agents and tests. `{_1}`, `{_2}` … stand for the `{}` of the format string, in order -# (`complete_languages_translate_every_message` lists any that are missing). Command ids, parameter +# (`untranslated_report` lists any that are missing). Command ids, parameter # names and values in backticks or code (`colorModel rgb`, `document.export`) stay as they are. # Simplified Chinese: no final 。 unless the English ends with a period; a space between Chinese text and # an adjacent Latin word, number or {placeholder}; 、 between the items of a list. diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 6eb95bb11..7613f6c54 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1360,7 +1360,7 @@ file-filter Templates 範本 Clear History 清除步驟記錄 Set History Limit… 設定步驟記錄上限… History States 步驟記錄狀態 -# History panel: the names of undo steps (`every_undo_label_is_translated`). +# History panel: the names of undo steps (`untranslated_report` lists any that are missing). Apply Data Set 套用資料集 Area Type 區域文字 Bloat 膨脹 @@ -1584,7 +1584,7 @@ file-filter Templates 範本 Keyboard Shortcuts… 鍵盤快速鍵… # Status and error messages (`@msg`), translated where the status bar shows them; the message itself # stays English for agents and tests. `{_1}`, `{_2}` … stand for the `{}` of the format string, in order -# (`complete_languages_translate_every_message` lists any that are missing). Command ids, parameter +# (`untranslated_report` lists any that are missing). Command ids, parameter # names and values in backticks or code (`colorModel rgb`, `document.export`) stay as they are. # Traditional Chinese: no final 。 unless the English ends with a period; a space between Chinese text and # an adjacent Latin word, number or {placeholder}; 、 between the items of a list; 「」 around the name of diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 4d18ba727..4f5042d77 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -4435,10 +4435,14 @@ mod tests { _ => panic!("{id} is not a menu item"), } }; - assert_eq!(shown(&app, "edit.undo"), "ぼかしを取り消す"); + let step = |template: &str, name: &str| { + let name = crate::panels::history::step_name(ja, name).unwrap_or_else(|| name.to_string()); + crate::i18n::fmt(crate::i18n::tr(ja, template), &[("name", &name)]) + }; + assert_eq!(shown(&app, "edit.undo"), step("Undo {name}", "Feather")); app.run("edit.undo", json!({})).unwrap(); - assert_eq!(shown(&app, "edit.redo"), "ぼかしをやり直す"); - assert_eq!(shown(&app, "edit.undo"), "長方形を取り消す", "a step with a plain row"); + assert_eq!(shown(&app, "edit.redo"), step("Redo {name}", "Feather")); + assert_eq!(shown(&app, "edit.undo"), step("Undo {name}", "Rectangle"), "a step with a plain row"); crate::i18n::set_current(crate::i18n::Lang::EN); assert_eq!(shown(&app, "edit.redo"), "Redo Feather"); } @@ -4543,10 +4547,15 @@ mod tests { let text = app.run("text.create", json!({"x": 0, "y": 40, "text": "Hello"})).unwrap()["id"].clone(); app.run("charStyle.new", json!({"name": "Brush"})).unwrap(); app.run("charStyle.apply", json!({"name": "Brush", "id": text})).unwrap(); - crate::i18n::set_current(crate::i18n::Lang::from_code("ja").expect("ja registered")); - assert_eq!(display_label(&app, "edit.undo", "Undo"), "スタイル「Brush」を適用を取り消す"); + let ja = crate::i18n::Lang::from_code("ja").expect("ja registered"); + crate::i18n::set_current(ja); + let step = |template: &str| { + let applied = crate::i18n::fmt(crate::i18n::tr(ja, "Apply {name}"), &[("name", "Brush")]); + crate::i18n::fmt(crate::i18n::tr(ja, template), &[("name", &applied)]) + }; + assert_eq!(display_label(&app, "edit.undo", "Undo"), step("Undo {name}")); app.run("edit.undo", json!({})).unwrap(); - assert_eq!(display_label(&app, "edit.redo", "Redo"), "スタイル「Brush」を適用をやり直す"); + assert_eq!(display_label(&app, "edit.redo", "Redo"), step("Redo {name}")); crate::i18n::set_current(crate::i18n::Lang::EN); assert_eq!(display_label(&app, "edit.redo", "Redo"), "Redo Apply Brush"); } diff --git a/crates/ui-egui/src/panels/appearance.rs b/crates/ui-egui/src/panels/appearance.rs index d02460957..8232bf8ec 100644 --- a/crates/ui-egui/src/panels/appearance.rs +++ b/crates/ui-egui/src/panels/appearance.rs @@ -1277,14 +1277,16 @@ mod tests { } /// Those choices read as their dialogs show them in the UI language: in French, the channel's - /// RGB is RVB, and in Spanish, Extrude & Bevel's Rounded is the "3d" row's Redondeado, not the - /// plain row's Redondeada. Rasterize names the document's color mode. + /// RGB as the "effect" row translates it, and in Spanish, Extrude & Bevel's Rounded as the "3d" + /// row does, not the plain row. Rasterize names the document's color mode. #[test] fn the_effect_editor_shows_those_choices_as_their_dialogs_do() { - crate::i18n::set_current(crate::i18n::Lang::from_code("fr").unwrap()); + let fr = crate::i18n::Lang::from_code("fr").unwrap(); + crate::i18n::set_current(fr); let (mut app, ctx, _) = editing("adjust.levels"); let texts = shown_texts(&mut app, &ctx); - assert!(texts.iter().any(|s| s == "RVB"), "{texts:?}"); + let rgb = crate::i18n::tr_ctx(fr, "effect", "RGB"); + assert!(texts.iter().any(|s| s == rgb), "{rgb}: {texts:?}"); let es = crate::i18n::Lang::from_code("es").unwrap(); crate::i18n::set_current(es); let (mut app, ctx, _) = editing("threeD.extrude"); diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs index 5488651a0..e7986dd5e 100644 --- a/crates/ui-egui/src/panels/character.rs +++ b/crates/ui-egui/src/panels/character.rs @@ -1080,11 +1080,13 @@ mod tests { let text = crate::tests_labels::painted_text(&mut app, |app, ui| axis_sliders(app, ui, &face)); assert!(text.contains("Weight\n") && text.contains("Width\n") && text.contains("400") && text.contains("100"), "{text}"); // The labels are the font's names in every interface language, not catalog words (ja - // translates the interface's Weight as 線幅, the stroke weight). - crate::i18n::set_current(crate::i18n::Lang::from_code("ja").unwrap()); + // translates the interface's Weight as the stroke weight). + let ja = crate::i18n::Lang::from_code("ja").unwrap(); + crate::i18n::set_current(ja); let text = crate::tests_labels::painted_text(&mut app, |app, ui| axis_sliders(app, ui, &face)); crate::i18n::set_current(crate::i18n::Lang::EN); - assert!(text.contains("Weight\n") && text.contains("Width\n") && !text.contains("線幅"), "{text}"); + let weight = crate::i18n::tr(ja, "Weight"); + assert!(text.contains("Weight\n") && text.contains("Width\n") && (weight == "Weight" || !text.contains(weight)), "{text}"); // Screen readers name each slider by its axis. let tree = crate::widgets::accessibility_tests::tree(|ui| axis_sliders(&mut app, ui, &face)); for name in ["Weight", "Width"] { diff --git a/crates/ui-egui/src/panels/history.rs b/crates/ui-egui/src/panels/history.rs index 73ed1b125..9339ad0f0 100644 --- a/crates/ui-egui/src/panels/history.rs +++ b/crates/ui-egui/src/panels/history.rs @@ -154,19 +154,21 @@ mod tests { let ja = crate::i18n::Lang::from_code("ja").expect("ja registered"); crate::i18n::set_current(ja); let rows = listed(&mut app); + let step = |template: &str, name: &str| crate::i18n::fmt(crate::i18n::tr(ja, template), &[("name", name)]); + let apply = |name: &str| step("Apply {name}", name); let want = [ - "グラフィックスタイル「S」を再定義", - "スタイル「Body」を適用", - "スタイル「{name}」を適用", - "スタイル「Apply」を適用", - "スタイル「Brush」を適用", - "スタイル「[標準文字スタイル]」を適用", - "スタイル「Graphic Style “X”」を再定義", + step("Redefine Graphic Style “{name}”", "S"), + apply("Body"), + apply("{name}"), + apply("Apply"), + apply("Brush"), + apply(crate::i18n::tr(ja, "[Normal Character Style]")), + step("Redefine {name}", "Graphic Style “X”"), ]; - assert!(rows.contains(&want[0].to_string()), "{rows:#?}"); + assert!(rows.contains(&want[0]), "{rows:#?}"); assert_eq!(rows[rows.len() - 6..], want[1..], "{rows:#?}"); app.session.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(listed(&mut app).last().map(String::as_str), Some(want[6]), "an undone step"); + assert_eq!(listed(&mut app).last(), Some(&want[6]), "an undone step"); crate::i18n::set_current(crate::i18n::Lang::EN); assert_eq!(listed(&mut app)[rows.len() - 6..rows.len() - 4], ["Apply Body", "Apply {name}"]); } @@ -176,8 +178,12 @@ mod tests { #[test] fn effect_steps_read_the_menu_row() { let ja = crate::i18n::Lang::from_code("ja").expect("ja registered"); - assert_eq!(step_name(ja, "Revolve").as_deref(), Some("回転体")); - assert_eq!(step_name(ja, "Feather").as_deref(), Some("ぼかし")); + for effect in ["Revolve", "Feather"] { + let menu_row = + crate::i18n::has(ja, &format!("{effect}…")).then(|| crate::i18n::tr(ja, &format!("{effect}…")).trim_end_matches('…').to_string()); + let plain_row = crate::i18n::has(ja, effect).then(|| crate::i18n::tr(ja, effect).to_string()); + assert_eq!(step_name(ja, effect), plain_row.or(menu_row), "{effect}"); + } assert_eq!(step_name(ja, "No Such Step"), None); } diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index fc3be3721..e634403cd 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -1794,7 +1794,6 @@ mod tests { let (stored, painted) = names(rect); let inner = stored.trim_start_matches('<').trim_end_matches('>'); assert_eq!(painted, format!("<{}>", crate::i18n::tr(zh, inner))); - assert_ne!(painted, stored, "translated"); } #[test] diff --git a/crates/ui-egui/src/tests_nativemenu.rs b/crates/ui-egui/src/tests_nativemenu.rs index 3d6f19b6e..a9d91e71a 100644 --- a/crates/ui-egui/src/tests_nativemenu.rs +++ b/crates/ui-egui/src/tests_nativemenu.rs @@ -147,7 +147,6 @@ fn language_and_appearance_are_in_the_app_menu() { assert_eq!(appearance.len(), crate::theme::Brightness::ALL.len()); assert_eq!(appearance.iter().filter(|(_, c)| *c == Some(true)).count(), 1, "{appearance:?}"); assert!(appearance.iter().all(|(_, c)| c.is_some())); - assert_eq!(crate::i18n::tr_ctx(Lang::from_code("ja").unwrap(), "theme", "Appearance"), "外観", "the theme, not the Appearance panel"); } /// Everything the in-window bar lists is in the Mac menu bar, once: what moved to the app menu diff --git a/docs/development.md b/docs/development.md index cd87ff67b..f931b2266 100644 --- a/docs/development.md +++ b/docs/development.md @@ -258,24 +258,30 @@ other `sv-*` locale resolve to it), Georgian (`ka`, complete; `ka-GE` and `ka_GE rule). The Language menu, the dropdown, locale matching and the catalog tests (well-formed, no duplicates, placeholders and ellipses agree, command ids exist, every row of a partial catalog is a string the UI shows, the UI fonts have every glyph, no Simplified characters in `zh-hant`) pick it up. Set `complete_menus` - once every menu string and `tl!` literal is translated; `i18n::tests` then enforce it - (`VECTORCRAFT_I18N_DUMP=strings.txt cargo test -p vectorcraft-ui-egui dump_source_strings` lists them). + once the menus and `tl!` literals are translated; the untranslated report then lists what the catalog lacks + (`VECTORCRAFT_I18N_DUMP=strings.txt cargo test -p vectorcraft-ui-egui dump_source_strings` lists every + English string). +- A missing translation never fails a test or a build: the interface shows the English text until a translator + adds the row. `cargo xtask i18n-report` prints, per language, the strings its catalog lacks (menu strings, + `tl!` literals, undo step names, menu labels, render-site labels, status and error messages, PostScript + error meanings, default swatch names), and `cargo xtask ci` prints the same list in its `i18n-report` step. + The report is the test `i18n::tests::untranslated_report`, run with `--nocapture`; it fails only when a + scan finds too few strings to be working. Tests don't check one language's exact wording: a test that needs a + translation compares with the catalog lookup (`tr(lang, "English")`). - Translations are clean-room: written from the meaning of the English text in ordinary vocabulary, never from another product's localisation resources. Product and technology names stay in Latin letters. - Status-bar messages and errors stay English where they are made (`app.ui.status`, `EngineError`: the 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). Czech, German, Greek, Spanish, French, Georgian, Hungarian, Italian, Japanese, Korean, Polish, Brazilian Portuguese, Russian, Simplified Chinese, Traditional Chinese, Swedish, Ukrainian, Vietnamese and Finnish cover every - message literal the test scan finds (`complete_languages_translate_every_message`, languages listed in - `COMPLETE_MESSAGES`), except the messages listed in `crates/ui-egui/src/i18n/untranslated_messages.txt`. - A new `Err("…")`, `Other(…)`, `#[error(…)]` or `status(…)` message needs a row in each of those catalogs, and so - does a new message given to an error enum's variant (`PsError::Limit(…)`), to the engine's `bad(cmd, …)`, `err(…)` or - `error(…)`, or to the PDF writer's `bad(…)` or `failed(…)` (`VECTORCRAFT_I18N_DUMP_MESSAGES=messages.txt cargo test -p vectorcraft-ui-egui - complete_languages_translate_every_message` lists them all). The list also holds the messages that open pull - requests add without those rows. A change that adds the rows for a listed message removes its line from the list - (`VECTORCRAFT_I18N_DUMP_UNTRANSLATED=` writes the messages in the sources that lack a row). Other languages - show messages in English until they add `@msg` rows. + placeholders are translated in turn (the reason after `: {e}` is often a message too). Czech, German, Greek, Spanish, French, Georgian, Hungarian, Italian, Japanese, Korean, Polish, Brazilian Portuguese, Russian, Simplified Chinese, Traditional Chinese, Swedish, Ukrainian, Vietnamese, Finnish and Turkish + translate status and error messages (`COMPLETE_MESSAGES` in `i18n::tests`); the untranslated report lists the + message literals the scan finds that one of them has no row for. The scan reads `Err("…")`, `Other(…)`, + `#[error(…)]` and `status(…)` messages, messages given to an error enum's variant (`PsError::Limit(…)`), to the + engine's `bad(cmd, …)`, `err(…)` or `error(…)`, and to the PDF writer's `bad(…)` or `failed(…)` + (`VECTORCRAFT_I18N_DUMP_MESSAGES=messages.txt cargo test -p vectorcraft-ui-egui dump_source_strings` lists them + all). A missing row shows the English message. Other languages show messages in English until they add `@msg` + rows. - Not translated on purpose: names that are user data (layers, swatches, fonts, documents), the tab title's colour mode, command ids and parameter names inside messages. Not done yet: locale-aware number and date formats, right-to-left layout. Chinese and Japanese UI text diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 301a88236..cb80b3419 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-16 · **Change:** minor (Georgian (`ka`) interface catalog; Bulgarian (`bg`) and Greek (`el`) catalogs complete under the tests, #1909, #2076; Finnish (`fi`) catalog added, complete under the tests; Swedish (`sv`) catalog added, complete under the tests; scores and estimates unchanged; Brazilian Portuguese (`pt-br`) catalog completed under the tests: menus, `tl!` literals and messages; 12-language score 71% → 74%; #1914) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** patch (missing translations never fail; `cargo xtask ci` prints the untranslated report) Per-language status of VectorCraft's interface. How the catalogs work and how to add a language: [`development.md` › Localisation](development.md#localisation). Part of @@ -8,18 +8,15 @@ Per-language status of VectorCraft's interface. How the catalogs work and how to **How it is measured:** rows with a translation in each `crates/ui-egui/src/i18n/.tsv`, counted on origin/main `01e165af`, except the rows that give a count date, which were counted on `main` at `cb0a7d9` (2026-10-21). -The reference is the French catalog, which the tests hold complete: 3,416 interface +The reference is the French catalog, which was complete when counted: 3,416 interface strings (menus, command labels, `tl!` literals: panels, dialogs, tooltips, Preferences) plus 604 `@msg` status and -error messages, 4,020 in all. A language's % is its rows over 4,020 (capped at 100% per part). The -`complete_menus` flag and `COMPLETE_MESSAGES` list make `i18n::tests` fail if a complete language misses a -string, so "full" here is enforced by tests. The messages listed in `crates/ui-egui/src/i18n/untranslated_messages.txt` -are not translated yet, and the tests let them through. No language has help content: there is no translated (or English) -in-app help, and the docs are English. -error messages, 4,020 in all. A language's % is its rows over 4,020 (capped at 100% per part). -`i18n::tests` fail if a language with the `complete_menus` flag misses a menu string or `tl!` literal, or if a -language in the `COMPLETE_MESSAGES` list misses a status or error message. Every non-English language marked -"full" has the flag, and all of them except Bulgarian are in the list. No language has help content: there is -no translated (or English) in-app help, and the docs are English. +error messages, 4,020 in all. A language's % is its rows over 4,020 (capped at 100% per part). A missing +translation never fails a test or a build: the interface shows the English text until a translator adds the row. +`cargo xtask ci` prints the untranslated report (`cargo xtask i18n-report` alone), which lists per language the +strings missing from each catalog with the `complete_menus` flag and the messages missing from each language in +the `COMPLETE_MESSAGES` list. Every non-English language marked "full" has the flag, and all of them except +Bulgarian, Danish, Indonesian and Dutch are in the list. No language has help content: there is no translated (or +English) in-app help, and the docs are English. **Native review:** every catalog was written clean-room from the English meaning, most of it by agents (`bflatastic`), with community contributors adding or correcting (German by `dwetscher`, Japanese and Traditional @@ -30,17 +27,17 @@ Chinese by `imachus`). Only Georgian is recorded as reviewed by a native speaker | Language | Code | UI strings translated | Dialogs / tooltips / messages | Script support | Native review | Status | To `full` (h) | |---|---|---|---|---|---|---|---| | English | `en` | source, 100% | all | Latin | — | full | — | -| Simplified Chinese (Mandarin) | `zh-hans` | 5,102 rows, 791 of them `@msg` (counted 2026-10-21): menus, `tl!` literals and messages complete (tests) | all | CJK UI font from craft-fonts (BIZ UDPGothic) when embedded, else a system font; IME with marked text at the caret; vertical type partial ([type-parity.md](type-parity.md)) | no | full | — (review) | +| Simplified Chinese (Mandarin) | `zh-hans` | 5,102 rows, 791 of them `@msg` (counted 2026-10-21): menus, `tl!` literals and messages complete when counted | all | CJK UI font from craft-fonts (BIZ UDPGothic) when embedded, else a system font; IME with marked text at the caret; vertical type partial ([type-parity.md](type-parity.md)) | no | full | — (review) | | Spanish | `es` | 4,009 / 4,020 (99.7%) | all (604 messages) | Latin | no | full | — (review) | | Hindi | `hi` | 0 | none | Devanagari: the document's text shapes (HarfRust), with tests of conjuncts, reph, pre-base matras, nukta forms and caret stops that run when an OpenType Devanagari font is installed, but the egui interface does no complex shaping | no | none | 20–30 | | Arabic | `ar` | 0 | none | document text: bidi layout and Paragraph Direction; interface: no right-to-left layout, no Arabic shaping in egui | no | none | 25–40 | | French | `fr` | 4,020 / 4,020 (100%) | all | Latin | no | full | — (review) | -| Portuguese | `pt-br` | 5,194 catalog entries (counted 2026-10-21): menus, `tl!` literals and messages complete (tests); one/other plurals | all | Latin | contributor is a Brazilian Portuguese speaker; further native review welcome | full | — (review) | -| Indonesian | `id` | 5,279 rows, 750 of them `@msg` (counted 2026-10-21): menus and `tl!` literals complete (tests); the tests do not hold its messages complete; one plural form | dialogs and tooltips all; 750 message rows (French has 796) | Latin | no | full | — (review) | +| Portuguese | `pt-br` | 5,194 catalog entries (counted 2026-10-21): menus, `tl!` literals and messages complete when counted; one/other plurals | all | Latin | contributor is a Brazilian Portuguese speaker; further native review welcome | full | — (review) | +| Indonesian | `id` | 5,279 rows, 750 of them `@msg` (counted 2026-10-21): menus and `tl!` literals complete when counted; its messages are partial; one plural form | dialogs and tooltips all; 750 message rows (French has 796) | Latin | no | full | — (review) | | Japanese | `ja` | 4,004 / 4,020 (99.6%) | all | CJK UI font as above; Japanese IME; vertical type partial; Japanese crop marks and composition settings | no | full | — (review) | -| German | `de` | 3,952 / 4,020 (98%; tests hold menus, literals and messages complete) | all (602 of 604 message rows; landed 2026-10-10) | Latin | contributor-written, not reviewed | full | — (review) | -| Korean | `ko` | 4,870 rows (counted 2026-10-21): menus, `tl!` literals and messages complete (tests); one plural form | all | Hangul from an installed system font on the desktop (Apple SD Gothic Neo, Malgun Gothic, Noto Sans CJK KR…); the web build has no Korean UI font yet (craft-fonts); IME path exists | no (AI-drafted) | full | — (review; web font) | -| Vietnamese | `vi` | 4,886 rows (counted 2026-10-21): menus, `tl!` literals and messages complete (tests); one plural form; 4,046 plain + 18 `@plural` + 28 contextual + 794 `@msg` | all | Latin with Vietnamese diacritics (precomposed forms; glyph tests pass) | contributor is a Vietnamese speaker; AI-assisted draft, further native review welcome | full | — (review) | +| German | `de` | 3,952 / 4,020 (98%; menus, literals and messages complete when counted) | all (602 of 604 message rows; landed 2026-10-10) | Latin | contributor-written, not reviewed | full | — (review) | +| Korean | `ko` | 4,870 rows (counted 2026-10-21): menus, `tl!` literals and messages complete when counted; one plural form | all | Hangul from an installed system font on the desktop (Apple SD Gothic Neo, Malgun Gothic, Noto Sans CJK KR…); the web build has no Korean UI font yet (craft-fonts); IME path exists | no (AI-drafted) | full | — (review; web font) | +| Vietnamese | `vi` | 4,886 rows (counted 2026-10-21): menus, `tl!` literals and messages complete when counted; one plural form; 4,046 plain + 18 `@plural` + 28 contextual + 794 `@msg` | all | Latin with Vietnamese diacritics (precomposed forms; glyph tests pass) | contributor is a Vietnamese speaker; AI-assisted draft, further native review welcome | full | — (review) | **12-language score: ~82%** (equal weights: 100 + 85 + 100 + 0 + 0 + 100 + 100 + 100 + 100 + 98 + 100 + 100, over 12). **To `full` for all twelve: ~50–85 h**, most of it Arabic (right-to-left interface) and Hindi (interface @@ -51,19 +48,19 @@ plus review. Native-speaker review is human work and isn't in the hours. | Language | Code | Translated | Status | |---|---|---|---| -| Traditional Chinese (Taiwan) | `zh-hant` | 4,892 rows, 790 of them `@msg` (counted 2026-10-21): menus, `tl!` literals and messages complete under the tests (80% overall) | partial | +| Traditional Chinese (Taiwan) | `zh-hant` | 4,892 rows, 790 of them `@msg` (counted 2026-10-21): menus, `tl!` literals and messages complete when counted (80% overall) | partial | | Italian | `it` | 4,011 / 4,020 | full | -| Dutch | `nl` | 5,317 rows, 777 of them `@msg` (counted 2026-10-21): menus and `tl!` literals complete (tests); the tests do not hold its messages complete; one/other plurals | full | -| Danish | `da` | 5,279 rows, 750 of them `@msg` (counted 2026-10-21): menus and `tl!` literals complete (tests); the tests do not hold its messages complete; one/other plurals | full | +| Dutch | `nl` | 5,317 rows, 777 of them `@msg` (counted 2026-10-21): menus and `tl!` literals complete when counted; its messages are partial; one/other plurals | full | +| Danish | `da` | 5,279 rows, 750 of them `@msg` (counted 2026-10-21): menus and `tl!` literals complete when counted; its messages are partial; one/other plurals | full | | Russian | `ru` | 4,009 / 4,020 | full | | Ukrainian | `uk` | 4,009 / 4,020 (one/few/many plurals) | full | -| Hungarian | `hu` | 5,253 rows (counted 2026-10-21): menus, `tl!` literals and messages complete (tests); one plural form | full | -| Polish | `pl` | 5,130 rows (counted 2026-10-21): menus, `tl!` literals and messages complete (tests); one/few/many plurals | full; native review pending | -| Finnish | `fi` | 5,318 rows (counted 2026-10-21): menus, `tl!` literals and messages complete (tests); one/other plurals, 797 `@msg` rows | full; AI-assisted, native proofreading pending | -| Czech | `cs` | 5,240 catalog entries, including 795 `@msg` rows (counted 2026-10-21); menu, literal and message coverage enforced by tests | catalog complete; render-site gaps reduced; native review pending | -| Swedish | `sv` | 5,251 rows, 795 of them `@msg` (counted 2026-10-21): menus, `tl!` literals and messages complete (tests); one/other plurals | full; native review welcome | -| Bulgarian | `bg` | 5,245 rows, 763 of them `@msg` (counted 2026-10-21): menus and `tl!` literals complete (tests); the tests do not hold its messages complete | full; AI-drafted, native review pending | -| Greek | `el` | 5,251 rows, 795 of them `@msg` (counted 2026-10-21): menus, `tl!` literals and messages complete (tests) | full; native review welcome | +| Hungarian | `hu` | 5,253 rows (counted 2026-10-21): menus, `tl!` literals and messages complete when counted; one plural form | full | +| Polish | `pl` | 5,130 rows (counted 2026-10-21): menus, `tl!` literals and messages complete when counted; one/few/many plurals | full; native review pending | +| Finnish | `fi` | 5,318 rows (counted 2026-10-21): menus, `tl!` literals and messages complete when counted; one/other plurals, 797 `@msg` rows | full; AI-assisted, native proofreading pending | +| Czech | `cs` | 5,240 catalog entries, including 795 `@msg` rows (counted 2026-10-21); menus, literals and messages complete when counted | catalog complete; render-site gaps reduced; native review pending | +| Swedish | `sv` | 5,251 rows, 795 of them `@msg` (counted 2026-10-21): menus, `tl!` literals and messages complete when counted; one/other plurals | full; native review welcome | +| Bulgarian | `bg` | 5,245 rows, 763 of them `@msg` (counted 2026-10-21): menus and `tl!` literals complete when counted; its messages are partial | full; AI-drafted, native review pending | +| Greek | `el` | 5,251 rows, 795 of them `@msg` (counted 2026-10-21): menus, `tl!` literals and messages complete when counted | full; native review welcome | | Georgian | `ka` | 5,200 rows (counted 2026-10-21): menus, panels, dialogs, plurals (one count-neutral form) and messages, tested complete; reviewed by a native speaker | full | Twenty-three non-English catalogs in all. Illustrator, as its public documentation lists, ships around two dozen @@ -82,7 +79,7 @@ contextual radial adjective. PDF/Print mark styles use `mark-style`, so `Japanes The search placeholder, Home screen, toolbar/panel tooltips and Preferences already use lookups. No new `tl!("literal")` keys were needed: only `cs.tsv` adds 19 AI-draft rows and revises the gradient template. Every other complete catalog (`complete_menus`) has the same rows and a `Roman` row for the -other mark style; `complete_catalogs_translate_render_site_labels` fails when one of them lacks a row. +other mark style; the untranslated report lists a catalog that lacks one of them. Native review is still needed, and catalog coverage does not prove that every variable in the UI is translated. @@ -95,6 +92,7 @@ font for the web build. | Date | Change | Summary | |---|---|---| +| 2026-10-23 | patch | A missing translation never fails a test or a build; one untranslated report, printed by `cargo xtask ci` and `cargo xtask i18n-report`, lists the missing strings per language; scores unchanged | | 2026-10-16 | minor | Bulgarian (`bg`, #1909) and Greek (`el`, #2076) catalogs added, complete under the tests; Swedish (#2139) and Finnish (#1994) rows for strings added since; eighteen non-English catalogs | | 2026-10-16 | minor | Finnish (`fi`) catalog added, complete under the tests (menus, literals, messages; one/other plurals); AI-assisted, native proofreading pending | | 2026-10-15 | minor | Swedish (`sv`) catalog added, complete under the tests (menus, literals, messages; one/other plurals); every `sv-*` locale resolves to it; AI-drafted, native review welcome; scores and estimates unchanged | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 104471b8f..d4695265c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -549,10 +549,10 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Panels undock (#495): a dock tab, a panel icon or a popped-out panel's title dragged out of the dock floats that panel inside the window, and the strip right of the dock's tabs floats the whole tabbed group. A floating group moves by its title bar, a tab dragged out of it floats on its own, and dropped on another group's title bar or tabs it stacks with that group; dropped on a dock group's tabs, which light up under the pointer, its panels join that group, and on the group's upper or lower half they make a new group above or below it (`window.panel.dock {anchor, zone}`); dropped on the icon column they become icons there, the tabbed group's panels too (`window.panel.dock {icon: true}`); dropped anywhere else on the dock, which lights up, they go back where they live (the tabbed group or the icon column), as its × and the panel menus' Float Panel / Dock Panel do (`window.panel.float`, `window.panel.dock`). The Tools panel floats by its title bar and docks again on the window's left edge. Floating groups stack into sets: dropped on the upper or lower half of another group (a line shows where), a group joins it right above or below it, and the set's groups stack top to bottom, each showing one panel, moving together by the top group's title bar, whose × docks them all; a group dragged by its tab strip leaves the set (`window.panel.float {below, above}`). A double-click on a tab collapses a floating group to its tabs (`collapsed`), and a dock group too, a group below it moving up into the room; a second double-click, or a click on another of its tabs, expands it (`window.panel.collapse`, kept with the preferences and in saved workspaces). Sets dropped on the left edge of the icon column or of another dock column dock as dock columns (a multi-column dock, `window.panel.dock {column}`), whose groups stack, tab and tear out as a floating set's do, and whose top strip floats the column again. Floating panels, their sets and the Tools panel's position are kept with the preferences and in user workspaces, and clamped into a smaller window. Not done: panels in separate OS windows, reordering tabs by dragging, collapsing a set or dock column to icons, dock columns right of the default dock, resizing floating panels and the groups of a set. - The dock's double arrow collapses Properties, Layers and Libraries to icons at the top of the icon column, popping out like the other icon panels, and expands them again (`window.collapseDock`, kept with the preferences and in saved workspaces). The icon column's own arrow (expanding the icon panels into a full column) is not done. - Czech render-site labels: desktop/web file filters, diagnostics, suggested preset names and contributor tooltips/plurals now use catalog lookups; gradients have Czech adjective agreement and printer marks have a separate context (PDF/Print and Summary). Search, Home, toolbar/panel tooltips and Preferences already look up their labels. Every complete catalog has rows for these labels and for the Roman and Japanese printer mark styles. Evidence: `i18n::tests::czech_render_site_follow_up`, `i18n::tests::complete_catalogs_translate_render_site_labels`, `dialogs::tests_modal::printer_mark_labels_are_distinct_from_language_names`; see [localization-parity.md](localization-parity.md). - - The History panel, Edit › Undo and Edit › Redo translate the names of the steps that apply or redefine a character or paragraph style and of the step that redefines a graphic style: each such step keeps its template (`Apply {name}`, `Redefine {name}`, `Redefine Graphic Style “{name}”`) and the style's name, every complete catalog has a row for each template, and the panel and the menu show the style's name as the document holds it, except the Normal styles' names, which they show in the interface language (`steps_named_after_a_style_list_the_template_around_the_name`, `undo_and_redo_name_a_style_step_around_the_style_name`, `every_undo_label_is_translated`). + - The History panel, Edit › Undo and Edit › Redo translate the names of the steps that apply or redefine a character or paragraph style and of the step that redefines a graphic style: each such step keeps its template (`Apply {name}`, `Redefine {name}`, `Redefine Graphic Style “{name}”`) and the style's name, every complete catalog has a row for each template, and the panel and the menu show the style's name as the document holds it, except the Normal styles' names, which they show in the interface language (`steps_named_after_a_style_list_the_template_around_the_name`, `undo_and_redo_name_a_style_step_around_the_style_name`). - Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan), Simplified Chinese (mainland), Japanese, Korean, Vietnamese, German, Spanish, French, Italian, Polish, Brazilian Portuguese, Russian, Ukrainian and Georgian ship complete, and Czech covers every menu label (and more), with system-locale detection on macOS, Windows, Linux and the web (the browser's languages, or `?lang=`), a Language preference and, on macOS, a VectorCraft ▸ Language menu. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins). - Edit › Undo and Edit › Redo, in the in-window and native menus, name the step in the interface language as the History panel lists it (`panels::history::step_name`), so when the catalog has no plain row for a step named after an effect, such as Feather, the menu shows the translation of the effect's menu row without its ellipsis (`undo_and_redo_name_the_step_as_the_history_panel_does`). - - The History panel translates the undo step names the engine, the tools and the shell write as literals: each has a row in every complete catalog (`every_undo_label_is_translated`), and a step whose catalog row exists only with an ellipsis (such as an effect's menu name) shows that row without the ellipsis. + - The History panel translates the undo step names the engine, the tools and the shell write as literals: the untranslated report (`untranslated_report`) lists any a complete catalog lacks, and a step whose catalog row exists only with an ellipsis (such as an effect's menu name) shows that row without the ellipsis. - The History panel menu has Step Backward and Step Forward, which run Edit › Undo and Edit › Redo and are disabled when there is no step to undo or redo, and Set History Limit…, which opens Preferences at Performance, where History States is set. Not done: a new document from the selected state. - The History panel menu's Clear History (`edit.clearHistory`) removes every step to undo and to redo and leaves the document as it is; it is not an undo step itself, and it is disabled when there is no step to clear. - Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool. Native menus explicitly end tracking on command selection, outside window presses and Escape; menu updates wait until tracking ends, and a dismissing press never starts a canvas gesture (M14.8). diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 04486ff2f..3ce5c5afb 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -27,6 +27,8 @@ commands: assets check that every icon/image/font/asset is attributed in ASSETS.md brands check that user-visible text (labels, params docs, MCP, docs, packaging) names no other vendor's products cursors warn about Cursor values with no entry in crates/tools/src/cursors.toml (never fails) + i18n-report list the strings each language's catalog lacks, shown in English (fails only on a + broken scan; ci runs it too) pdf-deps verify immutable shared PDF writer and patched Krilla sources layers enforce the crate dependency layering (plan/architecture.md §3) wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates and the web app @@ -49,6 +51,7 @@ fn main() -> ExitCode { Some("assets") => assets::run(&root()), Some("brands") => brands::run(&root()), Some("cursors") => cursors::run(&root()), + Some("i18n-report") => cmd_i18n_report(), Some("layers") => cmd_layers(), Some("pdf-deps") => pdf_deps::run(&root()), Some("wasm") => cmd_wasm(), @@ -271,6 +274,7 @@ fn cmd_ci() -> Result<(), String> { ("assets", Box::new(|| assets::run(&root()))), ("brands", Box::new(|| brands::run(&root()))), ("cursors", Box::new(|| cursors::run(&root()))), + ("i18n-report", Box::new(cmd_i18n_report)), ("layers", Box::new(cmd_layers)), ("wasm", Box::new(cmd_wasm)), ]; @@ -317,3 +321,11 @@ Tests that use a corpus skip cleanly when it is absent. } pngsuite::fetch() } + +/// Prints the strings each language's catalog has no row for (`i18n::tests::untranslated_report`). +/// The interface shows them in English, so a gap never fails it; only a scan that finds nothing does. +fn cmd_i18n_report() -> Result<(), String> { + let mut c = cargo(); + c.args(["test", "-p", "vectorcraft-ui-egui", "--lib", "--", "i18n::tests::untranslated_report", "--exact", "--nocapture"]); + run(c, "cargo test -p vectorcraft-ui-egui --lib -- i18n::tests::untranslated_report --exact --nocapture") +}