From 8d99a5ce1f909c1ffad9e2a866d62644be0d00cf Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:42:18 -0400 Subject: [PATCH] Translations: a missing string never fails a test; xtask ci prints the gaps (#4302) Delete the translation coverage tests and the tests of one language's wording. Keep catalog validation (format, duplicates, placeholders, plural forms, command ids) and the tests that use a language (registration, plural rules, RTL order, glyphs, casing, font fallback). cargo xtask i18n-report runs i18n::tests::translation_gaps_report, which prints each language's missing count and first few keys and always passes; xtask ci runs it after the tests. --- crates/ui-egui/src/docking.rs | 8 - crates/ui-egui/src/i18n/mod.rs | 2612 ++-------------------------- crates/ui-egui/src/motion.rs | 16 - crates/ui-egui/src/object_ui.rs | 25 - crates/ui-egui/src/scroll_speed.rs | 3 - crates/ui-egui/src/sign_ui.rs | 9 +- docs/architecture.md | 6 +- docs/localization.md | 13 +- parity/acrobat-features.toml | 2 +- xtask/src/gates.rs | 31 + xtask/src/main.rs | 3 +- 11 files changed, 190 insertions(+), 2538 deletions(-) diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs index 453170904..11eb732be 100644 --- a/crates/ui-egui/src/docking.rs +++ b/crates/ui-egui/src/docking.rs @@ -459,14 +459,6 @@ fn panel_limits(panel: &Panel) -> PanelLimits { mod tests { use super::*; - #[test] - fn lithuanian_translates_every_dynamic_panel_title() { - let lang = crate::i18n::Lang::from_code("lt").expect("Lithuanian is registered"); - for panel in Panel::ALL { - assert_ne!(crate::i18n::tr(lang, panel.label()), panel.label(), "{} remains untranslated", panel.id()); - } - } - #[test] fn tools_side_moves_to_both_edges_and_survives_closed_restore() { let mut workspace = Workspace::default(); diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index dff8efc3d..8f17a8aca 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -686,19 +686,19 @@ mod tests { use super::*; const JA: fn() -> Lang = || Lang::from_code("ja").expect("ja registered"); - // These physical key-cap names remain English in several keyboard layouts. Only their - // dedicated key context may use them unchanged; ordinary command labels still translate. - const KEY_CAP_NAMES: &[&str] = &["Ctrl", "Shift", "Delete", "Esc", "Home", "End"]; #[test] fn keyboard_key_names_have_a_separate_context_and_english_fallback() { - for lang in Lang::all().filter(|l| *l != Lang::EN) { - for key in ["Ctrl", "Shift", "Delete", "Esc", "Home", "End", "Space"] { - assert!(lang.catalog().contextual("key", key).is_some(), "{}: {key}", lang.code()); - } - } + static KEYS: LangInfo = LangInfo { + code: "keys", + name: "Key name test catalog", + source: "key\tDelete\tEntf\n\tDelete\tLöschen\n\tOpen\tÖffnen\n", + plural: plural_one_other, + rtl: false, + catalog: OnceLock::new(), + }; let previous = current(); - set_current(Lang::from_code("de").unwrap()); + set_current(Lang(&KEYS)); assert_eq!(key_name("Delete"), "Entf"); assert_eq!(t("Delete"), "Löschen"); assert_eq!(key_name("Open"), "Open", "a missing key name must not borrow a command translation"); @@ -936,89 +936,9 @@ mod tests { assert_eq!(fmt("unchanged", &[]), "unchanged"); } - /// #103: the missing-OCR-models messages are translated under their current English text - /// (catalogs once keyed an older `PRINTCRAFT_MODELS` wording, which never matched). - #[test] - fn missing_ocr_models_messages_are_translated() { - let error = pdfcraft_engine::ocr::OcrError::NoModels.to_string(); - let notice = "Text recognition isn't installed: its model files are missing. Reinstall PdfCraft, or set PDFCRAFT_MODELS to the folder that holds them."; - assert!(include_str!("../ocr_ui.rs").contains(notice), "keep in step with ocr_ui.rs"); - for code in ["bg", "cs", "de", "es", "fr", "hu", "ja", "ru", "te", "uk", "zh-hans", "zh-hant"] { - let lang = Lang::from_code(code).expect("registered"); - assert!(has(lang, notice) && has(lang, &error), "{code}"); - } - for lang in &LANGUAGES { - assert!(!lang.source.contains("PRINTCRAFT"), "{}", lang.code); - } - } - - /// Japanese translates every registered command and every All tools group, section and item. - #[test] - fn japanese_covers_commands_and_catalogue() { - for command in pdfcraft_engine::commands::COMMANDS { - assert!(has(JA(), command.label), "missing command: {}", command.label); - } - for group in pdfcraft_engine::catalog::TOOL_GROUPS { - assert!(has(JA(), group.label), "missing group: {}", group.label); - for section in group.sections { - assert!(has(JA(), section.title), "missing section: {}", section.title); - for item in section.items { - assert!(has(JA(), item.label), "missing item: {}", item.label); - } - } - } - } - - /// Every `tl!("…")` literal in the UI source (test modules aside), unescaped. - /// Every label Shared Field Properties shows, including those it passes through a variable - /// (flag labels, errors, the font label), exists in each full catalog. - #[test] - fn bulk_field_properties_labels_are_in_every_full_catalog() { - let source = include_str!("../bulk_fields.rs"); - // The string literal starting right after `at`, unescaped. - fn literal(rest: &str) -> Option { - let mut out = String::new(); - let mut chars = rest.chars(); - while let Some(c) = chars.next() { - match c { - '"' => return Some(out), - '\\' => out.push(chars.next()?), - c => out.push(c), - } - } - None - } - let mut labels = std::collections::BTreeSet::new(); - for marker in ["tl!(\"", "Err(\"", "ok_or(\"", "|| \"", "MISSING: &str = \"", "i18n::t(\"", "_ => \""] { - for (at, _) in source.match_indices(marker) { - labels.extend(source.get(at + marker.len()..).and_then(literal)); - } - } - // Flag labels: `(field_flags::NAME, "Label", inverted)`. - for (at, _) in source.match_indices("(field_flags::") { - let line = source.get(at..).and_then(|s| s.lines().next()).unwrap_or_default(); - if let Some(q) = line.find(", \"") { - labels.extend(line.get(q + 3..).and_then(literal)); - } - } - labels.retain(|l| l.chars().any(char::is_alphabetic) && !l.contains("credits") && !l.starts_with("bulk")); - for label in ["Mixed", "Custom font", "The document is no longer open."] { - labels.insert(label.to_string()); - } - assert!(labels.len() > 20 && labels.contains("Comb of characters"), "the scan found the dialog's labels: {labels:?}"); - for code in ["bg", "cs", "de", "es", "fr", "hu", "it", "ja", "pt-br", "ru", "te", "tr", "uk", "zh-hans", "zh-hant"] { - let lang = Lang::from_code(code).expect("registered"); - let missing: Vec<_> = labels.iter().filter(|l| !has(lang, l)).collect(); - assert!(missing.is_empty(), "{code} lacks Shared Field Properties labels: {missing:#?}"); - } - } - + /// Every literal passed to `tl!(…)` or `crate::i18n::t(…)` in the UI source (test modules + /// aside), unescaped, including literals on a line of their own. fn ui_literals() -> std::collections::BTreeSet { - scan_ui_literals(false) - } - - // Lithuanian also checks direct lookup calls and multiline literal arguments. - fn scan_ui_literals(expanded: bool) -> std::collections::BTreeSet { let mut stack = vec![std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src")]; let mut literals = std::collections::BTreeSet::new(); while let Some(dir) = stack.pop() { @@ -1030,18 +950,13 @@ mod tests { } } else if path.extension().is_some_and(|ext| ext == "rs") { let source = std::fs::read_to_string(path).expect("UI source file").replace("\r\n", "\n"); - let source = if expanded { source.replace("crate::i18n::t(", "tl!(") } else { source }; + let source = source.replace("crate::i18n::t(", "tl!("); let mut rest = source.split("#[cfg(test)]\nmod ").next().unwrap_or_default(); - while let Some((_, after)) = rest.split_once(if expanded { "tl!(" } else { "tl!(\"" }) { - let after = if expanded { - let after = after.trim_start(); - let Some(after) = after.strip_prefix('"') else { - rest = after; - continue; - }; - after - } else { - after + while let Some((_, after)) = rest.split_once("tl!(") { + let after = after.trim_start(); + let Some(after) = after.strip_prefix('"') else { + rest = after; + continue; }; let mut escaped = false; let end = after @@ -1058,7 +973,7 @@ mod tests { let argument_end = tail.strip_prefix('"').expect("closing quote").trim_start(); let literal_call = argument_end.starts_with(')') || argument_end.strip_prefix(',').is_some_and(|rest| rest.trim_start().starts_with(')')); - if tail.starts_with("\")") || (expanded && literal_call) { + if literal_call { let label: String = serde_json::from_str(&format!("\"{raw}\"")).expect("UI literal escapes"); literals.insert(label); } @@ -1070,50 +985,14 @@ mod tests { literals } - /// Partial catalogs also translate every label of the absolute local GoToR consent prompt. + /// Each translated label of a visible signature that runs into its value keeps a separator + /// at its end. #[test] - fn local_gotor_confirmation_is_translated_in_all_supported_languages() { - for language in LANGUAGES.iter().filter(|language| !language.source.is_empty()) { - let lang = Lang::from_code(language.code).expect("registered language"); - for label in [ - "Open this local PDF?", - "This document is trying to open a local file:", - "Only allow this if you trust the source document.", - "Allow", - "Cancel", - ] { - assert!(has(lang, label), "{} lacks GoToR confirmation label: {label}", language.code); - assert_ne!(tr(lang, label), label, "{} falls back to English for {label}", language.code); - } - } - } - - /// Both page-gap labels must be translated, even in catalogs without full UI coverage. - #[test] - fn page_gap_labels_are_translated_in_every_catalog() { - for language in LANGUAGES.iter().filter(|language| !language.source.is_empty()) { - let lang = Lang::from_code(language.code).expect("registered language"); - for label in ["Show gaps between pages", "Hide gaps to join page edges. Margins inside the PDF remain unchanged."] { - assert!(has(lang, label), "{} lacks {label:?}", language.code); - let translated = tr(lang, label); - assert!(!translated.trim().is_empty(), "{} has an empty translation for {label:?}", language.code); - assert_ne!(translated, label, "{} falls back to English for {label:?}", language.code); - } - } - } - - /// Every catalog says the labels of a visible signature, which are looked up by the sign - /// crate's own English words (so the `tl!` scan can't see them), and the note shown when they - /// fall back to English. Each label that runs into its value keeps a separator at its end. - #[test] - fn signature_appearance_labels_are_in_every_catalog() { + fn signature_appearance_labels_keep_their_separator() { let english = pdfcraft_engine::sign::AppearanceLabels::default(); - let note = "The labels are in English: the signature's font can't draw them in this language."; for language in LANGUAGES.iter().filter(|language| !language.source.is_empty()) { let lang = Lang::from_code(language.code).expect("registered language"); - assert!(has(lang, note), "{} lacks the labels note", language.code); for (field, label) in english.fields() { - assert!(has(lang, label), "{} lacks the signature label {field}: {label:?}", language.code); let said = tr(lang, label); if field != "signed_by" && !lang.rtl() { assert!(said.ends_with(' ') || said.ends_with(':'), "{}: the {field} label runs into its value: {said:?}", language.code); @@ -1122,67 +1001,6 @@ mod tests { } } - /// Polish translates every `tl!("…")` literal in the UI source, as Japanese does. - #[test] - fn polish_covers_ui_literals() { - let pl = Lang::from_code("pl").expect("pl registered"); - let missing: Vec<_> = ui_literals().into_iter().filter(|label| !has(pl, label)).collect(); - assert!(missing.is_empty(), "untranslated Polish UI literals: {missing:#?}"); - } - - /// Polish says everything a signature's validation says, and the changes after signing it - /// names, keeping each sentence's holes: a Polish signature panel listed these in English. - #[test] - fn polish_says_what_a_signature_says() { - let pl = Lang::from_code("pl").expect("pl registered"); - for wording in pdfcraft_engine::sign::pdf::WORDINGS.iter().chain(pdfcraft_engine::sign::pdf::kind::ALL) { - assert!(has(pl, wording), "missing signature wording: {wording}"); - let said = tr(pl, wording); - assert_eq!(wording.matches("{}").count(), said.matches("{}").count(), "the Polish of {wording:?} loses a hole: {said:?}"); - } - } - - /// Japanese translates every `tl!("…")` literal in the UI source, so a new string can't ship in - /// English by accident (the same check French, German, Russian and Simplified Chinese have). - #[test] - fn japanese_covers_ui_literals() { - let literals = ui_literals(); - assert!(literals.len() > 900, "source scan found only {} literals", literals.len()); - let missing: Vec<_> = literals.iter().filter(|label| !has(JA(), label)).collect(); - assert!(missing.is_empty(), "untranslated Japanese UI literals: {missing:#?}"); - } - - /// Italian translates every registered command and every All tools group, section and item. - #[test] - fn italian_covers_commands_and_catalogue() { - let it = Lang::from_code("it").expect("it registered"); - for command in pdfcraft_engine::commands::COMMANDS { - assert!(has(it, command.label), "missing command: {}", command.label); - if let Some(menu) = command.menu { - assert!(has(it, menu), "missing menu: {menu}"); - } - } - for group in pdfcraft_engine::catalog::TOOL_GROUPS { - assert!(has(it, group.label), "missing group: {}", group.label); - for section in group.sections { - assert!(has(it, section.title), "missing section: {}", section.title); - for item in section.items { - assert!(has(it, item.label), "missing item: {}", item.label); - } - } - } - } - - /// New tl!("literal") labels must not silently fall back to English in Italian. - #[test] - fn italian_covers_ui_literals() { - let it = Lang::from_code("it").expect("it registered"); - let literals = ui_literals(); - assert!(literals.len() > 900, "source scan found only {} literals", literals.len()); - let missing: Vec<_> = literals.iter().filter(|label| !has(it, label)).collect(); - assert!(missing.is_empty(), "untranslated Italian UI literals: {missing:#?}"); - } - /// With craft-fonts, every Japanese translation has glyphs: with all interface faces (desktop) /// and with BIZ UDPGothic Regular alone (the web build's only Japanese face). #[test] @@ -1233,8 +1051,8 @@ mod tests { 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"); - // No `@plural` entry yet: Japanese falls back to the English forms. - assert_eq!(trn(JA(), 7, "{n} item", "{n} items"), "7 items"); + // No catalog has this `@plural` entry: Japanese falls back to the English forms. + assert_eq!(trn(JA(), 7, "{n} test item", "{n} test items"), "7 test items"); 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"]); @@ -1270,8 +1088,6 @@ mod tests { assert_eq!(normalize_pref("pt"), None, "only exact codes are preferences"); assert_eq!(lang_from_tag("pt_BR.UTF-8"), Some(pt)); assert_eq!(lang_from_tag("pt_PT"), Some(pt)); - assert_eq!(tr(pt, "File"), "Arquivo"); - assert_eq!(tr(pt, "Save as…"), "Salvar como…"); assert_eq!(tr(pt, "Arquivo do usuário.pdf"), "Arquivo do usuário.pdf"); assert_eq!((0..=3).map(|n| (pt.0.plural)(n)).collect::>(), [0, 0, 1, 1]); let mut app = crate::PdfCraftApp::default(); @@ -1282,100 +1098,6 @@ mod tests { assert_eq!(restored.language, "pt-br"); } - /// Brazilian Portuguese translates every registered command and every All tools group, section and item. - #[test] - fn brazilian_portuguese_covers_commands_and_catalogue() { - let pt = Lang::from_code("pt-br").expect("pt-br registered"); - for command in pdfcraft_engine::commands::COMMANDS { - assert!(has(pt, command.label), "missing command: {}", command.label); - if let Some(menu) = command.menu { - assert!(has(pt, menu), "missing menu: {menu}"); - } - } - for group in pdfcraft_engine::catalog::TOOL_GROUPS { - assert!(has(pt, group.label), "missing group: {}", group.label); - for section in group.sections { - assert!(has(pt, section.title), "missing section: {}", section.title); - for item in section.items { - assert!(has(pt, item.label), "missing item: {}", item.label); - } - } - } - } - - #[test] - fn brazilian_portuguese_covers_catalog_union() { - let pt = Lang::from_code("pt-br").expect("pt-br registered"); - let pt_catalog = pt.catalog(); - for language in LANGUAGES.iter().filter(|language| !language.source.is_empty()) { - let (entries, errors) = catalog::parse_entries(language.source, language.plural_forms()); - assert!(errors.is_empty(), "{} catalog: {errors:?}", language.code); - for entry in entries { - let covered = match entry.context.as_str() { - "" => pt_catalog.plain(&entry.source).is_some(), - "@id" => pt_catalog.id(&entry.source).is_some(), - "@plural" => { - let (one, other) = entry.source.split_once('|').expect("validated plural source"); - pt_catalog.plural(one, other, 0).is_some() - } - context => pt_catalog.contextual(context, &entry.source).is_some(), - }; - assert!(covered, "Brazilian Portuguese lacks {} / {:?}", entry.context, entry.source); - } - } - } - - /// Check direct lookups and multiline literals as well as ordinary tl!("literal") calls, - /// following the complete-catalog check for Simplified Chinese. - #[test] - fn brazilian_portuguese_covers_ui_literals() { - let pt = Lang::from_code("pt-br").expect("pt-br registered"); - let mut stack = vec![std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src")]; - let mut literals = std::collections::BTreeSet::new(); - while let Some(dir) = stack.pop() { - for entry in std::fs::read_dir(dir).expect("UI source directory") { - let path = entry.expect("UI source entry").path(); - if path.is_dir() { - if path.file_name().is_some_and(|name| name != "i18n") { - stack.push(path); - } - } else if path.extension().is_some_and(|ext| ext == "rs") { - let source = std::fs::read_to_string(path).expect("UI source file").replace("\r\n", "\n").replace("crate::i18n::t(", "tl!("); - let mut rest = source.split("#[cfg(test)]\nmod ").next().unwrap_or_default(); - while let Some((_, after)) = rest.split_once("tl!(") { - let after = after.trim_start(); - let Some(after) = after.strip_prefix('"') else { - rest = after; - continue; - }; - let mut escaped = false; - let end = after - .char_indices() - .find_map(|(i, c)| { - if c == '"' && !escaped { - return Some(i); - } - escaped = c == '\\' && !escaped; - None - }) - .expect("closed tl! literal"); - let (raw, tail) = after.split_at(end); - let closing = tail.strip_prefix('"').expect("closing quote").trim_start(); - let closing = closing.strip_prefix(',').unwrap_or(closing).trim_start(); - if closing.starts_with(')') { - let label: String = serde_json::from_str(&format!("\"{raw}\"")).expect("UI literal escapes"); - literals.insert(label); - } - rest = tail.strip_prefix('"').expect("closing quote"); - } - } - } - } - assert!(literals.len() > 900, "source scan found only {} literals", literals.len()); - let missing: Vec<_> = literals.iter().filter(|label| !has(pt, label)).collect(); - assert!(missing.is_empty(), "untranslated Brazilian Portuguese UI literals: {missing:#?}"); - } - #[test] fn spanish_is_registered() { let es = Lang::from_code("es").expect("es registered"); @@ -1384,13 +1106,7 @@ mod tests { assert_eq!(lang_from_tag("es_ES.UTF-8"), Some(es)); assert_eq!(lang_from_tag("es-MX"), Some(es)); assert_eq!(first_supported("es-ES\r\nen-US"), Some(es)); - assert_eq!(tr(es, "File"), "Archivo"); - assert_eq!(tr(es, "Save as…"), "Guardar como…"); assert_eq!(tr(es, "Informe del usuario.pdf"), "Informe del usuario.pdf"); - assert_eq!(trn(es, 1, "{n} page", "{n} pages"), "1 página"); - assert_eq!(trn(es, 3, "{n} page", "{n} pages"), "3 páginas"); - // The signature pad's Type is the verb (type your name), not the noun "Tipo". - assert_eq!(tr_ctx(es, "signature pad", "Type"), "Escribir"); let mut app = crate::PdfCraftApp::default(); app.set_option("language", "es").unwrap(); assert_eq!(app.language, "es"); @@ -1399,70 +1115,9 @@ mod tests { assert_eq!(restored.language, "es"); } - /// Spanish translates every registered command and every All tools group, section and item. - #[test] - fn spanish_covers_commands_and_catalogue() { - let es = Lang::from_code("es").expect("es registered"); - for command in pdfcraft_engine::commands::COMMANDS { - assert!(has(es, command.label), "missing command: {}", command.label); - if let Some(menu) = command.menu { - assert!(has(es, menu), "missing menu: {menu}"); - } - } - for group in pdfcraft_engine::catalog::TOOL_GROUPS { - assert!(has(es, group.label), "missing group: {}", group.label); - for section in group.sections { - assert!(has(es, section.title), "missing section: {}", section.title); - for item in section.items { - assert!(has(es, item.label), "missing item: {}", item.label); - } - } - } - } - - /// New tl!("literal") labels must not silently fall back to English in Spanish. - #[test] - fn spanish_covers_ui_literals() { - let es = Lang::from_code("es").expect("es registered"); - let literals = ui_literals(); - assert!(literals.len() > 900, "source scan found only {} literals", literals.len()); - let missing: Vec<_> = literals.iter().filter(|label| !has(es, label)).collect(); - assert!(missing.is_empty(), "untranslated Spanish UI literals: {missing:#?}"); - } - - /// Spanish says everything the layers below word for themselves: what the accessibility - /// checker finds and what its saved report calls things, what the PDF/A verifier reports, what - /// a signature's details say and the changes they name, what can be wrong with the pages to - /// print or with a table of text fields, and what an edit is called in the history. - /// - /// These lists are the whole point of the engine handing the wording over apart from the - /// document's own words: a catalog that covers them says those sentences in its own language. - /// A sentence missing here is a sentence in English on a Spanish screen. - #[test] - fn spanish_covers_what_the_engine_says() { - let es = Lang::from_code("es").expect("es registered"); - let lists: [(&str, &[&str]); 9] = [ - ("accessibility finding", pdfcraft_engine::a11y::WORDINGS), - ("accessibility report", pdfcraft_engine::a11y::REPORT_WORDS), - ("PDF/A problem", pdfcraft_engine::pdfa::WORDINGS), - ("signature detail", pdfcraft_engine::sign::pdf::WORDINGS), - ("change after signing", pdfcraft_engine::sign::pdf::kind::ALL), - ("pages to print", pdfcraft_engine::print::WORDINGS), - ("edit label", pdfcraft_engine::EDIT_LABELS), - ("batch label", pdfcraft_engine::BATCH_LABELS), - ("text field table mistake", pdfcraft_engine::field_table::WORDINGS), - ]; - for (what, list) in lists { - assert!(!list.is_empty(), "no {what} wordings to cover"); - for wording in list { - assert!(has(es, wording), "missing {what}: {wording}"); - } - } - } - - /// Each of those sentences keeps its holes when it is said in Spanish, so the document's own - /// words still have somewhere to go: a translation that drops `{p}` or `{}` would read as a - /// sentence about nothing in particular. + /// Each sentence the engine words for itself keeps its holes when it is said in Spanish, so + /// the document's own words still have somewhere to go: a translation that drops `{p}` or + /// `{}` would read as a sentence about nothing in particular. #[test] fn spanish_keeps_the_holes_in_what_the_engine_says() { let es = Lang::from_code("es").expect("es registered"); @@ -1490,11 +1145,7 @@ mod tests { assert_eq!(normalize_pref("TE"), Some("te")); assert_eq!(lang_from_tag("te_IN.UTF-8"), Some(te)); assert_eq!(first_supported("te-IN\r\nen-US"), Some(te)); - assert_eq!(tr(te, "File"), "ఫైల్"); - assert_eq!(tr(te, "Save as…"), "వేరే పేరుతో సేవ్ చేయి…"); assert_eq!(tr(te, "నివేదిక.pdf"), "నివేదిక.pdf"); - assert_eq!(trn(te, 1, "{n} page", "{n} pages"), "1 పేజీ"); - assert_eq!(trn(te, 3, "{n} page", "{n} pages"), "3 పేజీలు"); let mut app = crate::PdfCraftApp::default(); app.set_option("language", "te").unwrap(); assert_eq!(app.language, "te"); @@ -1531,24 +1182,6 @@ mod tests { assert_eq!(tr_ctx(es, "no such context", "Type"), tr(es, "Type")); } - /// Telugu translates every registered command and every All tools group, section and item. - #[test] - fn telugu_covers_commands_and_catalogue() { - let te = Lang::from_code("te").expect("te registered"); - for command in pdfcraft_engine::commands::COMMANDS { - assert!(has(te, command.label), "missing command: {}", command.label); - } - for group in pdfcraft_engine::catalog::TOOL_GROUPS { - assert!(has(te, group.label), "missing group: {}", group.label); - for section in group.sections { - assert!(has(te, section.title), "missing section: {}", section.title); - for item in section.items { - assert!(has(te, item.label), "missing item: {}", item.label); - } - } - } - } - /// With a craft-fonts Telugu face, every Telugu translation has glyphs: with all interface /// faces (desktop) and with the Telugu face as the only craft-fonts face (the web build keeps /// it; Telugu labels must not need a Japanese or Arabic face). @@ -1575,56 +1208,12 @@ mod tests { } } - /// Czech covers every registered menu title and menu command label (#68), keeps ellipses, and - /// only leaves untranslated what is deliberately the same in Czech. #[test] - fn czech_covers_every_menu_label() { + fn czech_is_registered_and_persists() { let cs = Lang::from_code("cs").expect("cs registered"); assert_eq!(cs.name(), "Čeština"); - assert_eq!(tr(cs, "File"), "Soubor"); assert_eq!(tr(cs, "Žluťoučký kůň.pdf"), "Žluťoučký kůň.pdf"); assert_eq!((0..=6).map(|n| (cs.0.plural)(n)).collect::>(), [2, 0, 1, 1, 1, 2, 2]); - for spec in pdfcraft_engine::commands::COMMANDS { - let Some(menu) = spec.menu else { continue }; - assert!(has(cs, menu), "Czech lacks the menu title {menu:?}"); - assert!(has(cs, spec.label), "Czech lacks the {menu} menu label {:?} ({})", spec.label, spec.id); - } - // Spelled the same in Czech: product and format names, abbreviations, key caps, words - // Czech shares with English, and templates with no English words. - const KEEP_AS_IS: &[&str] = &[ - "OK", - "PDF/A…", - "Microsoft Word (.docx)", - "PostScript / EPS", - "Home / End", - "JavaScript", - "ZIP", - "Text", - "Index", - "Metadata", - "metadata", - "Model", - "{field}: {e}", - "{0}: {1}", - "{month} {y}", - "+LOC", - "−LOC", - "ΔLOC", - "+Bin", - "−Bin", - // The distinguished name of a certificate, abbreviated as in X.500. - "DN: ", - ]; - let (entries, errors) = parse_entries(cs.0.source, cs.0.plural_forms()); - assert!(errors.is_empty(), "{errors:?}"); - // The code joins some labels with a space at either end: keep exactly the source's. - let edges = |s: &str| (s.len() - s.trim_start().len(), s.len() - s.trim_end().len()); - for e in &entries { - let key_cap = e.context == "key" && KEY_CAP_NAMES.contains(&e.source.as_str()); - assert!(e.source != e.translation || KEEP_AS_IS.contains(&e.source.as_str()) || key_cap, "{:?} is untranslated", e.source); - assert!(!e.translation.contains("..."), "use … rather than three dots: {:?}", e.translation); - assert_eq!(edges(&e.translation), edges(&e.source), "stray whitespace: {:?}", e.translation); - } let mut app = crate::PdfCraftApp::default(); app.set_option("language", "cs").unwrap(); assert!(app.set_option("language", "cz").is_err()); @@ -1634,178 +1223,12 @@ mod tests { assert_eq!(restored.language, "cs"); } - /// Czech translates every registered command, menu and All tools label. #[test] - fn czech_covers_commands_and_catalogue() { - let cs = Lang::from_code("cs").expect("cs registered"); - for command in pdfcraft_engine::commands::COMMANDS { - assert!(has(cs, command.label), "missing command: {}", command.label); - if let Some(menu) = command.menu { - assert!(has(cs, menu), "missing menu: {menu}"); - } - } - for group in pdfcraft_engine::catalog::TOOL_GROUPS { - assert!(has(cs, group.label), "missing group: {}", group.label); - for section in group.sections { - assert!(has(cs, section.title), "missing section: {}", section.title); - for item in section.items { - assert!(has(cs, item.label), "missing item: {}", item.label); - } - } - } - } - - /// Czech translates every `tl!("…")` literal in the UI source. - #[test] - fn czech_covers_ui_literals() { - let cs = Lang::from_code("cs").expect("cs registered"); - let literals = ui_literals(); - assert!(literals.len() > 900, "source scan found only {} literals", literals.len()); - let missing: Vec<_> = literals.iter().filter(|label| !has(cs, label)).collect(); - assert!(missing.is_empty(), "untranslated Czech UI literals: {missing:#?}"); - } - - /// Czech counts take three forms (1; 2–4; 0 and 5+), dates use the genitive month after the - /// day, and history labels keep captured names. - #[test] - fn czech_plurals_dates_and_history_labels() { - let cs = Lang::from_code("cs").expect("cs registered"); - assert_eq!(trn(cs, 1, "{n} page", "{n} pages"), "1 stránka"); - assert_eq!(trn(cs, 3, "{n} page", "{n} pages"), "3 stránky"); - assert_eq!(trn(cs, 5, "{n} page", "{n} pages"), "5 stránek"); - assert_eq!(trn(cs, 0, "{n} field", "{n} fields"), "0 polí"); - assert_eq!(trn(cs, 1, "{n} byte", "{n} bytes"), "1 bajt"); - assert_eq!(trn(cs, 512, "{n} byte", "{n} bytes"), "512 bajtů"); - assert_eq!(trn(cs, 3, "{n} image optimized", "{n} images optimized"), "3 obrázky optimalizovány"); - assert_eq!(tr(cs, "October"), "Říjen"); - let month = tr_ctx(cs, "calendar date", "October"); - assert_eq!(fmt(tr(cs, "{month} {day}, {y}"), &[("month", month), ("day", "9"), ("y", "2026")]), "9. října 2026"); - set_current(cs); - assert_eq!(command_label("Undo Insert pages from Zpráva {n}.pdf"), "Zpět: Vložit stránky ze souboru Zpráva {n}.pdf"); - assert_eq!(command_label("Redo Fill in Kontakt {key}"), "Znovu: Vyplnit Kontakt {key}"); - assert_eq!(action_label("Change Title"), "Změnit: Název"); - assert_eq!(action_label("Delete 3 fields"), "Odstranit 3 pole"); - assert_eq!(action_label("Delete 12 fields"), "Odstranit 12 polí"); - assert_eq!(fmt(t("{n} pages selected"), &[("n", "3")]), "Vybráno stránek: 3"); - set_current(Lang::EN); - } - - /// Czech says everything the layers below word for themselves (see - /// `spanish_covers_what_the_engine_says`), and keeps each sentence's holes. - #[test] - fn czech_covers_what_the_engine_says() { - let cs = Lang::from_code("cs").expect("cs registered"); - let lists: [(&str, &[&str]); 8] = [ - ("accessibility finding", pdfcraft_engine::a11y::WORDINGS), - ("accessibility report", pdfcraft_engine::a11y::REPORT_WORDS), - ("PDF/A problem", pdfcraft_engine::pdfa::WORDINGS), - ("signature detail", pdfcraft_engine::sign::pdf::WORDINGS), - ("change after signing", pdfcraft_engine::sign::pdf::kind::ALL), - ("pages to print", pdfcraft_engine::print::WORDINGS), - ("edit label", pdfcraft_engine::EDIT_LABELS), - ("batch label", pdfcraft_engine::BATCH_LABELS), - ]; - for (what, list) in lists { - assert!(!list.is_empty(), "no {what} wordings to cover"); - for wording in list { - assert!(has(cs, wording), "missing {what}: {wording}"); - let said = tr(cs, wording); - assert_eq!(wording.matches("{}").count(), said.matches("{}").count(), "the Czech of {wording:?} loses a hole: {said:?}"); - for hole in wording.split('{').skip(1).filter_map(|rest| rest.split_once('}')).map(|(name, _)| name).filter(|n| !n.is_empty()) { - assert!(said.contains(&format!("{{{hole}}}")), "the Czech of {wording:?} loses {{{hole}}}: {said:?}"); - } - } - } - assert_eq!(tr(cs, "Signature is valid"), "Podpis je platný"); - } - - /// The date picker's weekday heads and the certificate viewer's fields are looked up in their - /// own contexts, which the `tl!` scan doesn't see: none of them may fall back to English. - #[test] - fn czech_covers_weekdays_and_certificate_fields() { - let cs = Lang::from_code("cs").expect("cs registered"); - assert_eq!( - ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"].map(|day| tr_ctx(cs, "calendar weekday", day)), - ["Ne", "Po", "Út", "St", "Čt", "Pá", "So"] - ); - assert_eq!(tr_ctx(cs, "certificate", "Subject"), "Subjekt"); - for field in [ - "Issued to", - "Issued by", - "Valid from", - "Valid to", - "Intended usage", - "Serial number", - "Issuer", - "Validity starts", - "Validity ends", - "Public key", - "Basic constraints", - "Key usage", - "Self-signed", - "SHA-1 digest", - "SHA-256 digest", - "Version", - ] { - assert_ne!(tr_ctx(cs, "certificate", field), field, "certificate field remains untranslated: {field}"); - } - assert_eq!(trn(cs, 3, "{n} file", "{n} files"), "3 soubory"); - assert_eq!(fmt(tr(cs, "{n} tags"), &[("n", "1")]), "Tagy: 1"); - } - - /// Turkish covers every registered menu title and menu command label, keeps ellipses, and - /// persists in preferences. - #[test] - fn turkish_covers_every_menu_label() { + fn turkish_is_registered_and_persists() { let tr_lang = Lang::from_code("tr").expect("tr registered"); assert_eq!(tr_lang.name(), "Türkçe"); - assert_eq!(tr(tr_lang, "File"), "Dosya"); assert_eq!(tr(tr_lang, "Türkçe Belge.pdf"), "Türkçe Belge.pdf"); assert_eq!((0..=6).map(|n| (tr_lang.0.plural)(n)).collect::>(), [0; 7]); - for spec in pdfcraft_engine::commands::COMMANDS { - let Some(menu) = spec.menu else { continue }; - assert!(has(tr_lang, menu), "Turkish lacks the menu title {menu:?}"); - assert!(has(tr_lang, spec.label), "Turkish lacks the {menu} menu label {:?} ({})", spec.label, spec.id); - } - const KEEP_AS_IS: &[&str] = &[ - "OK", - "PDF/A…", - "Microsoft Word (.docx)", - "PostScript / EPS", - "Preflight", - "JavaScript", - "ZIP", - "{n} {kind}", - "{rule} - {status}", - "Poster", - "{field}: {e}", - "`{command}` {when}", - "{0}: {1}", - "{month} {y}", - "+LOC", - "−LOC", - "ΔLOC", - "+Bin", - "−Bin", - "Model", - "Home / End", - // The distinguished name of a certificate, abbreviated as in X.500. - "DN: ", - ]; - let (entries, errors) = parse_entries(tr_lang.0.source, tr_lang.0.plural_forms()); - assert!(errors.is_empty(), "Turkish catalog errors: {errors:?}"); - for e in &entries { - assert!( - e.source != e.translation - || KEEP_AS_IS.contains(&e.source.as_str()) - || (e.context == "key" && KEY_CAP_NAMES.contains(&e.source.as_str())), - "{:?} is untranslated", - e.source - ); - assert!(!e.translation.contains("..."), "use … rather than three dots: {:?}", e.translation); - assert_eq!(e.source.starts_with(' '), e.translation.starts_with(' '), "leading space mismatch: {:?}", e.translation); - assert_eq!(e.source.ends_with(' '), e.translation.ends_with(' '), "trailing space mismatch: {:?}", e.translation); - } let mut app = crate::PdfCraftApp::default(); app.set_option("language", "tr").unwrap(); assert!(app.set_option("language", "tu").is_err()); @@ -1815,35 +1238,20 @@ mod tests { assert_eq!(restored.language, "tr"); } - /// Turkish translates every registered command, menu and All tools label. + /// The code joins some labels with a space at either end, so these catalogs keep exactly the + /// source's, and write … rather than three dots. #[test] - fn turkish_covers_commands_and_catalogue() { - let tr_lang = Lang::from_code("tr").expect("tr registered"); - for command in pdfcraft_engine::commands::COMMANDS { - assert!(has(tr_lang, command.label), "missing command: {}", command.label); - if let Some(menu) = command.menu { - assert!(has(tr_lang, menu), "missing menu: {menu}"); + fn catalog_rows_keep_the_source_spacing_and_ellipsis() { + let edges = |s: &str| (s.len() - s.trim_start().len(), s.len() - s.trim_end().len()); + for code in ["cs", "tr", "vi"] { + let lang = Lang::from_code(code).expect("registered"); + let (entries, errors) = parse_entries(lang.0.source, lang.0.plural_forms()); + assert!(errors.is_empty(), "{code}: {errors:?}"); + for e in &entries { + assert!(!e.translation.contains("..."), "{code}: use … rather than three dots: {:?}", e.translation); + assert_eq!(edges(&e.translation), edges(&e.source), "{code}: stray whitespace: {:?}", e.translation); } } - for group in pdfcraft_engine::catalog::TOOL_GROUPS { - assert!(has(tr_lang, group.label), "missing group: {}", group.label); - for section in group.sections { - assert!(has(tr_lang, section.title), "missing section: {}", section.title); - for item in section.items { - assert!(has(tr_lang, item.label), "missing item: {}", item.label); - } - } - } - } - - /// Turkish translates every `tl!("…")` literal in the UI source. - #[test] - fn turkish_covers_ui_literals() { - let literals = ui_literals(); - assert!(literals.len() > 900, "source scan found only {} literals", literals.len()); - let tr_lang = Lang::from_code("tr").expect("tr registered"); - let missing: Vec<_> = literals.iter().filter(|label| !has(tr_lang, label)).collect(); - assert!(missing.is_empty(), "untranslated Turkish UI literals: {missing:#?}"); } #[test] @@ -1852,138 +1260,7 @@ mod tests { assert_eq!(zh.name(), "简体中文"); assert_eq!(normalize_pref("zh-Hans"), Some("zh-hans")); assert_eq!(normalize_pref("zh"), None, "only exact codes are preferences"); - assert_eq!(tr(zh, "File"), "文件"); assert_eq!(tr(zh, "报告.pdf"), "报告.pdf"); - for menu in ["File", "Edit", "Pages", "View", "Help", "Preferences…", "Interface language"] { - assert!(has(zh, menu), "missing {menu}"); - } - } - - /// Like PhotoCraft's complete-catalog gates, derive coverage from the UI, not another language. - #[test] - fn simplified_chinese_covers_commands_and_catalogue() { - let zh = Lang::from_code("zh-hans").expect("zh-hans registered"); - for command in pdfcraft_engine::commands::COMMANDS { - assert!(has(zh, command.label), "missing command: {}", command.label); - if let Some(menu) = command.menu { - assert!(has(zh, menu), "missing menu: {menu}"); - } - } - for group in pdfcraft_engine::catalog::TOOL_GROUPS { - assert!(has(zh, group.label), "missing group: {}", group.label); - for section in group.sections { - assert!(has(zh, section.title), "missing section: {}", section.title); - for item in section.items { - assert!(has(zh, item.label), "missing item: {}", item.label); - } - } - } - } - - #[test] - fn simplified_chinese_covers_about_credit_labels() { - let zh = Lang::from_code("zh-hans").expect("zh-hans registered"); - for label in ["About", "Contributors", "Models"].into_iter().chain(crate::credits::MODEL_COLUMNS) { - assert!(has(zh, label), "missing About label: {label}"); - } - for mode in crate::credits::NameMode::ALL { - assert!(has(zh, mode.label()), "missing name mode: {}", mode.label()); - } - for key in crate::credits::SortKey::ALL { - let (label, header) = key.label(); - for text in [label, header] { - assert!(has(zh, text), "missing contributor sort/header: {text}"); - } - } - } - - /// Direct tl! and i18n::t labels must not silently fall back to English in the complete catalog. - #[test] - fn simplified_chinese_covers_ui_literals() { - let zh = Lang::from_code("zh-hans").expect("zh-hans registered"); - let mut stack = vec![std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src")]; - let mut literals = std::collections::BTreeSet::new(); - while let Some(dir) = stack.pop() { - for entry in std::fs::read_dir(dir).expect("UI source directory") { - let path = entry.expect("UI source entry").path(); - if path.is_dir() { - // Catalog implementation and its test-only lookup examples are not UI labels. - if path.file_name().is_some_and(|name| name != "i18n") { - stack.push(path); - } - } else if path.extension().is_some_and(|ext| ext == "rs") { - let source = std::fs::read_to_string(path).expect("UI source file").replace("\r\n", "\n").replace("crate::i18n::t(", "tl!("); - let mut rest = source.split("#[cfg(test)]\nmod ").next().unwrap_or_default(); - while let Some((_, after)) = rest.split_once("tl!(") { - let after = after.trim_start(); - let Some(after) = after.strip_prefix('"') else { - rest = after; - continue; - }; - let mut escaped = false; - let end = after - .char_indices() - .find_map(|(i, c)| { - if c == '"' && !escaped { - return Some(i); - } - escaped = c == '\\' && !escaped; - None - }) - .expect("closed tl! literal"); - let (raw, tail) = after.split_at(end); - let closing = tail.strip_prefix('"').expect("closing quote").trim_start(); - let closing = closing.strip_prefix(',').unwrap_or(closing).trim_start(); - if closing.starts_with(')') { - // Current UI literals use the shared Rust/JSON string escapes. - let label: String = serde_json::from_str(&format!("\"{raw}\"")).expect("UI literal escapes"); - literals.insert(label); - } - rest = tail.strip_prefix('"').expect("closing quote"); - } - } - } - } - assert!(literals.len() > 900, "source scan found only {} literals", literals.len()); - let missing: Vec<_> = literals.iter().filter(|label| !has(zh, label)).collect(); - assert!(missing.is_empty(), "untranslated Simplified Chinese UI literals: {missing:#?}"); - } - - #[test] - fn simplified_chinese_history_and_diagnostics_preserve_user_values() { - let zh = Lang::from_code("zh-hans").expect("zh-hans registered"); - set_current(zh); - assert_eq!(command_label("Undo Insert pages from 报告 {n}.pdf"), "撤销 从 报告 {n}.pdf 插入页面"); - assert_eq!(command_label("Redo Fill in 联系人 {key}"), "重做 填写 联系人 {key}"); - assert_eq!(action_label("Change Title"), "更改标题"); - assert_eq!(action_label("Custom action {n}"), "Custom action {n}"); - assert_eq!(fmt(t("This page couldn't be displayed.\n{e}"), &[("e", "OS error {n}")]), "无法显示此页面。\nOS error {n}"); - assert_eq!(fmt(t("{n} pages selected"), &[("n", "3")]), "已选择 3 页"); - assert_eq!(tr(zh, "CheckBox"), "复选框"); - assert_eq!(tr(zh, "pages"), "页"); - assert_eq!(trn(zh, 1, "{n} page", "{n} pages"), "1 页"); - assert_eq!(trn(zh, 0, "{n} field", "{n} fields"), "0 个字段"); - assert_eq!(trn(zh, 2, "{n} field", "{n} fields"), "2 个字段"); - assert_eq!(fmt(t("{n} contributors · {c} commits"), &[("n", "2"), ("c", "1,234")]), "2 位贡献者 · 1,234 次提交"); - let contributor = crate::credits::Contributor { - login: "reader{n}", - display_name: Some("Save"), - real_name: None, - prs: 2, - commits: 3, - lines_added: 10, - lines_deleted: 4, - binary_added: 1, - binary_deleted: 0, - first_commit: "2026-10-01T00:00:00Z", - last_commit: "2026-10-09T00:00:00Z", - }; - assert_eq!( - contributor.summary(), - "@reader{n}:2 个 PR,3 次提交,新增 10 行 / 删除 4 行(净增 +6 行),新增 1 个 / 删除 0 个二进制资源,2026-10-01 – 2026-10-09" - ); - assert_eq!(contributor.name(crate::credits::NameMode::DisplayName), "Save"); - set_current(Lang::EN); } #[test] @@ -1991,37 +1268,7 @@ mod tests { let zh = Lang::from_code("zh-hant").expect("zh-hant registered"); assert_eq!(zh.name(), "繁體中文"); assert_eq!(normalize_pref("zh-Hant"), Some("zh-hant")); - assert_eq!(tr(zh, "File"), "檔案"); assert_eq!(tr(zh, "報告.pdf"), "報告.pdf"); - for command in pdfcraft_engine::commands::COMMANDS { - assert!(has(zh, command.label), "missing command: {}", command.label); - } - } - - /// Report statuses and rule descriptions must retain their meaning in Taiwan Chinese. - #[test] - fn traditional_chinese_accessibility_meaning() { - use pdfcraft_engine::a11y::{Rule, Status}; - let zh = Lang::from_code("zh-hant").expect("zh-hant registered"); - assert_eq!(tr(zh, Status::Passed.label()), "通過"); - assert_eq!(tr(zh, Status::Failed.label()), "未通過"); - assert_eq!(tr(zh, Rule::Scripts.description()), "沒有妨礙無障礙操作的指令碼"); - assert_eq!(tr(zh, Rule::TableRows.name()), "列"); - assert_eq!(tr(zh, Rule::TableRows.explanation()), "表格列應直接位於表格或其表頭、主體、表尾內。請修正表格標記。"); - assert_eq!(tr(zh, Rule::TableRegularity.description()), "表格每列的欄數必須相同,每欄的列數也必須相同"); - assert_eq!(tr(zh, Rule::TableCells.explanation()), "表格儲存格應位於列內。請修正表格標記。"); - assert_eq!(tr(zh, Rule::TableHeaders.explanation()), "表頭儲存格(TH)讓聽者知道每欄或每列的內容。請標記表頭儲存格。"); - } - - /// A choice in a form is an item, not a project; field notices preserve user-supplied names. - #[test] - fn traditional_chinese_form_vocabulary() { - let zh = Lang::from_code("zh-hant").expect("zh-hant registered"); - assert_eq!(tr(zh, "Item:"), "項目:"); - assert_eq!(tr(zh, "Item List"), "項目清單"); - assert_eq!(tr(zh, "Sort items"), "排序項目"); - assert_eq!(tr(zh, "Read Only"), "唯讀"); - assert_eq!(fmt(tr(zh, "{name} is read-only"), &[("name", "Field{n}")]), "Field{n} 為唯讀"); } #[test] @@ -2032,17 +1279,8 @@ mod tests { assert_eq!(lang_from_tag("fr_FR.UTF-8"), Some(fr)); assert_eq!(lang_from_tag("fr-CA"), Some(fr)); assert_eq!(first_supported("fr-FR\r\nen-US"), Some(fr)); - assert_eq!(tr(fr, "File"), "Fichier"); - assert_eq!(tr(fr, "Save as…"), "Enregistrer sous…"); - assert_eq!(tr(fr, "Bookmarks"), "Signets"); - assert_eq!(tr(fr, "Layers"), "Calques"); assert_eq!(tr(fr, "Rapport de l'utilisateur.pdf"), "Rapport de l'utilisateur.pdf"); assert_eq!((0..=3).map(|n| (fr.0.plural)(n)).collect::>(), [0, 0, 1, 1]); - assert_eq!(trn(fr, 0, "{n} page", "{n} pages"), "0 page"); - assert_eq!(trn(fr, 1, "{n} page", "{n} pages"), "1 page"); - assert_eq!(trn(fr, 2, "{n} page", "{n} pages"), "2 pages"); - assert_eq!(trn(fr, 1, "{n} field", "{n} fields"), "1 champ"); - assert_eq!(trn(fr, 2, "{n} field", "{n} fields"), "2 champs"); let mut app = crate::PdfCraftApp::default(); app.set_option("language", "fr").unwrap(); assert_eq!(app.language, "fr"); @@ -2051,84 +1289,6 @@ mod tests { assert_eq!(restored.language, "fr"); } - /// French translates every registered command and every All tools group, section and item. - #[test] - fn french_covers_commands_and_catalogue() { - let fr = Lang::from_code("fr").expect("fr registered"); - for command in pdfcraft_engine::commands::COMMANDS { - assert!(has(fr, command.label), "missing command: {}", command.label); - if let Some(menu) = command.menu { - assert!(has(fr, menu), "missing menu: {menu}"); - } - } - for group in pdfcraft_engine::catalog::TOOL_GROUPS { - assert!(has(fr, group.label), "missing group: {}", group.label); - for section in group.sections { - assert!(has(fr, section.title), "missing section: {}", section.title); - for item in section.items { - assert!(has(fr, item.label), "missing item: {}", item.label); - } - } - } - } - - /// New tl!("literal") labels must not silently fall back to English. - #[test] - fn french_covers_ui_literals() { - let fr = Lang::from_code("fr").expect("fr registered"); - let mut stack = vec![std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src")]; - let mut literals = std::collections::BTreeSet::new(); - while let Some(dir) = stack.pop() { - for entry in std::fs::read_dir(dir).expect("UI source directory") { - let path = entry.expect("UI source entry").path(); - if path.is_dir() { - if path.file_name().is_some_and(|name| name != "i18n") { - stack.push(path); - } - } else if path.extension().is_some_and(|ext| ext == "rs") { - let source = std::fs::read_to_string(path).expect("UI source file").replace("\r\n", "\n"); - let mut rest = source.split("#[cfg(test)]\nmod ").next().unwrap_or_default(); - while let Some((_, after)) = rest.split_once("tl!(\"") { - let mut escaped = false; - let end = after - .char_indices() - .find_map(|(i, c)| { - if c == '"' && !escaped { - return Some(i); - } - escaped = c == '\\' && !escaped; - None - }) - .expect("closed tl! literal"); - let (raw, tail) = after.split_at(end); - if tail.starts_with("\")") { - let label: String = serde_json::from_str(&format!("\"{raw}\"")).expect("UI literal escapes"); - literals.insert(label); - } - rest = tail.strip_prefix('"').expect("closing quote"); - } - } - } - } - assert!(literals.len() > 900, "source scan found only {} literals", literals.len()); - let missing: Vec<_> = literals.iter().filter(|label| !has(fr, label)).collect(); - assert!(missing.is_empty(), "untranslated French UI literals: {missing:#?}"); - } - - #[test] - fn french_history_and_diagnostics_preserve_user_values() { - let fr = Lang::from_code("fr").expect("fr registered"); - set_current(fr); - assert_eq!(command_label("Undo Insert pages from Rapport {n}.pdf"), "Annuler Insérer des pages depuis Rapport {n}.pdf"); - assert_eq!(command_label("Redo Fill in Contact {key}"), "Rétablir Remplir Contact {key}"); - assert_eq!(action_label("Change Title"), "Modifier Titre"); - assert_eq!(action_label("Custom action {n}"), "Custom action {n}"); - assert_eq!(fmt(t("This page couldn't be displayed.\n{e}"), &[("e", "OS error {n}")]), "Impossible d'afficher cette page.\nOS error {n}"); - assert_eq!(fmt(t("{n} pages selected"), &[("n", "3")]), "3 pages sélectionnées"); - assert_eq!(tr(fr, "CheckBox"), "Case à cocher"); - set_current(Lang::EN); - } - #[test] fn german_is_registered() { let de = Lang::from_code("de").expect("de registered"); @@ -2138,17 +1298,8 @@ mod tests { assert_eq!(lang_from_tag("de-AT"), Some(de)); assert_eq!(lang_from_tag("de_CH"), Some(de)); assert_eq!(first_supported("de-DE\r\nen-US"), Some(de)); - assert_eq!(tr(de, "File"), "Datei"); - assert_eq!(tr(de, "Save as…"), "Speichern unter…"); - assert_eq!(tr(de, "Bookmarks"), "Lesezeichen"); - assert_eq!(tr(de, "Layers"), "Ebenen"); assert_eq!(tr(de, "Bericht des Nutzers.pdf"), "Bericht des Nutzers.pdf"); assert_eq!((0..=3).map(|n| (de.0.plural)(n)).collect::>(), [1, 0, 1, 1]); - assert_eq!(trn(de, 0, "{n} page", "{n} pages"), "0 Seiten"); - assert_eq!(trn(de, 1, "{n} page", "{n} pages"), "1 Seite"); - assert_eq!(trn(de, 2, "{n} page", "{n} pages"), "2 Seiten"); - assert_eq!(trn(de, 1, "{n} field", "{n} fields"), "1 Feld"); - assert_eq!(trn(de, 2, "{n} field", "{n} fields"), "2 Felder"); let mut app = crate::PdfCraftApp::default(); app.set_option("language", "de").unwrap(); assert_eq!(app.language, "de"); @@ -2157,70 +1308,6 @@ mod tests { assert_eq!(restored.language, "de"); } - /// German translates every registered command and every All tools group, section and item. - #[test] - fn german_covers_commands_and_catalogue() { - let de = Lang::from_code("de").expect("de registered"); - for command in pdfcraft_engine::commands::COMMANDS { - assert!(has(de, command.label), "missing command: {}", command.label); - if let Some(menu) = command.menu { - assert!(has(de, menu), "missing menu: {menu}"); - } - } - for group in pdfcraft_engine::catalog::TOOL_GROUPS { - assert!(has(de, group.label), "missing group: {}", group.label); - for section in group.sections { - assert!(has(de, section.title), "missing section: {}", section.title); - for item in section.items { - assert!(has(de, item.label), "missing item: {}", item.label); - } - } - } - } - - /// New tl!("literal") labels must not silently fall back to English. - #[test] - fn german_covers_ui_literals() { - let de = Lang::from_code("de").expect("de registered"); - let mut stack = vec![std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src")]; - let mut literals = std::collections::BTreeSet::new(); - while let Some(dir) = stack.pop() { - for entry in std::fs::read_dir(dir).expect("UI source directory") { - let path = entry.expect("UI source entry").path(); - if path.is_dir() { - if path.file_name().is_some_and(|name| name != "i18n") { - stack.push(path); - } - } else if path.extension().is_some_and(|ext| ext == "rs") { - let source = std::fs::read_to_string(path).expect("UI source file").replace("\r\n", "\n"); - let mut rest = source.split("#[cfg(test)]\nmod ").next().unwrap_or_default(); - while let Some((_, after)) = rest.split_once("tl!(\"") { - let mut escaped = false; - let end = after - .char_indices() - .find_map(|(i, c)| { - if c == '"' && !escaped { - return Some(i); - } - escaped = c == '\\' && !escaped; - None - }) - .expect("closed tl! literal"); - let (raw, tail) = after.split_at(end); - if tail.starts_with("\")") { - let label: String = serde_json::from_str(&format!("\"{raw}\"")).expect("UI literal escapes"); - literals.insert(label); - } - rest = tail.strip_prefix('"').expect("closing quote"); - } - } - } - } - assert!(literals.len() > 900, "source scan found only {} literals", literals.len()); - let missing: Vec<_> = literals.iter().filter(|label| !has(de, label)).collect(); - assert!(missing.is_empty(), "untranslated German UI literals: {missing:#?}"); - } - #[test] fn tool_names_inside_sentences_are_lowercased_except_in_german() { set_current(Lang::EN); @@ -2244,32 +1331,6 @@ mod tests { assert_eq!(in_sentence("Image"), "image"); } - #[test] - fn german_certificate_subject_has_a_distinct_context() { - let de = Lang::from_code("de").expect("de registered"); - assert_eq!(tr(de, "Subject"), "Thema"); - assert_eq!(tr_ctx(de, "certificate", "Subject"), "Zertifikatsinhaber"); - assert_eq!(tr_ctx(de, "certificate", "Issuer"), tr(de, "Issuer")); - assert_eq!(tr_ctx(Lang::EN, "certificate", "Subject"), "Subject"); - } - - #[test] - fn german_history_and_diagnostics_preserve_user_values() { - let de = Lang::from_code("de").expect("de registered"); - set_current(de); - assert_eq!(command_label("Undo Insert pages from Rapport {n}.pdf"), "Seiten aus Rapport {n}.pdf einfügen rückgängig machen"); - assert_eq!(command_label("Redo Fill in Contact {key}"), "Contact {key} ausfüllen wiederholen"); - assert_eq!(action_label("Change Title"), "Titel ändern"); - assert_eq!(action_label("Custom action {n}"), "Custom action {n}"); - assert_eq!( - fmt(t("This page couldn't be displayed.\n{e}"), &[("e", "OS error {n}")]), - "Diese Seite konnte nicht angezeigt werden.\nOS error {n}" - ); - assert_eq!(fmt(t("{n} pages selected"), &[("n", "3")]), "3 Seiten ausgewählt"); - assert_eq!(tr(de, "CheckBox"), "Kontrollkästchen"); - set_current(Lang::EN); - } - #[test] fn russian_is_registered() { let ru = Lang::from_code("ru").expect("ru registered"); @@ -2278,23 +1339,9 @@ mod tests { assert_eq!(lang_from_tag("ru_RU.UTF-8"), Some(ru)); assert_eq!(lang_from_tag("ru-BY"), Some(ru)); assert_eq!(first_supported("ru-RU\r\nen-US"), Some(ru)); - assert_eq!(tr(ru, "File"), "Файл"); - assert_eq!(tr(ru, "Save as…"), "Сохранить как…"); - assert_eq!(tr(ru, "Bookmarks"), "Закладки"); - assert_eq!(tr(ru, "Layers"), "Слои"); assert_eq!(tr(ru, "Rapport de l'utilisateur.pdf"), "Rapport de l'utilisateur.pdf"); // one (1), few (2-4), many (0, 5+), with 11-14 exception assert_eq!((0..=3).map(|n| (ru.0.plural)(n)).collect::>(), [2, 0, 1, 1]); - assert_eq!(trn(ru, 0, "{n} page", "{n} pages"), "0 страниц"); - assert_eq!(trn(ru, 1, "{n} page", "{n} pages"), "1 страница"); - assert_eq!(trn(ru, 2, "{n} page", "{n} pages"), "2 страницы"); - assert_eq!(trn(ru, 5, "{n} page", "{n} pages"), "5 страниц"); - assert_eq!(trn(ru, 11, "{n} page", "{n} pages"), "11 страниц"); - assert_eq!(trn(ru, 21, "{n} page", "{n} pages"), "21 страница"); - assert_eq!(trn(ru, 22, "{n} page", "{n} pages"), "22 страницы"); - assert_eq!(trn(ru, 1, "{n} field", "{n} fields"), "1 поле"); - assert_eq!(trn(ru, 2, "{n} field", "{n} fields"), "2 поля"); - assert_eq!(trn(ru, 5, "{n} field", "{n} fields"), "5 полей"); let mut app = crate::PdfCraftApp::default(); app.set_option("language", "ru").unwrap(); assert_eq!(app.language, "ru"); @@ -2303,84 +1350,6 @@ mod tests { assert_eq!(restored.language, "ru"); } - /// Russian translates every registered command and every All tools group, section and item. - #[test] - fn russian_covers_commands_and_catalogue() { - let ru = Lang::from_code("ru").expect("ru registered"); - for command in pdfcraft_engine::commands::COMMANDS { - assert!(has(ru, command.label), "missing command: {}", command.label); - if let Some(menu) = command.menu { - assert!(has(ru, menu), "missing menu: {menu}"); - } - } - for group in pdfcraft_engine::catalog::TOOL_GROUPS { - assert!(has(ru, group.label), "missing group: {}", group.label); - for section in group.sections { - assert!(has(ru, section.title), "missing section: {}", section.title); - for item in section.items { - assert!(has(ru, item.label), "missing item: {}", item.label); - } - } - } - } - - /// New tl!("literal") labels must not silently fall back to English. - #[test] - fn russian_covers_ui_literals() { - let ru = Lang::from_code("ru").expect("ru registered"); - let mut stack = vec![std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src")]; - let mut literals = std::collections::BTreeSet::new(); - while let Some(dir) = stack.pop() { - for entry in std::fs::read_dir(dir).expect("UI source directory") { - let path = entry.expect("UI source entry").path(); - if path.is_dir() { - if path.file_name().is_some_and(|name| name != "i18n") { - stack.push(path); - } - } else if path.extension().is_some_and(|ext| ext == "rs") { - let source = std::fs::read_to_string(path).expect("UI source file").replace("\r\n", "\n"); - let mut rest = source.split("#[cfg(test)]\nmod ").next().unwrap_or_default(); - while let Some((_, after)) = rest.split_once("tl!(\"") { - let mut escaped = false; - let end = after - .char_indices() - .find_map(|(i, c)| { - if c == '"' && !escaped { - return Some(i); - } - escaped = c == '\\' && !escaped; - None - }) - .expect("closed tl! literal"); - let (raw, tail) = after.split_at(end); - if tail.starts_with("\")") { - let label: String = serde_json::from_str(&format!("\"{raw}\"")).expect("UI literal escapes"); - literals.insert(label); - } - rest = tail.strip_prefix('"').expect("closing quote"); - } - } - } - } - assert!(literals.len() > 900, "source scan found only {} literals", literals.len()); - let missing: Vec<_> = literals.iter().filter(|label| !has(ru, label)).collect(); - assert!(missing.is_empty(), "untranslated Russian UI literals: {missing:#?}"); - } - - #[test] - fn russian_history_and_diagnostics_preserve_user_values() { - let ru = Lang::from_code("ru").expect("ru registered"); - set_current(ru); - assert_eq!(command_label("Undo Insert pages from Rapport {n}.pdf"), "Отменить Вставить страницы из Rapport {n}.pdf"); - assert_eq!(command_label("Redo Fill in Contact {key}"), "Повторить Заполнить Contact {key}"); - assert_eq!(action_label("Change Title"), "Изменить заголовок"); - assert_eq!(action_label("Custom action {n}"), "Custom action {n}"); - assert_eq!(fmt(t("This page couldn't be displayed.\n{e}"), &[("e", "OS error {n}")]), "Не удалось отобразить эту страницу.\nOS error {n}"); - assert_eq!(fmt(t("{n} pages selected"), &[("n", "3")]), "Выбрано 3 страницы"); - assert_eq!(tr(ru, "CheckBox"), "Флажок"); - set_current(Lang::EN); - } - #[test] fn finnish_is_registered_and_persists() { let fi = Lang::from_code("fi").expect("fi registered"); @@ -2392,21 +1361,6 @@ mod tests { // 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)); - assert_eq!(tr(fi, "File"), "Tiedosto"); - assert_eq!(tr(fi, "Edit"), "Muokkaa"); - assert_eq!(tr(fi, "Save"), "Tallenna"); - assert_eq!(tr(fi, "Interface language"), "Käyttöliittymän kieli"); - // The partitive singular follows any number but 1. - assert_eq!(trn(fi, 0, "{n} page", "{n} pages"), "0 sivua"); - assert_eq!(trn(fi, 1, "{n} page", "{n} pages"), "1 sivu"); - assert_eq!(trn(fi, 2, "{n} page", "{n} pages"), "2 sivua"); - assert_eq!(trn(fi, 21, "{n} page", "{n} pages"), "21 sivua"); - // Calendar headings use the nominative, dates the partitive. - assert_eq!(fmt(tr(fi, "{month} {y}"), &[("month", tr(fi, "October")), ("y", "2026")]), "Lokakuu 2026"); - assert_eq!( - fmt(tr(fi, "{month} {day}, {y}"), &[("month", tr_ctx(fi, "calendar date", "October")), ("day", "14"), ("y", "2026")]), - "14. lokakuuta 2026" - ); let mut app = crate::PdfCraftApp::default(); app.set_option("language", "FI").unwrap(); assert_eq!(app.language, "fi"); @@ -2415,68 +1369,6 @@ mod tests { assert_eq!(restored.language, "fi"); } - #[test] - fn finnish_covers_commands_and_tools() { - let fi = Lang::from_code("fi").expect("fi registered"); - let mut missing = std::collections::BTreeSet::new(); - for command in pdfcraft_engine::commands::COMMANDS { - if !has(fi, command.label) { - missing.insert(command.label); - } - if let Some(menu) = command.menu - && !has(fi, menu) - { - missing.insert(menu); - } - } - for group in pdfcraft_engine::catalog::TOOL_GROUPS { - if !has(fi, group.label) { - missing.insert(group.label); - } - for section in group.sections { - if !has(fi, section.title) { - missing.insert(section.title); - } - for item in section.items { - if !has(fi, item.label) { - missing.insert(item.label); - } - } - } - } - assert!(missing.is_empty(), "Finnish misses commands/tools: {missing:#?}"); - } - - #[test] - fn finnish_covers_ui_literals() { - let fi = Lang::from_code("fi").expect("fi registered"); - let missing: Vec<_> = scan_ui_literals(true).into_iter().filter(|label| !has(fi, label)).collect(); - assert!(missing.is_empty(), "Untranslated Finnish UI labels: {missing:#?}"); - } - - #[test] - fn finnish_covers_bundled_catalog_keys() { - // Compare English keys, not other languages' translations. - let fi = Lang::from_code("fi").expect("fi registered"); - let mut missing = std::collections::BTreeSet::new(); - for reference in LANGUAGES.iter().filter(|language| language.code != "fi") { - let (entries, errors) = catalog::parse_entries(reference.source, reference.plural_forms()); - assert!(errors.is_empty(), "{}: invalid reference catalog: {errors:?}", reference.code); - for entry in entries { - let translated = match entry.context.as_str() { - "" => fi.catalog().plain(&entry.source), - "@id" => fi.catalog().id(&entry.source), - "@plural" => entry.source.split_once('|').and_then(|(one, other)| fi.catalog().plural(one, other, 0)), - context => fi.catalog().contextual(context, &entry.source), - }; - if translated.is_none() { - missing.insert((entry.context, entry.source)); - } - } - } - assert!(missing.is_empty(), "Finnish misses bundled catalog keys: {missing:#?}"); - } - #[test] fn lithuanian_is_registered_and_persists() { let lt = Lang::from_code("lt").expect("lt registered"); @@ -2485,18 +1377,6 @@ mod tests { for tag in ["lt", "lt-LT", "lt_LT.UTF-8", "LT-lt"] { assert_eq!(lang_from_tag(tag), Some(lt), "{tag}"); } - assert_eq!(tr(lt, "File"), "Failas"); - assert_eq!(tr(lt, "Edit"), "Redaguoti"); - assert_eq!(tr(lt, "Save"), "Įrašyti"); - assert_eq!(tr(lt, "Interface language"), "Sąsajos kalba"); - assert_eq!(trn(lt, 1, "{n} page", "{n} pages"), "1 puslapis"); - assert_eq!(trn(lt, 2, "{n} page", "{n} pages"), "2 puslapiai"); - assert_eq!(trn(lt, 10, "{n} page", "{n} pages"), "10 puslapių"); - assert_eq!(trn(lt, 11, "{n} page", "{n} pages"), "11 puslapių"); - assert_eq!(trn(lt, 21, "{n} page", "{n} pages"), "21 puslapis"); - assert_eq!(trn(lt, 22, "{n} page", "{n} pages"), "22 puslapiai"); - assert_eq!(trn(lt, 12, "{n} object selected", "{n} objects selected"), "Pažymėta 12 objektų"); - assert_eq!(trn(lt, 22, "{n} object selected", "{n} objects selected"), "Pažymėti 22 objektai"); let mut app = crate::PdfCraftApp::default(); app.set_option("language", "LT").unwrap(); assert_eq!(app.language, "lt"); @@ -2519,8 +1399,6 @@ mod tests { assert_eq!(first_supported("(\n \"nb-NO\",\n \"en-US\"\n)\n"), Some(nb)); assert_eq!(first_supported("nn-NO\r\nen-US\r\n"), Some(nb)); assert_eq!((0..=3).map(|n| (nb.0.plural)(n)).collect::>(), [1, 0, 1, 1]); - assert_eq!(tr(nb, "Allow"), "Tillat"); - assert_eq!(tr_ctx(nb, "key", "Space"), "Mellomrom"); let mut app = crate::PdfCraftApp::default(); app.set_option("language", "NB").unwrap(); assert_eq!(app.language, "nb"); @@ -2529,181 +1407,6 @@ mod tests { assert_eq!(restored.language, "nb"); } - #[test] - fn lithuanian_covers_commands_and_catalogue() { - let lt = Lang::from_code("lt").expect("lt registered"); - let mut missing = std::collections::BTreeSet::new(); - for command in pdfcraft_engine::commands::COMMANDS { - if !has(lt, command.label) { - missing.insert(command.label); - } - if let Some(menu) = command.menu - && !has(lt, menu) - { - missing.insert(menu); - } - } - for group in pdfcraft_engine::catalog::TOOL_GROUPS { - if !has(lt, group.label) { - missing.insert(group.label); - } - for section in group.sections { - if !has(lt, section.title) { - missing.insert(section.title); - } - for item in section.items { - if !has(lt, item.label) { - missing.insert(item.label); - } - } - } - } - assert!(missing.is_empty(), "Lithuanian misses commands/tools: {missing:#?}"); - } - - #[test] - fn lithuanian_covers_ui_literals() { - let lt = Lang::from_code("lt").expect("lt registered"); - let literals = scan_ui_literals(true); - assert!(literals.contains("Float panel"), "direct lookup literals are included"); - assert!(literals.contains("@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}"), "multiline literal arguments are included"); - let missing: Vec<_> = literals.iter().filter(|label| !has(lt, label)).collect(); - assert!(missing.is_empty(), "Untranslated Lithuanian UI labels: {missing:#?}"); - } - - #[test] - fn lithuanian_covers_contextual_calendar_certificate_and_preference_labels() { - let lt = Lang::from_code("lt").expect("lt registered"); - assert_eq!(tr_ctx(lt, "signature pad", "Type"), "Tekstu"); - for label in ["All tools", "Read", "E-Sign", "Tools", "View more", "View less"] { - assert!(has(lt, label), "shell label remains untranslated: {label}"); - } - assert_eq!(["Slow", "Normal", "Fast"].map(|label| tr_ctx(lt, "scroll speed", label)), ["Lėtas", "Įprastas", "Greitas"]); - let months = [ - ("January", "sausis", "sausio"), - ("February", "vasaris", "vasario"), - ("March", "kovas", "kovo"), - ("April", "balandis", "balandžio"), - ("May", "gegužė", "gegužės"), - ("June", "birželis", "birželio"), - ("July", "liepa", "liepos"), - ("August", "rugpjūtis", "rugpjūčio"), - ("September", "rugsėjis", "rugsėjo"), - ("October", "spalis", "spalio"), - ("November", "lapkritis", "lapkričio"), - ("December", "gruodis", "gruodžio"), - ]; - for (source, standalone, in_date) in months { - assert_eq!(tr(lt, source), standalone); - assert_eq!(tr_ctx(lt, "calendar date", source), in_date); - } - assert_eq!( - ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"].map(|day| tr_ctx(lt, "calendar weekday", day)), - ["Sk", "Pr", "An", "Tr", "Kt", "Pn", "Št"] - ); - assert_eq!( - fmt(tr(lt, "{month} {day}, {y}"), &[("month", tr_ctx(lt, "calendar date", "March")), ("day", "9"), ("y", "2026")]), - "2026 m. kovo 9 d." - ); - assert_eq!(tr_ctx(lt, "certificate", "Subject"), "Sertifikato subjektas"); - for field in [ - "Issued to", - "Issued by", - "Valid from", - "Valid to", - "Intended usage", - "Serial number", - "Issuer", - "Validity starts", - "Validity ends", - "Public key", - "Basic constraints", - "Key usage", - "Self-signed", - "SHA-1 digest", - "SHA-256 digest", - ] { - assert_ne!(tr_ctx(lt, "certificate", field), field, "certificate field remains untranslated: {field}"); - } - } - - #[test] - fn lithuanian_dialog_wording_preserves_resolution_and_selection_counts() { - let lt = Lang::from_code("lt").expect("lt registered"); - assert_eq!(tr(lt, "for images above"), "kai raiška viršija"); - assert_eq!( - fmt(tr(lt, "Checking Options ({on} of 32 in all categories)"), &[("on", "31")]), - "Tikrinimo parinktys (visose kategorijose: 31 iš 32)" - ); - } - - #[test] - fn lithuanian_covers_bundled_catalog_keys_including_dynamic_dialog_labels() { - // Compare English keys, not other languages' translations. Literal scans alone - // miss labels passed through helpers, enum methods and conditional expressions. - let lt = Lang::from_code("lt").expect("lt registered"); - let mut missing = std::collections::BTreeSet::new(); - for reference in LANGUAGES.iter().filter(|language| language.code != "lt") { - let (entries, errors) = catalog::parse_entries(reference.source, reference.plural_forms()); - assert!(errors.is_empty(), "{}: invalid reference catalog: {errors:?}", reference.code); - for entry in entries { - let translated = match entry.context.as_str() { - "" => lt.catalog().plain(&entry.source), - "@id" => lt.catalog().id(&entry.source), - "@plural" => entry.source.split_once('|').and_then(|(one, other)| lt.catalog().plural(one, other, 0)), - context => lt.catalog().contextual(context, &entry.source), - }; - if translated.is_none() { - missing.insert((entry.context, entry.source)); - } - } - } - assert!(missing.is_empty(), "Lithuanian misses bundled catalog keys: {missing:#?}"); - } - - #[test] - fn lithuanian_covers_credits_labels() { - let lt = Lang::from_code("lt").expect("lt registered"); - let labels = crate::credits::NameMode::ALL - .iter() - .map(|mode| mode.label()) - .chain(crate::credits::SortKey::ALL.iter().flat_map(|key| { - let (menu, heading) = key.label(); - [menu, heading] - })) - .chain(crate::credits::MODEL_COLUMNS.iter().copied()); - let missing: Vec<_> = labels.filter(|label| !has(lt, label)).collect(); - assert!(missing.is_empty(), "Lithuanian misses credits labels: {missing:#?}"); - } - - #[test] - fn lithuanian_wording_fixes_keep_context_and_meaning() { - let lt = Lang::from_code("lt").expect("lt registered"); - assert_eq!(tr(lt, "of {name}"), "iš {name}"); - assert_eq!(tr(lt, "Active"), "Aktyvi", "the adjective describes a theme (tema)"); - for n in [1, 2, 12] { - let renamed = trn( - lt, - n, - "{n} field name collides with earlier files and will be renamed: {list}", - "{n} field names collide with earlier files and will be renamed: {list}", - ); - assert!(renamed.contains("ankstesniuose failuose esančių laukų pavadinimais")); - assert!(renamed.contains("bus pervadint")); - assert!(renamed.contains("{list}")); - } - assert_eq!(tr(lt, "Two-Up (Facing)"), "Du atgręžti puslapiai"); - assert_eq!(tr(lt, "Windows store · "), "„Windows“ sertifikatų saugykla · "); - assert_eq!( - tr(lt, "Adds invisible text over each word; the page image is not changed"), - "Virš kiekvieno žodžio prideda nematomą tekstą; puslapio vaizdas nesikeičia" - ); - assert!( - tr(lt, "The fonts PdfCraft writes with can't draw “{c}” ({code}), so it shows as ? in every PDF viewer. The text is saved.") - .contains("visose PDF žiūryklėse") - ); - } - #[test] fn vietnamese_is_registered_and_persists() { let vi = Lang::from_code("vi").expect("vi registered"); @@ -2713,18 +1416,7 @@ mod tests { assert_eq!(lang_from_tag(tag), Some(vi), "{tag}"); } assert_eq!(first_supported("vi-VN\r\nen-US"), Some(vi)); - assert_eq!(tr(vi, "File"), "Tệp"); - assert_eq!(tr(vi, "Edit"), "Chỉnh sửa"); - assert_eq!(tr(vi, "Save"), "Lưu"); - assert_eq!(tr(vi, "Save as…"), "Lưu thành…"); - assert_eq!(tr(vi, "Bookmarks"), "Dấu trang"); - assert_eq!(tr(vi, "Fill & Sign"), "Điền & Ký"); - assert_eq!(tr(vi, "Interface language"), "Ngôn ngữ giao diện"); assert_eq!((0..=3).map(|n| (vi.0.plural)(n)).collect::>(), [0, 0, 0, 0]); - for n in [0, 1, 2, 5] { - assert_eq!(trn(vi, n, "{n} page", "{n} pages"), format!("{n} trang")); - assert_eq!(trn(vi, n, "{n} field", "{n} fields"), format!("{n} trường")); - } let mut app = crate::PdfCraftApp::default(); app.set_option("language", "vi").unwrap(); assert_eq!(app.language, "vi"); @@ -2735,123 +1427,6 @@ mod tests { assert_eq!(restored.language, "vi"); } - #[test] - fn vietnamese_covers_commands_and_catalogue() { - let vi = Lang::from_code("vi").expect("vi registered"); - let mut missing = std::collections::BTreeSet::new(); - for command in pdfcraft_engine::commands::COMMANDS { - if !has(vi, command.label) { - missing.insert(command.label); - } - if let Some(menu) = command.menu - && !has(vi, menu) - { - missing.insert(menu); - } - } - for group in pdfcraft_engine::catalog::TOOL_GROUPS { - if !has(vi, group.label) { - missing.insert(group.label); - } - for section in group.sections { - if !has(vi, section.title) { - missing.insert(section.title); - } - for item in section.items { - if !has(vi, item.label) { - missing.insert(item.label); - } - } - } - } - assert!(missing.is_empty(), "Vietnamese misses commands/tools: {missing:#?}"); - } - - #[test] - fn vietnamese_covers_ui_literals() { - let vi = Lang::from_code("vi").expect("vi registered"); - let literals = scan_ui_literals(true); - let missing: Vec<_> = literals.iter().filter(|label| !has(vi, label)).collect(); - assert!(missing.is_empty(), "Untranslated Vietnamese UI labels: {missing:#?}"); - } - - #[test] - fn vietnamese_covers_contextual_calendar_certificate_and_preference_labels() { - let vi = Lang::from_code("vi").expect("vi registered"); - assert_eq!(tr_ctx(vi, "comment menu", "Edit"), "Sửa"); - assert_eq!(tr_ctx(vi, "signature pad", "Type"), "Nhập chữ"); - assert_eq!(tr_ctx(vi, "action wizard", "Start"), "Bắt đầu"); - assert_eq!(["Slow", "Normal", "Fast"].map(|label| tr_ctx(vi, "scroll speed", label)), ["Chậm", "Bình thường", "Nhanh"]); - let months = [ - ("January", "Tháng 1", "tháng 1"), - ("February", "Tháng 2", "tháng 2"), - ("March", "Tháng 3", "tháng 3"), - ("April", "Tháng 4", "tháng 4"), - ("May", "Tháng 5", "tháng 5"), - ("June", "Tháng 6", "tháng 6"), - ("July", "Tháng 7", "tháng 7"), - ("August", "Tháng 8", "tháng 8"), - ("September", "Tháng 9", "tháng 9"), - ("October", "Tháng 10", "tháng 10"), - ("November", "Tháng 11", "tháng 11"), - ("December", "Tháng 12", "tháng 12"), - ]; - for (source, standalone, in_date) in months { - assert_eq!(tr(vi, source), standalone); - assert_eq!(tr_ctx(vi, "calendar date", source), in_date); - } - assert_eq!( - ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"].map(|day| tr_ctx(vi, "calendar weekday", day)), - ["CN", "T2", "T3", "T4", "T5", "T6", "T7"] - ); - assert_eq!(tr_ctx(vi, "certificate", "Subject"), "Chủ thể chứng chỉ"); - assert_eq!(tr(vi, "Subject"), "Chủ đề"); - // Appearance: object/comment/signature look vs Preferences theme section. - assert_eq!(tr(vi, "Appearance"), "Diện mạo"); - assert_eq!(tr_ctx(vi, "theme", "Appearance"), "Giao diện"); - // PDF form Field — never the visual-box sense "ô". - assert_eq!(tr(vi, "Field"), "Trường"); - assert_eq!(tr(vi, "Fields"), "Các trường"); - assert_eq!(tr(vi, "Form fields"), "Các trường biểu mẫu"); - assert_eq!(tr(vi, "Text field"), "Trường văn bản"); - assert_eq!(tr(vi, "Date field"), "Trường ngày"); - assert_eq!(tr(vi, "Image field"), "Trường hình ảnh"); - assert_eq!(tr(vi, "Field properties"), "Thuộc tính trường"); - assert_eq!(tr(vi, "Field list"), "Danh sách trường"); - assert_eq!(tr(vi, "{kind} field"), "Trường {kind}"); - // Redaction: permanent removal, not temporary cover. - assert_eq!(tr(vi, "Mark for redaction"), "Đánh dấu để xóa"); - assert_eq!(tr(vi, "Apply redactions"), "Xóa vĩnh viễn nội dung đã đánh dấu"); - assert_eq!(tr(vi, "Redact text and images"), "Xóa nội dung nhạy cảm trong văn bản và hình ảnh"); - assert_eq!(tr(vi, "Clear form"), "Xóa dữ liệu biểu mẫu"); - // Fill & Sign tool section title — fill-in, not paint fill. - assert_eq!(tr(vi, "Fill"), "Điền"); - assert_eq!(tr(vi, "Fill & Sign"), "Điền & Ký"); - assert_eq!( - tr(vi, "Remove \"{name}\" from this list? The file itself is not deleted."), - "Gỡ \"{name}\" khỏi danh sách này? Bản thân tệp không bị xóa." - ); - for field in [ - "Issued to", - "Issued by", - "Valid from", - "Valid to", - "Intended usage", - "Serial number", - "Issuer", - "Validity starts", - "Validity ends", - "Public key", - "Basic constraints", - "Key usage", - "Self-signed", - "SHA-1 digest", - "SHA-256 digest", - ] { - assert_ne!(tr_ctx(vi, "certificate", field), field, "certificate field remains untranslated: {field}"); - } - } - #[test] fn romanian_is_registered_with_three_plural_forms() { let ro = Lang::from_code("ro").expect("ro registered"); @@ -2865,120 +1440,6 @@ mod tests { for (n, form) in [(1, 0), (0, 1), (2, 1), (19, 1), (101, 1), (119, 1), (20, 2), (100, 2), (120, 2), (1000, 2)] { assert_eq!(plural_ro(n), form, "{n}"); } - assert_eq!(trn(ro, 1, "{n} page", "{n} pages"), "1 pagină"); - assert_eq!(trn(ro, 5, "{n} page", "{n} pages"), "5 pagini"); - assert_eq!(trn(ro, 25, "{n} page", "{n} pages"), "25 de pagini"); - } - - #[test] - fn romanian_covers_bundled_catalog_keys() { - let ro = Lang::from_code("ro").expect("ro registered"); - let mut missing = std::collections::BTreeSet::new(); - for reference in LANGUAGES.iter().filter(|language| language.code != "ro") { - let (entries, errors) = catalog::parse_entries(reference.source, reference.plural_forms()); - assert!(errors.is_empty(), "{}: invalid reference catalog: {errors:?}", reference.code); - for entry in entries { - let translated = match entry.context.as_str() { - "" => ro.catalog().plain(&entry.source), - "@id" => ro.catalog().id(&entry.source), - "@plural" => entry.source.split_once('|').and_then(|(one, other)| ro.catalog().plural(one, other, 0)), - context => ro.catalog().contextual(context, &entry.source), - }; - if translated.is_none() { - missing.insert((entry.context, entry.source)); - } - } - } - assert!(missing.is_empty(), "Romanian misses bundled catalog keys: {missing:#?}"); - } - - #[test] - fn vietnamese_covers_bundled_catalog_keys() { - let vi = Lang::from_code("vi").expect("vi registered"); - let mut missing = std::collections::BTreeSet::new(); - for reference in LANGUAGES.iter().filter(|language| language.code != "vi") { - let (entries, errors) = catalog::parse_entries(reference.source, reference.plural_forms()); - assert!(errors.is_empty(), "{}: invalid reference catalog: {errors:?}", reference.code); - for entry in entries { - let translated = match entry.context.as_str() { - "" => vi.catalog().plain(&entry.source), - "@id" => vi.catalog().id(&entry.source), - "@plural" => entry.source.split_once('|').and_then(|(one, other)| vi.catalog().plural(one, other, 0)), - context => vi.catalog().contextual(context, &entry.source), - }; - if translated.is_none() { - missing.insert((entry.context, entry.source)); - } - } - } - assert!(missing.is_empty(), "Vietnamese misses bundled catalog keys: {missing:#?}"); - } - - #[test] - fn vietnamese_covers_credits_labels() { - let vi = Lang::from_code("vi").expect("vi registered"); - let labels = crate::credits::NameMode::ALL - .iter() - .map(|mode| mode.label()) - .chain(crate::credits::SortKey::ALL.iter().flat_map(|key| { - let (menu, heading) = key.label(); - [menu, heading] - })) - .chain(crate::credits::MODEL_COLUMNS.iter().copied()); - let missing: Vec<_> = labels.filter(|label| !has(vi, label)).collect(); - assert!(missing.is_empty(), "Vietnamese misses credits labels: {missing:#?}"); - } - - #[test] - fn vietnamese_leaves_no_unapproved_english_fallbacks() { - let vi = Lang::from_code("vi").expect("vi registered"); - // Authoritative allowlist for intentional English-identical Vietnamese rows. - const KEEP_AS_IS: &[&str] = &[ - "OK", - "Digital ID", - "ZIP", - "JavaScript", - "Microsoft Word (.docx)", - "PDF/A…", - "PostScript / EPS", - "Rich Text Format (.rtf)", - "CheckBox", - "Combo", - "Home / End", - "PushButton", - "Radio", - "Standard Business", - "Keychain · ", - "{0}: {1}", - "{field}: {e}", - "{month} {y}", - "{n} {kind}", - "{rule} - {status}", - "+LOC", - "−LOC", - "ΔLOC", - "+Bin", - "−Bin", - "`{command}` {when}", - "English", - ]; - const KEY_CAPS: &[&str] = &["Ctrl", "Shift", "Delete", "Esc", "Home", "End", "Space"]; - let (entries, errors) = catalog::parse_entries(vi.0.source, vi.0.plural_forms()); - assert!(errors.is_empty(), "{errors:?}"); - let edges = |s: &str| (s.len() - s.trim_start().len(), s.len() - s.trim_end().len()); - let mut unchanged = Vec::new(); - for e in &entries { - let key_cap = e.context == "key" && KEY_CAPS.contains(&e.source.as_str()); - if e.source == e.translation && !KEEP_AS_IS.contains(&e.source.as_str()) && !key_cap { - unchanged.push((e.context.clone(), e.source.clone())); - } - assert!(!e.translation.contains("..."), "use … rather than three dots: {:?}", e.translation); - assert_eq!(edges(&e.translation), edges(&e.source), "stray whitespace: {:?}", e.translation); - if e.context == "@plural" { - assert!(!e.translation.contains('|'), "Vietnamese @plural is one form: {:?}", e.translation); - } - } - assert!(unchanged.is_empty(), "unapproved English Vietnamese catalog entries: {unchanged:#?}"); } #[test] @@ -2990,15 +1451,8 @@ mod tests { assert_eq!(lang_from_tag(tag), Some(ca), "{tag}"); } assert_eq!(first_supported("ca-ES\r\nen-US"), Some(ca)); - assert_eq!(tr(ca, "File"), "Fitxer"); - assert_eq!(tr(ca, "Save as…"), "Anomena i desa…"); - assert_eq!(tr(ca, "Cancel"), "Cancel·la"); - assert_eq!(tr_ctx(ca, "comment menu", "Edit"), "Edita"); - assert_eq!(tr_ctx(ca, "signature pad", "Type"), "Escriu"); // one (1), other (0, 2+), as in English assert_eq!((0..=3).map(|n| (ca.0.plural)(n)).collect::>(), [1, 0, 1, 1]); - assert_eq!(trn(ca, 1, "{n} page", "{n} pages"), "1 pàgina"); - assert_eq!(trn(ca, 3, "{n} page", "{n} pages"), "3 pàgines"); let mut app = crate::PdfCraftApp::default(); app.set_option("language", "CA").unwrap(); assert_eq!(app.language, "ca"); @@ -3009,61 +1463,6 @@ mod tests { assert_eq!(restored.language, "ca"); } - /// Catalan translates every registered command, menu, All tools group, section and item. - #[test] - fn catalan_covers_commands_and_catalogue() { - let ca = Lang::from_code("ca").expect("ca registered"); - for command in pdfcraft_engine::commands::COMMANDS { - assert!(has(ca, command.label), "missing command: {}", command.label); - if let Some(menu) = command.menu { - assert!(has(ca, menu), "missing menu: {menu}"); - } - } - for group in pdfcraft_engine::catalog::TOOL_GROUPS { - assert!(has(ca, group.label), "missing group: {}", group.label); - for section in group.sections { - assert!(has(ca, section.title), "missing section: {}", section.title); - for item in section.items { - assert!(has(ca, item.label), "missing item: {}", item.label); - } - } - } - } - - /// Catalan translates every `tl!("…")` literal in the UI source, so a new string can't ship in - /// English by accident. - #[test] - fn catalan_covers_ui_literals() { - let ca = Lang::from_code("ca").expect("ca registered"); - let literals = ui_literals(); - assert!(literals.len() > 900, "source scan found only {} literals", literals.len()); - let missing: Vec<_> = literals.iter().filter(|label| !has(ca, label)).collect(); - assert!(missing.is_empty(), "untranslated Catalan UI literals: {missing:#?}"); - } - - /// Catalan has every key the other bundled catalogs have (English keys, not their translations). - #[test] - fn catalan_covers_bundled_catalog_keys() { - let ca = Lang::from_code("ca").expect("ca registered"); - let mut missing = std::collections::BTreeSet::new(); - for reference in LANGUAGES.iter().filter(|language| language.code != "ca") { - let (entries, errors) = catalog::parse_entries(reference.source, reference.plural_forms()); - assert!(errors.is_empty(), "{}: invalid reference catalog: {errors:?}", reference.code); - for entry in entries { - let translated = match entry.context.as_str() { - "" => ca.catalog().plain(&entry.source), - "@id" => ca.catalog().id(&entry.source), - "@plural" => entry.source.split_once('|').and_then(|(one, other)| ca.catalog().plural(one, other, 0)), - context => ca.catalog().contextual(context, &entry.source), - }; - if translated.is_none() { - missing.insert((entry.context, entry.source)); - } - } - } - assert!(missing.is_empty(), "Catalan misses bundled catalog keys: {missing:#?}"); - } - #[test] fn slovene_is_registered_and_persists() { let sl = Lang::from_code("sl").expect("sl registered"); @@ -3073,14 +1472,7 @@ mod tests { assert_eq!(lang_from_tag(tag), Some(sl), "{tag}"); } assert_eq!(first_supported("sl-SI\r\nen-US"), Some(sl)); - assert_eq!(tr(sl, "File"), "Datoteka"); - assert_eq!(tr(sl, "Save as…"), "Shrani kot…"); - assert_eq!(tr(sl, "Bookmarks"), "Zaznamki"); - assert_eq!(tr(sl, "Layers"), "Plasti"); assert_eq!(tr(sl, "Poročilo {n}.pdf"), "Poročilo {n}.pdf"); - assert_eq!(tr_ctx(sl, "comment menu", "Edit"), "Uredi"); - assert_eq!(tr_ctx(sl, "signature pad", "Type"), "Vtipkaj"); - assert_eq!(tr_ctx(sl, "action wizard", "Start"), "Začni"); // one (1), two (2), few (3, 4), other (0, 5+) assert_eq!((0..=5).map(|n| (sl.0.plural)(n)).collect::>(), [3, 0, 1, 2, 2, 3]); let mut app = crate::PdfCraftApp::default(); @@ -3102,12 +1494,8 @@ mod tests { assert_eq!(lang_from_tag(tag), Some(sq), "{tag}"); } assert_eq!(first_supported("sq-AL\r\nen-US"), Some(sq)); - assert_eq!(tr(sq, "File"), "Skedari"); // one (1), other (0, 2+) assert_eq!((0..=3).map(|n| (sq.0.plural)(n)).collect::>(), [1, 0, 1, 1]); - assert_eq!(trn(sq, 1, "{n} page", "{n} pages"), "1 faqe"); - assert_eq!(trn(sq, 0, "{n} page", "{n} pages"), "0 faqe"); - assert_eq!(trn(sq, 5, "{n} page", "{n} pages"), "5 faqe"); let mut app = crate::PdfCraftApp::default(); app.set_option("language", "SQ").unwrap(); assert_eq!(app.language, "sq"); @@ -3118,125 +1506,22 @@ mod tests { assert_eq!(restored.language, "sq"); } - /// Albanian translates every registered command and every All tools group, section and item. - #[test] - fn albanian_covers_commands_and_catalogue() { - let sq = Lang::from_code("sq").expect("sq registered"); - for command in pdfcraft_engine::commands::COMMANDS { - assert!(has(sq, command.label), "missing command: {}", command.label); - if let Some(menu) = command.menu { - assert!(has(sq, menu), "missing menu: {menu}"); - } - } - for group in pdfcraft_engine::catalog::TOOL_GROUPS { - assert!(has(sq, group.label), "missing group: {}", group.label); - for section in group.sections { - assert!(has(sq, section.title), "missing section: {}", section.title); - for item in section.items { - assert!(has(sq, item.label), "missing item: {}", item.label); - } - } - } - } - - /// New tl!("literal") labels must not silently fall back to English. - #[test] - fn albanian_covers_ui_literals() { - let sq = Lang::from_code("sq").expect("sq registered"); - let missing: Vec<_> = scan_ui_literals(false).into_iter().filter(|label| !has(sq, label)).collect(); - assert!(missing.is_empty(), "untranslated Albanian UI literals: {missing:#?}"); - } - #[test] fn slovene_plurals_cover_dual_few_teens_and_large_counts() { let sl = Lang::from_code("sl").expect("sl registered"); - for (counts, page, field) in [ - (vec![1, 101, 201, 1001, 1101], "stran", "polje"), - (vec![2, 102, 202, 1002], "strani", "polji"), - (vec![3, 4, 103, 104, 1003], "strani", "polja"), - (vec![0, 5, 10, 11, 12, 13, 14, 15, 20, 21, 22, 23, 24, 25, 31, 100, 111, 112, 121, 122, 1000, u64::MAX], "strani", "polj"), + assert_eq!(sl.0.plural_forms(), 4); + for (counts, form) in [ + (vec![1, 101, 201, 1001, 1101], 0), + (vec![2, 102, 202, 1002], 1), + (vec![3, 4, 103, 104, 1003], 2), + (vec![0, 5, 10, 11, 12, 13, 14, 15, 20, 21, 22, 23, 24, 25, 31, 100, 111, 112, 121, 122, 1000, u64::MAX], 3), ] { for n in counts { - assert_eq!(trn(sl, n, "{n} page", "{n} pages"), format!("{n} {page}")); - assert_eq!(trn(sl, n, "{n} field", "{n} fields"), format!("{n} {field}")); + assert_eq!((sl.0.plural)(n), form, "{n}"); } } } - #[test] - fn slovene_diagnostics_preserve_user_values() { - let sl = Lang::from_code("sl").expect("sl registered"); - set_current(sl); - assert_eq!(action_label("Custom action {n}"), "Custom action {n}"); - assert_eq!(fmt(t("{n} pages selected"), &[("n", "3")]), "Izbranih strani: 3"); - assert_eq!(tr(sl, "CheckBox"), "Potrditveno polje"); - set_current(Lang::EN); - } - - /// Slovene translates every registered command and every All tools group, section and item. - #[test] - fn slovene_covers_commands_and_catalogue() { - let sl = Lang::from_code("sl").expect("sl registered"); - for command in pdfcraft_engine::commands::COMMANDS { - assert!(has(sl, command.label), "missing command: {}", command.label); - if let Some(menu) = command.menu { - assert!(has(sl, menu), "missing menu: {menu}"); - } - } - for group in pdfcraft_engine::catalog::TOOL_GROUPS { - assert!(has(sl, group.label), "missing group: {}", group.label); - for section in group.sections { - assert!(has(sl, section.title), "missing section: {}", section.title); - for item in section.items { - assert!(has(sl, item.label), "missing item: {}", item.label); - } - } - } - } - - /// New tl!("literal") labels must not silently fall back to English. - #[test] - fn slovene_covers_ui_literals() { - let sl = Lang::from_code("sl").expect("sl registered"); - let mut stack = vec![std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src")]; - let mut literals = std::collections::BTreeSet::new(); - while let Some(dir) = stack.pop() { - for entry in std::fs::read_dir(dir).expect("UI source directory") { - let path = entry.expect("UI source entry").path(); - if path.is_dir() { - if path.file_name().is_some_and(|name| name != "i18n") { - stack.push(path); - } - } else if path.extension().is_some_and(|ext| ext == "rs") { - let source = std::fs::read_to_string(path).expect("UI source file").replace("\r\n", "\n"); - let mut rest = source.split("#[cfg(test)]\nmod ").next().unwrap_or_default(); - while let Some((_, after)) = rest.split_once("tl!(\"") { - let mut escaped = false; - let end = after - .char_indices() - .find_map(|(i, c)| { - if c == '"' && !escaped { - return Some(i); - } - escaped = c == '\\' && !escaped; - None - }) - .expect("closed tl! literal"); - let (raw, tail) = after.split_at(end); - if tail.starts_with("\")") { - let label: String = serde_json::from_str(&format!("\"{raw}\"")).expect("UI literal escapes"); - literals.insert(label); - } - rest = tail.strip_prefix('"').expect("closing quote"); - } - } - } - } - assert!(literals.len() > 900, "source scan found only {} literals", literals.len()); - let missing: Vec<_> = literals.iter().filter(|label| !has(sl, label)).collect(); - assert!(missing.is_empty(), "untranslated Slovene UI literals: {missing:#?}"); - } - #[test] fn indonesian_and_vietnamese_are_registered_and_persist() { let id = Lang::from_code("id").expect("id registered"); @@ -3253,25 +1538,9 @@ mod tests { } assert_eq!(first_supported("id-ID\r\nen-US"), Some(id)); assert_eq!(first_supported("vi-VN\r\nen-US"), Some(vi)); - assert_eq!(tr(id, "Save"), "Simpan"); - assert_eq!(tr(id, "Save as…"), "Simpan sebagai…"); - assert_eq!(tr(id, "Bookmarks"), "Bookmark"); - assert_eq!(tr(id, "Fill & Sign"), "Isi & Tanda Tangani"); - assert_eq!(tr(vi, "Save"), "Lưu"); - assert_eq!(tr(vi, "Save as…"), "Lưu thành…"); - assert_eq!(tr(vi, "Bookmarks"), "Dấu trang"); - assert_eq!(tr(vi, "Fill & Sign"), "Điền & Ký"); - assert_eq!(tr(vi, "Interface language"), "Ngôn ngữ giao diện"); - assert_eq!(tr(id, "Interface language"), "Bahasa antarmuka"); // One CLDR plural form (CLDR `id`, `vi`): the message reads the same for every count. assert_eq!((0..=3).map(|n| (id.0.plural)(n)).collect::>(), [0, 0, 0, 0]); assert_eq!((0..=3).map(|n| (vi.0.plural)(n)).collect::>(), [0, 0, 0, 0]); - for n in [0, 1, 2, 5] { - assert_eq!(trn(id, n, "{n} page", "{n} pages"), format!("{n} halaman")); - assert_eq!(trn(vi, n, "{n} page", "{n} pages"), format!("{n} trang")); - assert_eq!(trn(id, n, "{n} field", "{n} fields"), format!("{n} kolom")); - assert_eq!(trn(vi, n, "{n} field", "{n} fields"), format!("{n} trường")); - } let mut app = crate::PdfCraftApp::default(); app.set_option("language", "ID").unwrap(); assert_eq!(app.language, "id"); @@ -3288,74 +1557,6 @@ mod tests { assert_eq!(restored.language, "vi"); } - /// Indonesian and Vietnamese translate every registered command and every All tools group, - /// section and item. - #[test] - fn indonesian_and_vietnamese_cover_commands_and_catalogue() { - for lang in [Lang::from_code("id").expect("id registered"), Lang::from_code("vi").expect("vi registered")] { - for command in pdfcraft_engine::commands::COMMANDS { - assert!(has(lang, command.label), "{} missing command: {}", lang.code(), command.label); - if let Some(menu) = command.menu { - assert!(has(lang, menu), "{} missing menu: {menu}", lang.code()); - } - } - for group in pdfcraft_engine::catalog::TOOL_GROUPS { - assert!(has(lang, group.label), "{} missing group: {}", lang.code(), group.label); - for section in group.sections { - assert!(has(lang, section.title), "{} missing section: {}", lang.code(), section.title); - for item in section.items { - assert!(has(lang, item.label), "{} missing item: {}", lang.code(), item.label); - } - } - } - } - } - - /// Every `tl!("…")` literal in the UI source is translated in Indonesian and Vietnamese, so a - /// new label cannot silently fall back to English in either. - #[test] - fn indonesian_and_vietnamese_cover_ui_literals() { - let mut stack = vec![std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src")]; - let mut literals = std::collections::BTreeSet::new(); - while let Some(dir) = stack.pop() { - for entry in std::fs::read_dir(dir).expect("UI source directory") { - let path = entry.expect("UI source entry").path(); - if path.is_dir() { - if path.file_name().is_some_and(|name| name != "i18n") { - stack.push(path); - } - } else if path.extension().is_some_and(|ext| ext == "rs") { - let source = std::fs::read_to_string(path).expect("UI source file").replace("\r\n", "\n"); - let mut rest = source.split("#[cfg(test)]\nmod ").next().unwrap_or_default(); - while let Some((_, after)) = rest.split_once("tl!(\"") { - let mut escaped = false; - let end = after - .char_indices() - .find_map(|(i, c)| { - if c == '"' && !escaped { - return Some(i); - } - escaped = c == '\\' && !escaped; - None - }) - .expect("closed tl! literal"); - let (raw, tail) = after.split_at(end); - if tail.starts_with("\")") { - let label: String = serde_json::from_str(&format!("\"{raw}\"")).expect("UI literal escapes"); - literals.insert(label); - } - rest = tail.strip_prefix('"').expect("closing quote"); - } - } - } - } - assert!(literals.len() > 900, "source scan found only {} literals", literals.len()); - for lang in [Lang::from_code("id").expect("id registered"), Lang::from_code("vi").expect("vi registered")] { - let missing: Vec<_> = literals.iter().filter(|label| !has(lang, label)).collect(); - assert!(missing.is_empty(), "untranslated {} UI literals: {missing:#?}", lang.code()); - } - } - #[test] fn hindi_and_korean_are_registered_and_persist() { let hi = Lang::from_code("hi").expect("hi registered"); @@ -3372,26 +1573,9 @@ mod tests { } assert_eq!(first_supported("hi-IN\r\nen-US"), Some(hi)); assert_eq!(first_supported("ko-KR\r\nen-US"), Some(ko)); - assert_eq!(tr(hi, "Save"), "सहेजें"); - assert_eq!(tr(hi, "Save as…"), "इस रूप में सहेजें…"); - assert_eq!(tr(hi, "Bookmarks"), "बुकमार्क"); - assert_eq!(tr(hi, "Home"), "होम"); - assert_eq!(tr(hi, "Interface language"), "इंटरफ़ेस भाषा"); - assert_eq!(tr(ko, "Save"), "저장"); - assert_eq!(tr(ko, "Save as…"), "다른 이름으로 저장…"); - assert_eq!(tr(ko, "Bookmarks"), "책갈피"); - assert_eq!(tr(ko, "Home"), "홈"); - assert_eq!(tr(ko, "Interface language"), "인터페이스 언어"); // One plural form per catalog: the message reads the same for every count. assert_eq!((0..=3).map(|n| (hi.0.plural)(n)).collect::>(), [0, 0, 0, 0]); assert_eq!((0..=3).map(|n| (ko.0.plural)(n)).collect::>(), [0, 0, 0, 0]); - for n in [0, 1, 2, 5] { - assert_eq!(trn(hi, n, "{n} page", "{n} pages"), format!("{n} पृष्ठ")); - assert_eq!(trn(ko, n, "{n} page", "{n} pages"), format!("{n}페이지")); - assert_eq!(trn(ko, n, "{n} field", "{n} fields"), format!("필드 {n}개")); - } - // The broken-line regression: a `\n` escape stays one catalog line. - assert_eq!(tr(ko, "This page couldn't be displayed.\n{e}"), "이 페이지를 표시할 수 없습니다.\n{e}"); for code in ["hi", "ko"] { let mut app = crate::PdfCraftApp::default(); app.set_option("language", &code.to_uppercase()).unwrap(); @@ -3404,104 +1588,6 @@ mod tests { } } - /// Redaction first marks a region; applying it irreversibly removes marked content. - /// Korean must not present either stage as censorship ("검열") or imply that - /// clearing un-applied marks deletes PDF content. - #[test] - fn korean_redaction_labels_distinguish_marks_and_permanent_removal() { - let ko = Lang::from_code("ko").expect("ko registered"); - for english in [ - "Redact", - "Redact a PDF", - "Redact text and images", - "Redact pages…", - "Mark for redaction", - "Apply redactions", - "Apply redactions…", - "Clear redaction marks", - "Redact all", - ] { - assert!(!tr(ko, english).contains("검열"), "Korean label misstates redaction: {english}"); - } - assert_eq!(tr(ko, "Mark for redaction"), "제거할 영역 표시"); - assert_eq!(tr(ko, "Apply redactions"), "표시된 정보 영구 제거"); - assert_eq!(tr(ko, "Clear redaction marks"), "제거 대상 표시 지우기"); - let warning = concat!( - "You are about to apply 1 redaction mark. ", - "Text, images and drawings under the marks, and comments ", - "and form fields that overlap them, are removed permanently." - ); - assert!(tr(ko, warning).contains("영구적으로 제거됩니다")); - } - - /// Hindi and Korean translate every registered command and every All tools group, section - /// and item. - #[test] - fn hindi_and_korean_cover_commands_and_catalogue() { - for lang in [Lang::from_code("hi").expect("hi registered"), Lang::from_code("ko").expect("ko registered")] { - for command in pdfcraft_engine::commands::COMMANDS { - assert!(has(lang, command.label), "{} missing command: {}", lang.code(), command.label); - if let Some(menu) = command.menu { - assert!(has(lang, menu), "{} missing menu: {menu}", lang.code()); - } - } - for group in pdfcraft_engine::catalog::TOOL_GROUPS { - assert!(has(lang, group.label), "{} missing group: {}", lang.code(), group.label); - for section in group.sections { - assert!(has(lang, section.title), "{} missing section: {}", lang.code(), section.title); - for item in section.items { - assert!(has(lang, item.label), "{} missing item: {}", lang.code(), item.label); - } - } - } - } - } - - /// Every `tl!("…")` literal in the UI source is translated in Hindi and Korean, so a new - /// label cannot silently fall back to English in either. - #[test] - fn hindi_and_korean_cover_ui_literals() { - let mut stack = vec![std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src")]; - let mut literals = std::collections::BTreeSet::new(); - while let Some(dir) = stack.pop() { - for entry in std::fs::read_dir(dir).expect("UI source directory") { - let path = entry.expect("UI source entry").path(); - if path.is_dir() { - if path.file_name().is_some_and(|name| name != "i18n") { - stack.push(path); - } - } else if path.extension().is_some_and(|ext| ext == "rs") { - let source = std::fs::read_to_string(path).expect("UI source file").replace("\r\n", "\n"); - let mut rest = source.split("#[cfg(test)]\nmod ").next().unwrap_or_default(); - while let Some((_, after)) = rest.split_once("tl!(\"") { - let mut escaped = false; - let end = after - .char_indices() - .find_map(|(i, c)| { - if c == '"' && !escaped { - return Some(i); - } - escaped = c == '\\' && !escaped; - None - }) - .expect("closed tl! literal"); - let (raw, tail) = after.split_at(end); - if tail.starts_with("\")") { - let label: String = serde_json::from_str(&format!("\"{raw}\"")).expect("UI literal escapes"); - literals.insert(label); - } - rest = tail.strip_prefix('"').expect("closing quote"); - } - } - } - } - assert!(literals.len() > 900, "source scan found only {} literals", literals.len()); - for lang in [Lang::from_code("hi").expect("hi registered"), Lang::from_code("ko").expect("ko registered")] { - let missing: Vec<_> = literals.iter().filter(|label| !has(lang, label)).collect(); - assert!(missing.is_empty(), "untranslated {} UI literals: {missing:#?}", lang.code()); - } - } - #[test] fn bulgarian_is_registered() { let bg = Lang::from_code("bg").expect("bg registered"); @@ -3510,19 +1596,9 @@ mod tests { assert_eq!(lang_from_tag("bg_BG.UTF-8"), Some(bg)); assert_eq!(lang_from_tag("bg-BG"), Some(bg)); assert_eq!(first_supported("bg-BG\r\nen-US"), Some(bg)); - assert_eq!(tr(bg, "File"), "Файл"); - assert_eq!(tr(bg, "Save as…"), "Запиши като…"); - assert_eq!(tr(bg, "Bookmarks"), "Отметки"); - assert_eq!(tr(bg, "Layers"), "Слоеве"); assert_eq!(tr(bg, "Rapport de l'utilisateur.pdf"), "Rapport de l'utilisateur.pdf"); // one (1), other (0, 2+) assert_eq!((0..=3).map(|n| (bg.0.plural)(n)).collect::>(), [1, 0, 1, 1]); - assert_eq!(trn(bg, 0, "{n} page", "{n} pages"), "0 страници"); - assert_eq!(trn(bg, 1, "{n} page", "{n} pages"), "1 страница"); - assert_eq!(trn(bg, 2, "{n} page", "{n} pages"), "2 страници"); - assert_eq!(trn(bg, 21, "{n} page", "{n} pages"), "21 страници"); - assert_eq!(trn(bg, 1, "{n} field", "{n} fields"), "1 поле"); - assert_eq!(trn(bg, 5, "{n} field", "{n} fields"), "5 полета"); let mut app = crate::PdfCraftApp::default(); app.set_option("language", "bg").unwrap(); assert_eq!(app.language, "bg"); @@ -3531,80 +1607,6 @@ mod tests { assert_eq!(restored.language, "bg"); } - /// Bulgarian translates every registered command and every All tools group, section and item. - #[test] - fn bulgarian_covers_commands_and_catalogue() { - let bg = Lang::from_code("bg").expect("bg registered"); - for command in pdfcraft_engine::commands::COMMANDS { - assert!(has(bg, command.label), "missing command: {}", command.label); - if let Some(menu) = command.menu { - assert!(has(bg, menu), "missing menu: {menu}"); - } - } - for group in pdfcraft_engine::catalog::TOOL_GROUPS { - assert!(has(bg, group.label), "missing group: {}", group.label); - for section in group.sections { - assert!(has(bg, section.title), "missing section: {}", section.title); - for item in section.items { - assert!(has(bg, item.label), "missing item: {}", item.label); - } - } - } - } - - /// New tl!("literal") labels must not silently fall back to English. - #[test] - fn bulgarian_covers_ui_literals() { - let bg = Lang::from_code("bg").expect("bg registered"); - let mut stack = vec![std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src")]; - let mut literals = std::collections::BTreeSet::new(); - while let Some(dir) = stack.pop() { - for entry in std::fs::read_dir(dir).expect("UI source directory") { - let path = entry.expect("UI source entry").path(); - if path.is_dir() { - if path.file_name().is_some_and(|name| name != "i18n") { - stack.push(path); - } - } else if path.extension().is_some_and(|ext| ext == "rs") { - let source = std::fs::read_to_string(path).expect("UI source file").replace("\r\n", "\n"); - let mut rest = source.split("#[cfg(test)]\nmod ").next().unwrap_or_default(); - while let Some((_, after)) = rest.split_once("tl!(\"") { - let mut escaped = false; - let end = after - .char_indices() - .find_map(|(i, c)| { - if c == '"' && !escaped { - return Some(i); - } - escaped = c == '\\' && !escaped; - None - }) - .expect("closed tl! literal"); - let (raw, tail) = after.split_at(end); - if tail.starts_with("\")") { - let label: String = serde_json::from_str(&format!("\"{raw}\"")).expect("UI literal escapes"); - literals.insert(label); - } - rest = tail.strip_prefix('"').expect("closing quote"); - } - } - } - } - assert!(literals.len() > 900, "source scan found only {} literals", literals.len()); - let missing: Vec<_> = literals.iter().filter(|label| !has(bg, label)).collect(); - assert!(missing.is_empty(), "untranslated Bulgarian UI literals: {missing:#?}"); - } - - #[test] - fn bulgarian_diagnostics_preserve_user_values() { - let bg = Lang::from_code("bg").expect("bg registered"); - set_current(bg); - assert_eq!(action_label("Custom action {n}"), "Custom action {n}"); - assert_eq!(fmt(t("{n} pages selected"), &[("n", "3")]), "Избрани страници: 3"); - assert_eq!(tr(bg, "CheckBox"), "Квадратче за отметка"); - set_current(Lang::EN); - } - #[test] fn hungarian_is_registered() { let hu = Lang::from_code("hu").expect("hu registered"); @@ -3613,24 +1615,9 @@ mod tests { assert_eq!(lang_from_tag("hu_HU.UTF-8"), Some(hu)); assert_eq!(lang_from_tag("hu-HU"), Some(hu)); assert_eq!(first_supported("hu-HU\r\nen-US"), Some(hu)); - assert_eq!(tr(hu, "File"), "Fájl"); - assert_eq!(tr(hu, "Save as…"), "Mentés másként…"); - assert_eq!(tr(hu, "Bookmarks"), "Könyvjelzők"); - assert_eq!(tr(hu, "Layers"), "Rétegek"); assert_eq!(tr(hu, "Rapport de l'utilisateur.pdf"), "Rapport de l'utilisateur.pdf"); - assert_eq!(tr_ctx(hu, "signature pad", "Type"), "Gépelés"); - assert_eq!(tr_ctx(hu, "action wizard", "Start"), "Indítás"); - assert_eq!(tr(hu, "Windows store · "), "Windows-tároló · "); - assert_eq!(tr(hu, "Place saved {what}"), "Mentett {what} elhelyezése"); - assert_eq!(tr(hu, "Remove saved {what}"), "Mentett {what} eltávolítása"); - assert_eq!(tr(hu, "Squiggly"), "Hullámos aláhúzás"); // one (1), other (0, 2+) assert_eq!((0..=3).map(|n| (hu.0.plural)(n)).collect::>(), [1, 0, 1, 1]); - assert_eq!(trn(hu, 0, "{n} page", "{n} pages"), "0 oldal"); - assert_eq!(trn(hu, 1, "{n} page", "{n} pages"), "1 oldal"); - assert_eq!(trn(hu, 2, "{n} page", "{n} pages"), "2 oldal"); - assert_eq!(trn(hu, 1, "{n} field", "{n} fields"), "1 mező"); - assert_eq!(trn(hu, 5, "{n} field", "{n} fields"), "5 mező"); let mut app = crate::PdfCraftApp::default(); app.set_option("language", "hu").unwrap(); assert_eq!(app.language, "hu"); @@ -3639,87 +1626,6 @@ mod tests { assert_eq!(restored.language, "hu"); } - /// Hungarian translates every registered command and every All tools group, section and item. - #[test] - fn hungarian_covers_commands_and_catalogue() { - let hu = Lang::from_code("hu").expect("hu registered"); - for command in pdfcraft_engine::commands::COMMANDS { - assert!(has(hu, command.label), "missing command: {}", command.label); - if let Some(menu) = command.menu { - assert!(has(hu, menu), "missing menu: {menu}"); - } - } - for group in pdfcraft_engine::catalog::TOOL_GROUPS { - assert!(has(hu, group.label), "missing group: {}", group.label); - for section in group.sections { - assert!(has(hu, section.title), "missing section: {}", section.title); - for item in section.items { - assert!(has(hu, item.label), "missing item: {}", item.label); - } - } - } - } - - /// New tl!("literal") labels must not silently fall back to English. - #[test] - fn hungarian_covers_ui_literals() { - let hu = Lang::from_code("hu").expect("hu registered"); - let mut stack = vec![std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src")]; - let mut literals = std::collections::BTreeSet::new(); - while let Some(dir) = stack.pop() { - for entry in std::fs::read_dir(dir).expect("UI source directory") { - let path = entry.expect("UI source entry").path(); - if path.is_dir() { - if path.file_name().is_some_and(|name| name != "i18n") { - stack.push(path); - } - } else if path.extension().is_some_and(|ext| ext == "rs") { - let source = std::fs::read_to_string(path).expect("UI source file").replace("\r\n", "\n"); - let mut rest = source.split("#[cfg(test)]\nmod ").next().unwrap_or_default(); - while let Some((_, after)) = rest.split_once("tl!(\"") { - let mut escaped = false; - let end = after - .char_indices() - .find_map(|(i, c)| { - if c == '"' && !escaped { - return Some(i); - } - escaped = c == '\\' && !escaped; - None - }) - .expect("closed tl! literal"); - let (raw, tail) = after.split_at(end); - if tail.starts_with("\")") { - let label: String = serde_json::from_str(&format!("\"{raw}\"")).expect("UI literal escapes"); - literals.insert(label); - } - rest = tail.strip_prefix('"').expect("closing quote"); - } - } - } - } - assert!(literals.len() > 900, "source scan found only {} literals", literals.len()); - let missing: Vec<_> = literals.iter().filter(|label| !has(hu, label)).collect(); - assert!(missing.is_empty(), "untranslated Hungarian UI literals: {missing:#?}"); - } - - #[test] - fn hungarian_history_and_diagnostics_preserve_user_values() { - let hu = Lang::from_code("hu").expect("hu registered"); - set_current(hu); - assert_eq!(command_label("Undo Insert pages from Rapport {n}.pdf"), "Oldalak beszúrása ebből: Rapport {n}.pdf visszavonása"); - assert_eq!(command_label("Redo Fill in Contact {key}"), "Contact {key} kitöltése megismétlése"); - assert_eq!(action_label("Change Title"), "Cím módosítása"); - assert_eq!(action_label("Custom action {n}"), "Custom action {n}"); - assert_eq!( - fmt(t("This page couldn't be displayed.\n{e}"), &[("e", "OS error {n}")]), - "Ezt az oldalt nem sikerült megjeleníteni.\nOS error {n}" - ); - assert_eq!(fmt(t("{n} pages selected"), &[("n", "3")]), "3 oldal kijelölve"); - assert_eq!(tr(hu, "CheckBox"), "Jelölőnégyzet"); - set_current(Lang::EN); - } - #[test] fn ukrainian_is_registered_and_persists() { let uk = Lang::from_code("uk").expect("uk registered"); @@ -3730,16 +1636,7 @@ mod tests { } assert_eq!(first_supported("uk-UA\r\nen-US"), Some(uk)); assert_eq!(first_supported("(\n \"uk-UA\",\n \"en-US\"\n)\n"), Some(uk)); - assert_eq!(tr(uk, "File"), "Файл"); - assert_eq!(tr(uk, "Save as…"), "Зберегти як…"); - assert_eq!(tr(uk, "Bookmarks"), "Закладки"); - assert_eq!(tr(uk, "Layers"), "Шари"); assert_eq!(tr(uk, "Звіт {n}.pdf"), "Звіт {n}.pdf"); - assert_eq!(tr_ctx(uk, "comment menu", "Edit"), "Редагувати"); - assert_eq!(tr_ctx(uk, "signature pad", "Type"), "Ввести"); - assert_eq!(tr_ctx(uk, "action wizard", "Start"), "Почати"); - assert_eq!(tr(uk, "Subject"), "Тема"); - assert_eq!(tr_ctx(uk, "certificate", "Subject"), "Власник сертифіката"); let mut app = crate::PdfCraftApp::default(); app.set_option("language", "UK").unwrap(); assert_eq!(app.language, "uk"); @@ -3753,297 +1650,18 @@ mod tests { #[test] fn ukrainian_integer_plurals_cover_teens_and_large_counts() { let uk = Lang::from_code("uk").expect("uk registered"); - for (counts, page, field) in [ - (vec![1, 21, 31, 101, 121, 1001], "сторінка", "поле"), - (vec![2, 3, 4, 22, 23, 24, 102, 104, 122, 1002], "сторінки", "поля"), - (vec![0, 5, 10, 11, 12, 13, 14, 19, 20, 25, 100, 110, 111, 112, 114, 1000, u64::MAX], "сторінок", "полів"), + assert_eq!(uk.0.plural_forms(), 3); + for (counts, form) in [ + (vec![1, 21, 31, 101, 121, 1001], 0), + (vec![2, 3, 4, 22, 23, 24, 102, 104, 122, 1002], 1), + (vec![0, 5, 10, 11, 12, 13, 14, 19, 20, 25, 100, 110, 111, 112, 114, 1000, u64::MAX], 2), ] { for n in counts { - assert_eq!(trn(uk, n, "{n} page", "{n} pages"), format!("{n} {page}")); - assert_eq!(trn(uk, n, "{n} field", "{n} fields"), format!("{n} {field}")); + assert_eq!((uk.0.plural)(n), form, "{n}"); } } } - #[test] - fn ukrainian_calendar_uses_month_case_for_dates() { - let uk = Lang::from_code("uk").expect("uk registered"); - for (source, standalone, date) in [ - ("January", "Січень", "січня"), - ("February", "Лютий", "лютого"), - ("March", "Березень", "березня"), - ("April", "Квітень", "квітня"), - ("May", "Травень", "травня"), - ("June", "Червень", "червня"), - ("July", "Липень", "липня"), - ("August", "Серпень", "серпня"), - ("September", "Вересень", "вересня"), - ("October", "Жовтень", "жовтня"), - ("November", "Листопад", "листопада"), - ("December", "Грудень", "грудня"), - ] { - assert_eq!(tr(uk, source), standalone); - let month = tr_ctx(uk, "calendar date", source); - assert_eq!(month, date); - assert_eq!(fmt(tr(uk, "{month} {day}, {y}"), &[("month", month), ("day", "9"), ("y", "2026")]), format!("9 {date} 2026")); - // Catalogs without this context retain their existing month translation. - assert_eq!(tr_ctx(Lang::EN, "calendar date", source), source); - assert_eq!(tr_ctx(JA(), "calendar date", source), tr(JA(), source)); - } - } - - /// Preserve only product/format names, abbreviations and templates with no English words. - #[test] - fn ukrainian_catalog_does_not_leave_english_ui_text() { - let uk = Lang::from_code("uk").expect("uk registered"); - let (entries, errors) = parse_entries(uk.0.source, uk.0.plural_forms()); - assert!(errors.is_empty(), "{errors:?}"); - let keep = [ - "OK", - "PDF/A…", - "Microsoft Word (.docx)", - "PostScript / EPS", - // Key-cap names, printed in English on Ukrainian keyboards. - "Home / End", - "JavaScript", - "ZIP", - "{n} {kind}", - "{rule} - {status}", - "{field}: {e}", - "`{command}` {when}", - "{0}: {1}", - "{month} {y}", - "+LOC", - "−LOC", - "ΔLOC", - "+Bin", - "−Bin", - // The distinguished name of a certificate, abbreviated as in X.500. - "DN: ", - ]; - let unchanged: Vec<_> = entries - .iter() - .filter(|e| { - let key_cap = e.context == "key" && KEY_CAP_NAMES.contains(&e.source.as_str()); - e.source == e.translation && !keep.contains(&e.source.as_str()) && !key_cap - }) - .collect(); - assert!(unchanged.is_empty(), "untranslated Ukrainian catalog entries: {unchanged:#?}"); - } - - /// Ukrainian translates every registered command and every All tools label. - #[test] - fn ukrainian_covers_commands_and_catalogue() { - let uk = Lang::from_code("uk").expect("uk registered"); - for command in pdfcraft_engine::commands::COMMANDS { - assert!(has(uk, command.label), "missing command: {}", command.label); - if let Some(menu) = command.menu { - assert!(has(uk, menu), "missing menu: {menu}"); - } - } - for group in pdfcraft_engine::catalog::TOOL_GROUPS { - assert!(has(uk, group.label), "missing group: {}", group.label); - for section in group.sections { - assert!(has(uk, section.title), "missing section: {}", section.title); - for item in section.items { - assert!(has(uk, item.label), "missing item: {}", item.label); - } - } - } - } - - /// Kazakh translates every registered command and every All tools label. - #[test] - fn kazakh_covers_commands_and_catalogue() { - let kk = Lang::from_code("kk").expect("kk registered"); - for command in pdfcraft_engine::commands::COMMANDS { - assert!(has(kk, command.label), "missing command: {}", command.label); - if let Some(menu) = command.menu { - assert!(has(kk, menu), "missing menu: {menu}"); - } - } - for group in pdfcraft_engine::catalog::TOOL_GROUPS { - assert!(has(kk, group.label), "missing group: {}", group.label); - for section in group.sections { - assert!(has(kk, section.title), "missing section: {}", section.title); - for item in section.items { - assert!(has(kk, item.label), "missing item: {}", item.label); - } - } - } - } - /// Preserve only product/format names, abbreviations and templates with no English words. - #[test] - fn kazakh_catalog_does_not_leave_english_ui_text() { - let kk = Lang::from_code("kk").expect("kk registered"); - let (entries, errors) = parse_entries(kk.0.source, kk.0.plural_forms()); - assert!(errors.is_empty(), "{errors:?}"); - let keep = [ - "OK", - "PDF/A…", - "Microsoft Word (.docx)", - "PostScript / EPS", - // Key-cap names, printed in English on Kazakh keyboards. - "Home / End", - "JavaScript", - "ZIP", - "{n} {kind}", - "{rule} - {status}", - "{field}: {e}", - "`{command}` {when}", - "{0}: {1}", - "{month} {y}", - "+LOC", - "−LOC", - "ΔLOC", - "+Bin", - "−Bin", - // The distinguished name of a certificate, abbreviated as in X.500. - "DN: ", - ]; - let unchanged: Vec<_> = entries - .iter() - .filter(|e| { - let key_cap = e.context == "key" && KEY_CAP_NAMES.contains(&e.source.as_str()); - e.source == e.translation && !keep.contains(&e.source.as_str()) && !key_cap - }) - .collect(); - assert!(unchanged.is_empty(), "untranslated Kazakh catalog entries: {unchanged:#?}"); - } - /// Kazakh translates every tl!("literal") in the UI source, including direct lookups and - /// multiline literals, following the complete-catalog check for Simplified Chinese. - #[test] - fn kazakh_covers_ui_literals() { - let kk = Lang::from_code("kk").expect("kk registered"); - let mut stack = vec![std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src")]; - let mut literals = std::collections::BTreeSet::new(); - while let Some(dir) = stack.pop() { - for entry in std::fs::read_dir(dir).expect("UI source directory") { - let path = entry.expect("UI source entry").path(); - if path.is_dir() { - if path.file_name().is_some_and(|name| name != "i18n") { - stack.push(path); - } - } else if path.extension().is_some_and(|ext| ext == "rs") { - let source = std::fs::read_to_string(path).expect("UI source file").replace("\r\n", "\n").replace("crate::i18n::t(", "tl!("); - let mut rest = source.split("#[cfg(test)]\nmod ").next().unwrap_or_default(); - while let Some((_, after)) = rest.split_once("tl!(") { - let after = after.trim_start(); - let Some(after) = after.strip_prefix('"') else { - rest = after; - continue; - }; - let mut escaped = false; - let end = after - .char_indices() - .find_map(|(i, c)| { - if c == '"' && !escaped { - return Some(i); - } - escaped = c == '\\' && !escaped; - None - }) - .expect("closed tl! literal"); - let (raw, tail) = after.split_at(end); - let closing = tail.strip_prefix('"').expect("closing quote").trim_start(); - let closing = closing.strip_prefix(',').unwrap_or(closing).trim_start(); - if closing.starts_with(')') { - let label: String = serde_json::from_str(&format!("\"{raw}\"")).expect("UI literal escapes"); - literals.insert(label); - } - rest = tail.strip_prefix('"').expect("closing quote"); - } - } - } - } - assert!(literals.len() > 900, "source scan found only {} literals", literals.len()); - let missing: Vec<_> = literals.iter().filter(|label| !has(kk, label)).collect(); - assert!(missing.is_empty(), "untranslated Kazakh UI literals: {missing:#?}"); - } - /// Check direct lookups and multiline literals as well as ordinary tl!("literal") calls, - /// following the complete-catalog check for Simplified Chinese. - #[test] - fn ukrainian_covers_ui_literals() { - let uk = Lang::from_code("uk").expect("uk registered"); - let mut stack = vec![std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src")]; - let mut literals = std::collections::BTreeSet::new(); - while let Some(dir) = stack.pop() { - for entry in std::fs::read_dir(dir).expect("UI source directory") { - let path = entry.expect("UI source entry").path(); - if path.is_dir() { - if path.file_name().is_some_and(|name| name != "i18n") { - stack.push(path); - } - } else if path.extension().is_some_and(|ext| ext == "rs") { - let source = std::fs::read_to_string(path).expect("UI source file").replace("\r\n", "\n").replace("crate::i18n::t(", "tl!("); - let mut rest = source.split("#[cfg(test)]\nmod ").next().unwrap_or_default(); - while let Some((_, after)) = rest.split_once("tl!(") { - let after = after.trim_start(); - let Some(after) = after.strip_prefix('"') else { - rest = after; - continue; - }; - let mut escaped = false; - let end = after - .char_indices() - .find_map(|(i, c)| { - if c == '"' && !escaped { - return Some(i); - } - escaped = c == '\\' && !escaped; - None - }) - .expect("closed tl! literal"); - let (raw, tail) = after.split_at(end); - let closing = tail.strip_prefix('"').expect("closing quote").trim_start(); - let closing = closing.strip_prefix(',').unwrap_or(closing).trim_start(); - if closing.starts_with(')') { - let label: String = serde_json::from_str(&format!("\"{raw}\"")).expect("UI literal escapes"); - literals.insert(label); - } - rest = tail.strip_prefix('"').expect("closing quote"); - } - } - } - } - assert!(literals.len() > 900, "source scan found only {} literals", literals.len()); - let missing: Vec<_> = literals.iter().filter(|label| !has(uk, label)).collect(); - assert!(missing.is_empty(), "untranslated Ukrainian UI literals: {missing:#?}"); - } - - #[test] - fn ukrainian_history_and_diagnostics_preserve_user_values() { - let uk = Lang::from_code("uk").expect("uk registered"); - set_current(uk); - assert_eq!(command_label("Undo Insert pages from Звіт {n}.pdf"), "Скасувати: Вставити сторінки з Звіт {n}.pdf"); - assert_eq!(command_label("Redo Fill in Контакт {key}"), "Повторити: Заповнити Контакт {key}"); - assert_eq!(action_label("Change Title"), "Змінити: Заголовок"); - assert_eq!(action_label("Custom action {n}"), "Custom action {n}"); - assert_eq!(fmt(t("This page couldn't be displayed.\n{e}"), &[("e", "OS error {n}")]), "Не вдалося відобразити цю сторінку.\nOS error {n}"); - assert_eq!(fmt(t("{n} pages selected"), &[("n", "3")]), "Вибрано сторінок: 3"); - assert_eq!(fmt(t("{n} contributors · {c} commits"), &[("n", "2"), ("c", "1,234")]), "Учасників: 2 · Комітів: 1,234"); - assert_eq!(tr(uk, "CheckBox"), "Прапорець"); - let contributor = crate::credits::Contributor { - login: "reader{n}", - display_name: Some("Save"), - real_name: None, - prs: 2, - commits: 3, - lines_added: 10, - lines_deleted: 4, - binary_added: 1, - binary_deleted: 0, - first_commit: "2026-10-01T00:00:00Z", - last_commit: "2026-10-09T00:00:00Z", - }; - assert_eq!( - contributor.summary(), - "@reader{n}: PR: 2, комітів: 3, +10 / −4 рядків (Δ +6), +1 / −0 бінарних ресурсів, 2026-10-01 – 2026-10-09" - ); - assert_eq!(contributor.name(crate::credits::NameMode::DisplayName), "Save"); - set_current(Lang::EN); - } - fn arabic() -> Lang { Lang::from_code("ar").expect("ar registered") } @@ -4060,10 +1678,6 @@ mod tests { assert_eq!(lang_from_tag(tag), Some(ar), "{tag}"); } assert_eq!(first_supported("ar-MA\r\nen-US"), Some(ar)); - assert_eq!(tr(ar, "File"), "ملف"); - assert_eq!(tr(ar, "Bookmarks"), "المرجعية الإشارات"); - assert_eq!(tr_ctx(ar, "comment menu", "Edit"), "تحرير"); - assert_eq!(tr_ctx(ar, "certificate", "Subject"), "الشهادة صاحب"); // Unknown strings and user text are never reordered by a lookup. assert_eq!(tr(ar, "تقرير نهائي {n}.pdf"), "تقرير نهائي {n}.pdf"); let mut app = crate::PdfCraftApp::default(); @@ -4107,41 +1721,6 @@ mod tests { assert_eq!([3, 10, 103, 110, 1003].map(form), [3; 5]); assert_eq!([11, 99, 111, 199, 1011].map(form), [4; 5]); assert_eq!([100, 101, 102, 200, 1000, u64::MAX - 13].map(form), [5; 6]); - assert_eq!(trn(ar, 1, "{n} page", "{n} pages"), "صفحة 1"); - assert_eq!(trn(ar, 2, "{n} page", "{n} pages"), "صفحتان 2"); - assert_eq!(trn(ar, 5, "{n} page", "{n} pages"), "صفحات 5"); - assert_eq!(trn(ar, 11, "{n} field", "{n} fields"), "حقلًا 11"); - assert_eq!(trn(ar, 100, "{n} field", "{n} fields"), "حقل 100"); - } - - /// Arabic translates every registered command, menu and All tools label. - #[test] - fn arabic_covers_commands_and_catalogue() { - let ar = arabic(); - for command in pdfcraft_engine::commands::COMMANDS { - assert!(has(ar, command.label), "missing command: {}", command.label); - if let Some(menu) = command.menu { - assert!(has(ar, menu), "missing menu: {menu}"); - } - } - for group in pdfcraft_engine::catalog::TOOL_GROUPS { - assert!(has(ar, group.label), "missing group: {}", group.label); - for section in group.sections { - assert!(has(ar, section.title), "missing section: {}", section.title); - for item in section.items { - assert!(has(ar, item.label), "missing item: {}", item.label); - } - } - } - } - - /// Arabic translates every `tl!("…")` literal in the UI source. - #[test] - fn arabic_covers_ui_literals() { - let literals = ui_literals(); - assert!(literals.len() > 900, "source scan found only {} literals", literals.len()); - let missing: Vec<_> = literals.iter().filter(|label| !has(arabic(), label)).collect(); - assert!(missing.is_empty(), "untranslated Arabic UI literals: {missing:#?}"); } /// With a craft-fonts Arabic face, every Arabic translation has glyphs: with all interface @@ -4189,6 +1768,93 @@ mod tests { } } + /// The translation gaps report (`cargo xtask i18n-report`, a step of `cargo xtask ci`): for + /// each language, how many English strings it has no translation for, and the first few. + /// It never fails: a missing string shows in English. + /// + /// The English strings are the UI source's literals, the commands, All tools, the engine's + /// own wordings, and every key of every bundled catalog (which covers contextual, plural and + /// dynamically looked-up strings the scan can't see). + #[test] + fn translation_gaps_report() { + use std::collections::BTreeSet; + const SHOWN: usize = 3; + let mut plain: BTreeSet = ui_literals(); + let mut add = |s: &str| { + plain.insert(s.to_string()); + }; + for command in pdfcraft_engine::commands::COMMANDS { + add(command.label); + command.menu.into_iter().for_each(&mut add); + } + for group in pdfcraft_engine::catalog::TOOL_GROUPS { + add(group.label); + for section in group.sections { + add(section.title); + section.items.iter().for_each(|item| add(item.label)); + } + } + let engine: [&[&str]; 9] = [ + pdfcraft_engine::a11y::WORDINGS, + pdfcraft_engine::a11y::REPORT_WORDS, + pdfcraft_engine::pdfa::WORDINGS, + pdfcraft_engine::sign::pdf::WORDINGS, + pdfcraft_engine::sign::pdf::kind::ALL, + pdfcraft_engine::print::WORDINGS, + pdfcraft_engine::EDIT_LABELS, + pdfcraft_engine::BATCH_LABELS, + pdfcraft_engine::field_table::WORDINGS, + ]; + engine.into_iter().flatten().for_each(|s| add(s)); + pdfcraft_engine::sign::AppearanceLabels::default().fields().into_iter().for_each(|(_, label)| add(label)); + crate::credits::NameMode::ALL.iter().for_each(|mode| add(mode.label())); + for key in crate::credits::SortKey::ALL { + let (label, header) = key.label(); + add(label); + add(header); + } + crate::credits::MODEL_COLUMNS.iter().for_each(|s| add(s)); + // (context, source) keys; `@id` rows only reword a command label, which is in `plain`. + let mut keys: BTreeSet<(String, String)> = plain.into_iter().map(|s| (String::new(), s)).collect(); + for language in &LANGUAGES { + let (entries, _) = parse_entries(language.source, language.plural_forms()); + keys.extend(entries.into_iter().filter(|e| e.context != "@id").map(|e| (e.context, e.source))); + } + let mut report = format!("Translation gaps: {} English strings\n", keys.len()); + for lang in Lang::all().filter(|l| *l != Lang::EN) { + let catalog = lang.catalog(); + let missing: Vec<&(String, String)> = keys + .iter() + .filter(|(context, source)| match context.as_str() { + "" => catalog.plain(source).is_none(), + "@plural" => source.split_once('|').is_none_or(|(one, other)| catalog.plural(one, other, 0).is_none()), + // A contextual lookup falls back to the plain translation. + context => catalog.contextual(context, source).is_none() && catalog.plain(source).is_none(), + }) + .collect(); + let mut line = format!(" {:<8} {:>5} missing", lang.code(), missing.len()); + for (i, (context, source)) in missing.iter().take(SHOWN).enumerate() { + let mut shown: String = source.chars().take(48).collect(); + if shown.len() < source.len() { + shown.push('…'); + } + let key = if context.is_empty() { format!("{shown:?}") } else { format!("{context} / {shown:?}") }; + line.push_str(if i == 0 { ": " } else { ", " }); + line.push_str(&key); + } + if missing.len() > SHOWN { + line.push_str(", …"); + } + report.push_str(&line); + report.push('\n'); + } + println!("{REPORT_START}\n{report}{REPORT_END}"); + } + + /// The lines `cargo xtask i18n-report` prints from the test output lie between these. + const REPORT_START: &str = "=== translation gaps ==="; + const REPORT_END: &str = "=== end of translation gaps ==="; + /// Preferences ▸ Date format ▸ Language offers exactly the interface languages, in order. #[test] fn date_languages_are_the_interface_languages() { diff --git a/crates/ui-egui/src/motion.rs b/crates/ui-egui/src/motion.rs index c091cbd97..934fc0618 100644 --- a/crates/ui-egui/src/motion.rs +++ b/crates/ui-egui/src/motion.rs @@ -463,22 +463,6 @@ mod tests { let style = ctx.global_style(); assert_eq!((style.animation_time, style.scroll_animation), times, "{effects:?}"); } - // The row's labels are translated: the heading, and On and Off as they suit it. - let de = crate::i18n::Lang::from_code("de").expect("German ships"); - assert_eq!(crate::i18n::tr(de, "Animation effects"), "Animationseffekte"); - let labels: Vec<&str> = - AnimationEffects::ALL.into_iter().map(|choice| crate::i18n::tr_ctx(de, "animation effects", choice.label())).collect(); - assert_eq!(labels, ["Systemeinstellung verwenden", "Ein", "Aus"]); - for lang in crate::i18n::Lang::all() { - for english in ["Animation effects", "On", "Off"] { - let shown = if english == "Animation effects" { - crate::i18n::tr(lang, english) - } else { - crate::i18n::tr_ctx(lang, "animation effects", english) - }; - assert!(lang.code() == "en" || shown != english, "{}: {english} is not translated", lang.code()); - } - } } /// The chrome's hover wash has a duration of its own (`theme::Press`): it eases in with diff --git a/crates/ui-egui/src/object_ui.rs b/crates/ui-egui/src/object_ui.rs index 029dc5bc9..1b090b373 100644 --- a/crates/ui-egui/src/object_ui.rs +++ b/crates/ui-egui/src/object_ui.rs @@ -415,28 +415,3 @@ pub(crate) fn move_error(error: &pdfcraft_engine::EditError) -> String { _ => tl!("These objects could not be moved. Select them again and try a smaller move.").into(), } } - -#[cfg(test)] -mod tests { - #[test] - fn selection_labels_and_refusals_are_translated_in_every_catalog() { - for language in crate::i18n::LANGUAGES.iter().filter(|l| !l.source.is_empty()) { - let lang = crate::i18n::Lang::from_code(language.code).unwrap(); - for label in [ - "Drag the selection to move it. Esc clears the selection.", - "Shift-click to select several objects. Drag empty space to select an area.", - "Select at most 1000 objects at a time.", - "Finish or discard the text being edited before selecting several objects.", - "Move object", - "Move objects", - "Vertical text cannot be moved with this selection.", - "Text used as a clipping path cannot be moved with this selection.", - "These objects could not be moved. Select them again and try a smaller move.", - "This page contains objects that cannot be selected together.", - ] { - assert_ne!(crate::i18n::tr(lang, label), label, "{}: {label}", language.code); - } - assert_ne!(crate::i18n::trn(lang, 2, "{n} object selected", "{n} objects selected"), "2 objects selected", "{}", language.code); - } - } -} diff --git a/crates/ui-egui/src/scroll_speed.rs b/crates/ui-egui/src/scroll_speed.rs index cc3a7e86e..e5a2b453f 100644 --- a/crates/ui-egui/src/scroll_speed.rs +++ b/crates/ui-egui/src/scroll_speed.rs @@ -116,8 +116,5 @@ mod tests { } assert_eq!(ScrollSpeed::parse("FAST"), Some(ScrollSpeed::Fast)); assert_eq!(ScrollSpeed::parse("warp"), None); - // The labels are translated in their own context: they qualify "speed". - let ru = crate::i18n::Lang::from_code("ru").expect("Russian ships"); - assert_eq!(crate::i18n::tr_ctx(ru, "scroll speed", ScrollSpeed::Slow.label()), "Низкая"); } } diff --git a/crates/ui-egui/src/sign_ui.rs b/crates/ui-egui/src/sign_ui.rs index f771987a8..25f92530b 100644 --- a/crates/ui-egui/src/sign_ui.rs +++ b/crates/ui-egui/src/sign_ui.rs @@ -1633,19 +1633,22 @@ mod tests { /// left): never a mix, and never a label signing would refuse. #[test] fn appearance_labels_are_translated_or_all_english() { - use crate::i18n::{Lang, tr}; + use crate::i18n::{Lang, has, tr}; let english = AppearanceLabels::default(); assert_eq!(appearance_labels(Lang::EN), (english.clone(), false)); + // Latin-script catalogs the signature's font can draw: stamped in the language once every + // label is translated there. for code in ["de", "es", "fr", "it", "pt-br", "pl", "tr", "sl", "hu"] { let lang = Lang::from_code(code).unwrap(); + if !english.fields().into_iter().all(|(_, source)| has(lang, source)) { + continue; + } let (labels, in_english) = appearance_labels(lang); assert!(!in_english, "{code}: {labels:?}"); for ((field, ours), (_, source)) in labels.fields().into_iter().zip(english.fields()) { assert_eq!(ours, tr(lang, source), "{code}: {field}"); } } - let (de, _) = appearance_labels(Lang::from_code("de").unwrap()); - assert_eq!((de.signed_by.as_str(), de.reason.as_str()), ("Digital signiert von", "Grund: ")); for code in ["zh-hans", "zh-hant", "ja", "ru", "uk", "bg", "kk", "el", "te", "cs", "lt", "ar"] { assert_eq!(appearance_labels(Lang::from_code(code).unwrap()), (english.clone(), true), "{code}"); } diff --git a/docs/architecture.md b/docs/architecture.md index c6b64da48..40084794b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # PdfCraft architecture -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (xtask ci: nextest, one wasm check) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (xtask ci: translation gaps report) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) How PdfCraft is built today. The design rationale and the planned crates live in `plan/architecture.md` and `plan/adr/` (local-only); this file describes what is on `main`. Each @@ -101,7 +101,8 @@ Every user-facing feature is reachable headlessly (`AGENTS.md` §3): `cargo xtask ci` runs fmt, clippy (`-D warnings`), a check of `pdfcraft-cli` without default features, the tests (`xtask test`: cargo-nextest when installed, else `cargo test`, then the -doctests), the layering check, the WASM check (one `cargo check` over every crate below L8), +doctests), `xtask i18n-report` (the strings each interface language has no translation for; it +never fails), the layering check, the WASM check (one `cargo check` over every crate below L8), `xtask assets` (asset licences and hashes), `xtask parity` (checklist claims must cite real commands, tools and tests) and `xtask deny` (dependency licences). Other tasks: `xtask fuzz`, `xtask corpus` and `xtask check` (robustness sweep over the pinned pdf.js corpus), `xtask text-oracle` (word F1 and reading order against `pdftotext`), @@ -112,5 +113,6 @@ built by `.github/workflows/release.yml` ([releasing.md](releasing.md)). | Date | Change | Summary | |---|---|---| +| 2026-10-23 | minor | `xtask ci` prints the translation gaps (`xtask i18n-report`); a missing translation never fails it | | 2026-10-23 | minor | `xtask ci` runs nextest and doctests, checks the CLI without default features, and checks wasm in one call | | 2026-10-10 | major | Created from the code on main (crate list, `xtask/src/layers.rs`, crate READMEs, `vendor/README.md`) | diff --git a/docs/localization.md b/docs/localization.md index 2d9aff7f5..fc161e99a 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -20,15 +20,15 @@ Whole sentences that the layers below word — what the accessibility checker fo The Ukrainian catalog has 2,047 entries, written from the meaning of the English labels using Ukrainian computing vocabulary. Commands use infinitives; fixed-count messages use count-neutral wording. Plural-aware messages use the three [CLDR Ukrainian integer forms](https://www.unicode.org/cldr/charts/48/supplemental/language_plural_rules.html#uk): `1 сторінка`, `2 сторінки`, `5 сторінок`; 11–14 take the third form, while 21 and 22 return to the first and second. Calendar dates use genitive month names (`9 жовтня 2026`), while headings use standalone names (`Жовтень 2026`). Home recommendation cards grow to fit translated text; the toolbar search hint truncates before the shortcut, with the full label available on hover. -The Lithuanian catalog has 2,585 entries and three integer plural forms. Calendar headings use standalone month names; dates use genitive forms. Coverage tests check commands/tools, scanned literal and direct lookups, and source/context keys in the other bundled catalogs. Calendar, inspector and password-dialog UI regressions check dynamic labels and preserve document values. A native Lithuanian-speaking contributor has reviewed the translation. +The Lithuanian catalog has 2,585 entries and three integer plural forms. Calendar headings use standalone month names; dates use genitive forms. Calendar, inspector and password-dialog UI regressions check dynamic labels and preserve document values. A native Lithuanian-speaking contributor has reviewed the translation. -The Vietnamese catalog has 2,674 entries and one plural form (`plural_none`), covering the current catalog-key union. Preferences theme `Appearance` uses context `theme` → `Giao diện`; object/comment/signature appearance stays plain `Diện mạo`. Form fields use `Trường`; redaction apply wording states permanent removal. Calendar months are numeric (`Tháng 1`…`12` / `tháng 1`…`12`). Coverage tests require every current command, UI literal, bundled key and credit label. A native Vietnamese-speaking contributor has reviewed the translation. Status stays `partial`: no translated help, and engine/OS details stay English inside a translated frame. +The Vietnamese catalog has 2,674 entries and one plural form (`plural_none`), covering the current catalog-key union. Preferences theme `Appearance` uses context `theme` → `Giao diện`; object/comment/signature appearance stays plain `Diện mạo`. Form fields use `Trường`; redaction apply wording states permanent removal. Calendar months are numeric (`Tháng 1`…`12` / `tháng 1`…`12`). A native Vietnamese-speaking contributor has reviewed the translation. Status stays `partial`: no translated help, and engine/OS details stay English inside a translated frame. The Finnish catalog has 2,674 entries: every key of the other bundled catalogs, written from the English labels in ordinary Finnish computing vocabulary and sentence case. Finnish has the English one/other plural rule, with the partitive singular after any number but 1 (`1 sivu`, `2 sivua`, `0 sivua`). Calendar headings use the nominative month (`Lokakuu 2026`); dates use the partitive (`14. lokakuuta 2026`), and Finnish is also a date language for month and weekday names. Any `fi-*` locale selects it; `sv-FI` keeps English until PdfCraft has a Swedish catalog. The bundled Latin faces cover `ä ö å š ž`. The translation is AI-assisted (Claude Code) and has not yet been proofread line by line by a native speaker. The Catalan catalog has 2,592 entries, written from the meaning of the English labels in standard Catalan computing vocabulary. Commands use the second-person imperative (`Desa`, `Obre`, `Cancel·la`) and notices use impersonal or informal wording. Catalan has two plural forms like English (1 is singular; 0 and every other count take the plural: `1 pàgina`, `3 pàgines`). Every `ca-*` locale, including Valencian (`ca_ES@valencia`), uses this catalog. The interpunct of `l·l` is U+00B7, which the bundled Latin faces cover (tested in `tests/it/fonts.rs`). The date picker has Catalan month and weekday names; dates are day-first like French (`dddd d mmmm yyyy`), but after a day the month carries its own preposition, elided before a vowel (`7 de març`, `7 d'abril`, `8 d'octubre`), as Czech and Russian change theirs through `months_with_day`; the year takes `\de` from the pattern (`d mmmm \de yyyy`). A native Catalan speaker (the contributor) has reviewed the translation. -The Romanian catalog (`ro`, Română) has 2,673 entries and three plural forms (`plural_ro`, CLDR `ro`): `one` for 1, `few` for 0 and counts whose last two digits are 1–19 ("2 pagini", "101 pagini"), `other` for the rest, written with "de" ("20 de pagini"). Commands use the noun form common in Romanian software ("Salvare", "Deschidere…"); messages address the user formally. Redaction is "Anonimizare" ("redactare" means editing in Romanian), a watermark "Filigran". Calendar month names are lowercase inside dates (`{day} {month} {y}`). The bundled Inter faces cover ă, â, î, ș, ț (comma below). Coverage tests require every bundled key. Not yet reviewed by a second native speaker; status `partial`: no translated help, and engine/OS details stay English inside a translated frame. +The Romanian catalog (`ro`, Română) has 2,673 entries and three plural forms (`plural_ro`, CLDR `ro`): `one` for 1, `few` for 0 and counts whose last two digits are 1–19 ("2 pagini", "101 pagini"), `other` for the rest, written with "de" ("20 de pagini"). Commands use the noun form common in Romanian software ("Salvare", "Deschidere…"); messages address the user formally. Redaction is "Anonimizare" ("redactare" means editing in Romanian), a watermark "Filigran". Calendar month names are lowercase inside dates (`{day} {month} {y}`). The bundled Inter faces cover ă, â, î, ș, ț (comma below). Not yet reviewed by a second native speaker; status `partial`: no translated help, and engine/OS details stay English inside a translated frame. The Norwegian Bokmål catalog covers every key the other bundled catalogs have, with the English one/other plural rule, Norwegian key names (`Mellomrom`; Shift keeps its name) and month and weekday names for Fill & Sign dates. It was written from the meaning of the English labels; a native-speaking contributor reviewed its terminology (key names, Opacity, Redact, Highlight, Fill & Sign), not yet every line. @@ -61,7 +61,7 @@ Arabic glyphs come from the craft-fonts `Arab` face (Noto Sans Arabic, kept in t File names and document titles in Arabic or Hebrew are drawn with a craft-fonts `Arab` face when the build input has one; on desktop, a character no embedded face has is drawn with one font already installed on the machine (`PDFCRAFT_SYSTEM_FONTS=0` turns this off). The tab strip, the recent-files list and Properties show such names in display order; other places (bookmarks, comments, search results) have the glyphs but may order mixed-direction text wrongly. -Japanese, Simplified Chinese, French, German and Brazilian Portuguese coverage tests, following PhotoCraft's complete-catalog approach, check every registered command, All tools label and `tl!("literal")` in the UI source. Brazilian Portuguese also checks every key in the union of the bundled catalogs. Simplified Chinese additionally checks direct `i18n::t` calls and multiline literals, plus the About tabs, contributor name modes, sort options, table headers and model columns. Generated history labels, contributor summaries and diagnostics are tested with user-supplied names and error details. Catalog coverage does not guarantee glyph coverage: the craft-fonts revision pinned for CI and releases has no `Hans` face small enough to embed (desktop builds embed a `Hans` face of at most 4 MiB from `CRAFT_FONTS_DIR`), and the web build embeds only BIZ UDPGothic Regular. Chinese font delivery remains separate work, as discussed in [#112](https://github.com/storytold/pdfcraft/pull/112) and [#140](https://github.com/storytold/pdfcraft/pull/140). +Catalog coverage does not guarantee glyph coverage: the craft-fonts revision pinned for CI and releases has no `Hans` face small enough to embed (desktop builds embed a `Hans` face of at most 4 MiB from `CRAFT_FONTS_DIR`), and the web build embeds only BIZ UDPGothic Regular. Chinese font delivery remains separate work, as discussed in [#112](https://github.com/storytold/pdfcraft/pull/112) and [#140](https://github.com/storytold/pdfcraft/pull/140). German labels keep nouns capitalized, including tool names inserted into notices. The certificate viewer uses the `certificate` context for `Subject` (the certificate holder), while document properties keep the plain translation (the topic). Search hints reserve room for the shortcut, and Home cards limit their title and description to the available rows; hovering shows the full text when it is shortened. @@ -72,6 +72,7 @@ PdfCraft uses the same system as PhotoCraft (`crates/ui-egui/src/i18n/`): - Strings in the code stay in English and serve as lookup keys. At draw time, `tl!("Save")` returns the text in the current language, and falls back to English when there is no entry. - Each language has a catalog, `crates/ui-egui/src/i18n/.tsv`, with one entry per line: `contextEnglishtranslation`. An empty context marks a plain string. `@id` keys an entry by command id (for example `file.saveAs`), so one menu item can read differently from another with the same English label. `@plural` holds plural forms. Any other context disambiguates an English word with several meanings (`tl_ctx!` in UI code, for example the signature pad's "Type" button against the "Type" column). The header of `ja.tsv` documents the format and escapes. - Placeholders (`{name}`) must appear in both columns. A trailing `…` must be kept. `i18n::fmt` fills placeholders in one pass, so a value such as a file name containing `{n}` is inserted as written. +- A missing entry never fails a build or a test: that string shows in English. `cargo xtask i18n-report`, a step of `cargo xtask ci`, prints for each language how many strings it has no translation for and the first few. It counts every command, All tools label, literal passed to `tl!` or `i18n::t` in the UI source, engine wording (below) and key of the bundled catalogs, so a new English string shows up there until each catalog has it. - Catalogs are validated strictly: unknown escapes, unknown `@` contexts, placeholder or ellipsis mismatches, wrong plural form counts and duplicates are errors. The tests require the bundled catalogs to have none; at run time a bad line is skipped (and logged) and that string shows in English. - Catalogs are assets: each `.tsv` has an `ATTRIBUTION.toml` entry with `kind = "translation"` and its SHA-256, checked by `cargo xtask assets`. After editing a catalog, put the new hash into its entry by hand (`sha256sum crates/ui-egui/src/i18n/.tsv`), because `cargo xtask assets --write` does not touch `ATTRIBUTION.toml`; the hash check is deliberate, so a changed asset gets its provenance re-checked. Without it `cargo xtask assets` and the test `assets::tests::the_real_manifest_passes` fail. - Translations are clean-room: write them from the meaning of the English text, never from another product's string tables. @@ -84,7 +85,7 @@ Some sentences are not the interface's own. The accessibility checker, the PDF/A - `said(&words)` says one of them: the layer looks its own wording up through the closure it is given, then fills the holes with the document's values — a page number, a font name, what the person typed — in one pass, so a value containing `{n}` is inserted as written. - The UI passes `&crate::i18n::words`, which is a catalog lookup, so the sentence reads in the interface language. `in_english` is the same thing in English, and a wording with no entry comes back as it stands, which is the English — a catalog missing one shows an English sentence rather than a gap. - The lists are `a11y::{WORDINGS, REPORT_WORDS}`, `preflight::WORDINGS`, `sign::pdf::{WORDINGS, kind::ALL}`, `print::WORDINGS` and the engine's `{EDIT_LABELS, BATCH_LABELS}`. `a11y::report_html` takes a language tag and a `words` closure, so a saved report reads the way the panel read, down to its `lang` attribute. -- `spanish_covers_what_the_engine_says` requires an entry for every wording in those lists, and `spanish_keeps_the_holes_in_what_the_engine_says` requires each translation to keep the same holes, so the catalog cannot fall behind the engine without a test saying so. Other languages are free to add the entries when they are translated; until then those sentences show in English as before. +- The translation gaps report (`cargo xtask i18n-report`) counts every wording in those lists, so a catalog that lacks one shows there; until it is added, that sentence shows in English. Catalog validation requires each translation to keep the same holes, and `spanish_keeps_the_holes_in_what_the_engine_says` checks the Spanish ones against the lists themselves. Error details that come from the operating system, and faults rather than mistakes (a file that cannot be read, a lock that was poisoned), still carry their own text and are shown as they are. @@ -94,4 +95,4 @@ Error details that come from the operating system, and faults rather than mistak 2. Add one row to `LANGUAGES` in `crates/ui-egui/src/i18n/mod.rs`: code, native name, catalog and plural rule. 3. Add an `ATTRIBUTION.toml` entry for the catalog (`kind = "translation"`), with the catalog's `sha256sum` as `sha256`, then run `cargo xtask assets --write` (it regenerates `ATTRIBUTION.md` only) and `cargo xtask assets`. -The Preferences dropdown, the system-language match and the catalog tests (format, duplicates, placeholders, plural forms, command ids) then pick it up. +The Preferences dropdown, the system-language match, the catalog tests (format, duplicates, placeholders, plural forms, command ids) and the translation gaps report then pick it up. diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 6d950a71c..4ec39848c 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -9965,7 +9965,7 @@ milestone = "M14" status = "partial" commands = ["app.preferences"] tools = [] -evidence = ["crates/ui-egui/src/commands.rs::localized_shortcut_labels_follow_language_and_platform", "crates/ui-egui/src/i18n/mod.rs::keyboard_key_names_have_a_separate_context_and_english_fallback", "crates/ui-egui/tests/it/commands.rs::localized_shortcuts_update_immediately_and_keep_command_ids_and_key_bindings", "crates/ui-egui/src/i18n/mod.rs::windows_display_language_resolves_chinese_scripts", "crates/ui-egui/src/i18n/mod.rs::neutral_locale_does_not_override_display_language", "crates/ui-egui/src/i18n/mod.rs::windows_display_language_query_returns_a_locale_tag", "crates/ui-egui/src/i18n/mod.rs::language_persists_and_invalid_input_keeps_current_language", "crates/ui-egui/src/i18n/mod.rs::bundled_catalogs_are_consistent", "crates/ui-egui/src/i18n/mod.rs::arabic_is_registered_and_persists", "crates/ui-egui/src/i18n/mod.rs::arabic_lookups_are_in_display_order", "crates/ui-egui/src/i18n/mod.rs::arabic_plurals_follow_the_six_cldr_forms", "crates/ui-egui/src/i18n/mod.rs::arabic_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::arabic_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::arabic_labels_have_glyphs", "crates/ui-egui/src/bidi.rs::rtl_labels_keep_placeholders_whole", "crates/ui-egui/src/i18n/mod.rs::japanese_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_history_and_diagnostics_preserve_user_values", "crates/ui-egui/tests/it/control.rs::simplified_chinese_signature_prompts_and_errors_keep_document_state", "crates/ui-egui/tests/it/control.rs::language_switch_preserves_document_and_command_ids", "crates/ui-egui/tests/it/control.rs::japanese_controls_and_search_keep_command_ids", "crates/ui-egui/tests/it/control.rs::japanese_dialogs_errors_and_custom_action_names", "crates/ui-egui/src/i18n/mod.rs::french_is_registered", "crates/ui-egui/src/i18n/mod.rs::french_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::french_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::french_history_and_diagnostics_preserve_user_values", "crates/ui-egui/src/i18n/mod.rs::german_certificate_subject_has_a_distinct_context", "crates/ui-egui/src/widgets.rs::search_placeholder_never_overlaps_the_shortcut", "crates/ui-egui/src/home.rs::translated_card_description_leaves_room_for_the_action", "crates/ui-egui/tests/it/control.rs::german_preferences_and_search_keep_command_ids", "crates/ui-egui/src/i18n/mod.rs::telugu_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::telugu_labels_have_glyphs", "crates/ui-egui/src/i18n/mod.rs::hungarian_is_registered", "crates/ui-egui/src/i18n/mod.rs::hungarian_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::hungarian_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::hungarian_history_and_diagnostics_preserve_user_values", "crates/ui-egui/src/i18n/mod.rs::ukrainian_is_registered_and_persists", "crates/ui-egui/src/i18n/mod.rs::ukrainian_integer_plurals_cover_teens_and_large_counts", "crates/ui-egui/src/i18n/mod.rs::ukrainian_calendar_uses_month_case_for_dates", "crates/ui-egui/src/i18n/mod.rs::ukrainian_catalog_does_not_leave_english_ui_text", "crates/ui-egui/src/i18n/mod.rs::ukrainian_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::ukrainian_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::ukrainian_history_and_diagnostics_preserve_user_values", "crates/ui-egui/src/i18n/mod.rs::spanish_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::spanish_covers_what_the_engine_says", "crates/ui-egui/src/i18n/mod.rs::spanish_keeps_the_holes_in_what_the_engine_says", "crates/print/src/tests.rs::wordings_are_said", "crates/ui-egui/src/i18n/mod.rs::norwegian_bokmal_is_registered_and_persists"] +evidence = ["crates/ui-egui/src/commands.rs::localized_shortcut_labels_follow_language_and_platform", "crates/ui-egui/src/i18n/mod.rs::keyboard_key_names_have_a_separate_context_and_english_fallback", "crates/ui-egui/tests/it/commands.rs::localized_shortcuts_update_immediately_and_keep_command_ids_and_key_bindings", "crates/ui-egui/src/i18n/mod.rs::windows_display_language_resolves_chinese_scripts", "crates/ui-egui/src/i18n/mod.rs::neutral_locale_does_not_override_display_language", "crates/ui-egui/src/i18n/mod.rs::windows_display_language_query_returns_a_locale_tag", "crates/ui-egui/src/i18n/mod.rs::language_persists_and_invalid_input_keeps_current_language", "crates/ui-egui/src/i18n/mod.rs::bundled_catalogs_are_consistent", "crates/ui-egui/src/i18n/mod.rs::arabic_is_registered_and_persists", "crates/ui-egui/src/i18n/mod.rs::arabic_lookups_are_in_display_order", "crates/ui-egui/src/i18n/mod.rs::arabic_plurals_follow_the_six_cldr_forms", "crates/ui-egui/src/i18n/mod.rs::arabic_labels_have_glyphs", "crates/ui-egui/src/bidi.rs::rtl_labels_keep_placeholders_whole", "crates/ui-egui/tests/it/control.rs::simplified_chinese_signature_prompts_and_errors_keep_document_state", "crates/ui-egui/tests/it/control.rs::language_switch_preserves_document_and_command_ids", "crates/ui-egui/tests/it/control.rs::japanese_controls_and_search_keep_command_ids", "crates/ui-egui/tests/it/control.rs::japanese_dialogs_errors_and_custom_action_names", "crates/ui-egui/src/i18n/mod.rs::french_is_registered", "crates/ui-egui/src/widgets.rs::search_placeholder_never_overlaps_the_shortcut", "crates/ui-egui/src/home.rs::translated_card_description_leaves_room_for_the_action", "crates/ui-egui/tests/it/control.rs::german_preferences_and_search_keep_command_ids", "crates/ui-egui/src/i18n/mod.rs::telugu_labels_have_glyphs", "crates/ui-egui/src/i18n/mod.rs::hungarian_is_registered", "crates/ui-egui/src/i18n/mod.rs::ukrainian_is_registered_and_persists", "crates/ui-egui/src/i18n/mod.rs::ukrainian_integer_plurals_cover_teens_and_large_counts", "crates/ui-egui/src/i18n/mod.rs::spanish_keeps_the_holes_in_what_the_engine_says", "crates/print/src/tests.rs::wordings_are_said", "crates/ui-egui/src/i18n/mod.rs::norwegian_bokmal_is_registered_and_persists"] notes = "Shortcut modifiers and key names use a dedicated catalog context across toolbar, menus, palette, Help and tooltips, with English fallback and macOS symbols preserved. Preferences switches Auto (system language)/English/Japanese/Simplified Chinese/Traditional Chinese/Czech/Brazilian Portuguese/German/Spanish/French/Telugu/Hungarian/Ukrainian/Italian/Arabic/Norwegian Bokmål and persists; ui.set language auto|en|ja|zh-hans|zh-hant|cs|pt-br|de|es|fr|te|hu|uk|it|ar|nb and ui.state expose it. TSV catalogs per language (same system as PhotoCraft); every dialog, panel and notice goes through tl!. Japanese, Traditional Chinese, Simplified Chinese, German, Spanish, French and Hungarian cover commands, dialogs, panels, shortcuts and history labels; Brazilian Portuguese covers the current union of catalog keys, including signature and accessibility messages; Czech remains limited to menus. French locales (fr, fr_FR, fr_CA, fr_BE) select the French catalog, German locales (de, de_DE, de_AT, de_CH) the German one, Hungarian locales (hu, hu_HU) the Hungarian one, and Norwegian locales (nb, nb_NO, and no/nn until a Nynorsk catalog exists) the Bokmål one. German search hints reserve shortcut space, Home cards bound translated text, certificate Subject has its own context, and inserted tool names keep their capitalization. The sentences the layers below word for themselves (accessibility findings and the saved report, PDF/A problems, signature details, page-range mistakes, edit labels) are handed over apart from the document's own values and go through the catalog too, through said(&i18n::words); Spanish, Italian and Brazilian Portuguese translate them, Polish the signature details and the changes after signing; strings absent from other catalogs fall back to English. OS error details, and faults rather than mistakes, stay as they are. Auto detects the Windows display language after explicitly named supported locale overrides; neutral C/POSIX launcher locales are skipped on Windows, with Chinese script/region mapping and English fallback. Arabic (ar and every ar-* locale) covers commands, dialogs and panels with the six CLDR plural forms; its labels are put into display order when the catalog loads, because egui joins Arabic letters but lays words out left to right. Missing: a right-to-left layout (Arabic labels sit in the left-to-right layout, a wrapped Arabic paragraph breaks at the wrong end, and text typed into a field is not reordered), Czech dialogs, Chinese and Telugu font delivery in releases (the pinned craft-fonts revision has an Arabic face; its Noto Sans CJK SC is too large to embed whole), the engine's sentences in the other catalogs, a full localization audit." [[feature]] diff --git a/xtask/src/gates.rs b/xtask/src/gates.rs index f64ded099..0212e165a 100644 --- a/xtask/src/gates.rs +++ b/xtask/src/gates.rs @@ -113,6 +113,7 @@ pub fn ci(_: &[String]) -> anyhow::Result<()> { ("clippy", Box::new(|| run_args(&["clippy", "--workspace", "--all-targets", "--", "-D", "warnings"]))), ("cli-no-default-features", Box::new(|| run_args(&["check", "-p", "pdfcraft-cli", "--no-default-features"]))), ("test", Box::new(|| test(&[]))), + ("i18n-report", Box::new(|| i18n_report(&["--workspace".to_string()]))), ("layers", Box::new(|| layers(&[]))), ("wasm", Box::new(|| wasm(&[]))), ("assets", Box::new(|| crate::assets::run(&[]))), @@ -130,6 +131,36 @@ pub fn ci(_: &[String]) -> anyhow::Result<()> { Ok(()) } +/// `cargo xtask i18n-report`: the strings each interface language has no translation for, as +/// counts and the first few keys, from the UI crate's `translation_gaps_report` test. It always +/// passes: a missing string shows in English. `--workspace` runs the test from the workspace +/// test build, which `xtask ci` has just made; without it only the UI crate is built. +pub fn i18n_report(args: &[String]) -> anyhow::Result<()> { + const TEST: &str = "i18n::tests::translation_gaps_report"; + const START: &str = "=== translation gaps ==="; + const END: &str = "=== end of translation gaps ==="; + let scope: &[&str] = if args.iter().any(|a| a == "--workspace") { &["--workspace"] } else { &["-p", "pdfcraft-ui-egui"] }; + let mut c = cargo(); + c.arg("test").args(scope).args(["--lib", "--quiet", "--", TEST, "--exact", "--nocapture"]).stderr(std::process::Stdio::inherit()); + eprintln!("$ cargo test {} --lib --quiet -- {TEST} --exact --nocapture", scope.join(" ")); + let out = match c.output() { + Ok(out) => out, + Err(e) => { + println!("i18n-report: no report, cargo test did not start: {e}"); + return Ok(()); + } + }; + let stdout = String::from_utf8_lossy(&out.stdout); + let report: Vec<&str> = stdout.lines().skip_while(|l| *l != START).skip(1).take_while(|l| *l != END).collect(); + if report.is_empty() { + println!("i18n-report: no report (cargo test exited with {})", out.status); + } + for line in report { + println!("{line}"); + } + Ok(()) +} + /// Dependency licences, bans, sources and advisories (`deny.toml`). Skipped with a hint when /// cargo-deny is not installed, except under CI (`CI` set), where it is required. pub fn deny(_: &[String]) -> anyhow::Result<()> { diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 442fcf8a4..24ddd16d1 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -23,10 +23,11 @@ const COMMANDS: &[(&str, &str, Command)] = &[ ("deny", "Dependency licences, bans, sources and advisories (deny.toml; needs cargo-deny)", gates::deny), ( "ci", - "fmt --check, clippy -D warnings, the CLI without default features, test, layers, wasm, assets, deny, parity (stops at first failure)", + "fmt --check, clippy -D warnings, the CLI without default features, test, i18n-report, layers, wasm, assets, deny, parity (stops at first failure)", gates::ci, ), ("test", "Workspace tests: cargo nextest (cargo test when it is missing), then doctests", gates::test), + ("i18n-report", "Print the strings each interface language lacks (counts and a few keys); never fails", gates::i18n_report), ("assets", "Enforce the asset policy (AGENTS.md §1) against ATTRIBUTION.toml; --write regenerates ATTRIBUTION.md", assets::run), ("corpus", "Fetch the pinned pdf.js test corpus into corpus/ (git-ignored) and verify it; --update-pin re-records the pin", gates::corpus), ("check", "Robustness sweep over corpus/ with pdfcraft-cli; fails on crashes or regressions vs xtask/baselines", gates::check),