feat(i18n): apply russian language wiring patch

This commit is contained in:
Wondarkus
2026-10-10 23:56:21 +04:00
parent a127c6d5c3
commit f5da9d2b58
6 changed files with 1886 additions and 17 deletions
+1 -1
View File
@@ -608,7 +608,7 @@ pub fn status_bar(app: &mut DesignApp, ui: &mut egui::Ui) {
"{} ▾",
crate::i18n::count_label(
&app.ui.language,
if errors == 1 && app.ui.language != "uk" { "error" } else { "errors" },
if errors == 1 && !crate::i18n::category_first(&app.ui.language) { "error" } else { "errors" },
errors
)
)
+7 -7
View File
@@ -2328,15 +2328,15 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo
*changed = true;
}
};
if app.ui.language != "uk" {
if !crate::i18n::category_first(&app.ui.language) {
count_value(ui, ns, &mut changed);
}
let kind = ns["until"]["kind"].as_str().unwrap_or("words").to_string();
egui::ComboBox::from_id_salt(("ns_until", i))
.selected_text(crate::rtl::widget(
ui,
if app.ui.language == "uk" {
format!("{}:", crate::i18n::tr("uk", &kind))
if crate::i18n::category_first(&app.ui.language) {
format!("{}:", crate::i18n::tr(&app.ui.language, &kind))
} else {
crate::i18n::tr(&app.ui.language, &kind).to_owned()
},
@@ -2352,7 +2352,7 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo
}
}
});
if app.ui.language == "uk" {
if crate::i18n::category_first(&app.ui.language) {
count_value(ui, ns, &mut changed);
}
if kind == "chars" {
@@ -2408,8 +2408,8 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo
});
crate::rtl::label(
ui,
if app.ui.language == "uk" {
format!("{}:", crate::i18n::tr("uk", "lines"))
if crate::i18n::category_first(&app.ui.language) {
format!("{}:", crate::i18n::tr(&app.ui.language, "lines"))
} else {
crate::i18n::tr(&app.ui.language, "for").to_owned()
},
@@ -2419,7 +2419,7 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo
l["lines"] = json!(n);
changed = true;
}
if app.ui.language != "uk" {
if !crate::i18n::category_first(&app.ui.language) {
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, if n == 1 { "line" } else { "lines" }));
}
if ui
+98 -8
View File
@@ -7,6 +7,7 @@ mod ar;
mod it;
mod ja;
mod pt_br;
mod ru;
mod uk;
/// Supported interface languages: (code, name in that language).
@@ -21,6 +22,7 @@ pub const LANGUAGES: &[(&str, &str)] = &[
("pt-br", "Português (Brasil)"),
("it", "Italiano"),
("uk", "Українська"),
("ru", "Русский"),
];
/// English → [German, French, Spanish, Japanese, Simplified Chinese].
@@ -2837,6 +2839,7 @@ pub fn tr<'a>(lang: &str, s: &'a str) -> &'a str {
static UKRAINIAN: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
static PORTUGUESE_BR: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
static ITALIAN: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
static RUSSIAN: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
if lang == "ar" {
return ARABIC.get_or_init(|| ar::TABLE.iter().copied().collect()).get(s).copied().unwrap_or(s);
}
@@ -2856,16 +2859,21 @@ pub fn tr<'a>(lang: &str, s: &'a str) -> &'a str {
if lang == "uk" {
return UKRAINIAN.get_or_init(|| uk::TABLE.iter().copied().collect()).get(s).copied().unwrap_or(s);
}
if lang == "ru" {
return RUSSIAN.get_or_init(|| ru::TABLE.iter().copied().collect()).get(s).copied().unwrap_or(s);
}
let Some(c) = column(lang) else { return s };
TRANSLATIONS.get_or_init(|| TABLE.iter().copied().collect()).get(s).and_then(|row| row.get(c)).copied().unwrap_or(s)
}
/// Context-sensitive UI captions; unsupported contexts keep the shared translation.
pub fn tr_context<'a>(lang: &str, s: &'a str, context: &str) -> &'a str {
if lang == "uk" {
return uk::CONTEXT.iter().find(|(key, scope, _)| *key == s && *scope == context).map_or_else(|| tr(lang, s), |(_, _, text)| text);
}
tr(lang, s)
let contexts = match lang {
"uk" => uk::CONTEXT,
"ru" => ru::CONTEXT,
_ => return tr(lang, s),
};
contexts.iter().find(|(key, scope, _)| *key == s && *scope == context).map_or_else(|| tr(lang, s), |(_, _, text)| text)
}
#[cfg(test)]
@@ -2873,10 +2881,21 @@ pub(crate) fn ukrainian_catalog() -> Vec<&'static str> {
uk::TABLE.iter().map(|(_, text)| *text).chain(uk::CONTEXT.iter().map(|(_, _, text)| *text)).collect()
}
/// Count captions in Ukrainian put the category before the value so every count uses
/// a grammatical form; other languages keep the existing value-first caption.
#[cfg(test)]
pub(crate) fn russian_catalog() -> Vec<&'static str> {
ru::TABLE.iter().map(|(_, text)| *text).chain(ru::CONTEXT.iter().map(|(_, _, text)| *text)).collect()
}
/// Ukrainian and Russian have several plural forms, so their count captions put the category
/// before the value ("сторінок: 5", "страниц: 5") and every number stays grammatical; other
/// languages keep the existing value-first caption.
pub fn category_first(lang: &str) -> bool {
matches!(lang, "uk" | "ru")
}
/// Caption for a counted category, e.g. `count_label("ru", "pages", 5)` is "страниц: 5".
pub fn count_label(lang: &str, noun: &str, count: usize) -> String {
if lang == "uk" { format!("{}: {count}", tr(lang, noun)) } else { format!("{count} {}", tr(lang, noun)) }
if category_first(lang) { format!("{}: {count}", tr(lang, noun)) } else { format!("{count} {}", tr(lang, noun)) }
}
/// Localize reserved built-in style names only; user-defined names are document data.
@@ -2922,8 +2941,11 @@ mod tests {
for (key, expected) in uk::TABLE {
assert_eq!(tr("uk", key), *expected, "uk: {key}");
}
for (key, expected) in ru::TABLE {
assert_eq!(tr("ru", key), *expected, "ru: {key}");
}
let unknown = String::from("A user-defined untranslated label");
for lang in ["uk", "ar", "pt-br", "it", "zh", "ja", "de", "fr", "es", "", "unknown"] {
for lang in ["uk", "ru", "ar", "pt-br", "it", "zh", "ja", "de", "fr", "es", "", "unknown"] {
assert!(std::ptr::eq(tr(lang, &unknown), unknown.as_str()));
}
}
@@ -3020,6 +3042,74 @@ mod tests {
assert_eq!(count_label("", "objects", 2), "2 objects");
}
#[test]
fn russian_catalog_covers_existing_keys_and_preserves_placeholders() {
let catalog: std::collections::HashMap<_, _> = ru::TABLE.iter().copied().collect();
assert_eq!(catalog.len(), ru::TABLE.len(), "duplicate Russian keys");
let mut keys = std::collections::HashSet::new();
keys.extend(TABLE.iter().map(|(key, _)| *key));
keys.extend(ar::TABLE.iter().map(|(key, _)| *key));
keys.extend(pt_br::TABLE.iter().map(|(key, _)| *key));
keys.extend(it::TABLE.iter().map(|(key, _)| *key));
keys.extend(ja::TABLE.iter().map(|(key, _)| *key));
keys.extend(uk::TABLE.iter().map(|(key, _)| *key));
// A new English string falls back to English until it is translated, so only stale
// entries (keys no catalog knows any more) fail.
let stale: Vec<_> = catalog.keys().filter(|k| !keys.contains(*k)).collect();
assert!(stale.is_empty(), "Russian entries for unknown keys: {stale:?}");
let placeholders =
|s: &str| s.split('{').skip(1).filter_map(|part| part.split_once('}').map(|(name, _)| name.to_owned())).collect::<Vec<_>>();
for (key, translation) in ru::TABLE {
assert!(!translation.trim().is_empty(), "empty {key}");
assert_eq!(key.starts_with(' '), translation.starts_with(' '), "leading space: {key}");
assert_eq!(key.ends_with(' '), translation.ends_with(' '), "trailing space: {key}");
assert!(!translation.contains(['ґ', 'є', 'і', 'ї', 'Ґ', 'Є', 'І', 'Ї']), "Ukrainian letter in {key}");
let mut source = placeholders(key);
let mut translated = placeholders(translation);
source.sort();
translated.sort();
assert_eq!(source, translated, "placeholders: {key}");
}
}
#[test]
fn russian_is_selectable_ltr_and_resolves_contexts_styles_and_menu_paths() {
assert!(LANGUAGES.contains(&("ru", "Русский")));
assert!(!is_rtl("ru"));
assert_eq!(tr("ru", "File"), "Файл");
assert_eq!(tr("ru", "Type"), "Текст");
assert_eq!(tr("ru", "Spread"), "Разворот");
assert_eq!(tr("ru", "Story"), "Материал");
assert_eq!(tr("ru", "Unknown label"), "Unknown label");
assert_eq!(tr_context("ru", "Columns", "table"), "Столбцы");
assert_eq!(tr("ru", "Columns"), "Колонки");
assert_eq!(tr_context("ru", "Group", "selection"), "Группа");
assert_eq!(tr("ru", "Group"), "Сгруппировать");
assert_eq!(tr_context("ru", "Custom caption", "custom"), "Custom caption");
assert_eq!(tr("ru", "through"), "включительно");
assert_eq!(tr("ru", "up to"), "не включая");
assert_eq!(tr("ru", "Edit/Spelling"), "Редактирование/Орфография");
assert_eq!(tr("ru", "Type/Insert Special Character/Quotation Marks"), "Текст/Вставить специальный символ/Кавычки");
assert_eq!(style_name("ru", "[Basic Paragraph]"), "[Основной абзац]");
assert_eq!(style_name("ru", "My layout style"), "My layout style");
assert_eq!(workspace_name("ru", "Essentials"), "Основное");
assert_eq!(workspace_name("ru", "My workspace"), "My workspace");
// Fixed standard names stay identical.
assert_eq!(tr("ru", "PDF/X-4"), "PDF/X-4");
}
#[test]
fn russian_counts_use_category_captions_for_every_number() {
assert!(category_first("ru") && category_first("uk") && !category_first("de") && !category_first(""));
for n in [0, 1, 2, 4, 5, 11, 12, 21, 22, 25, 101, 111] {
assert_eq!(count_label("ru", "pages", n), format!("страниц: {n}"));
assert_eq!(count_label("ru", "objects", n), format!("объектов: {n}"));
assert_eq!(count_label("ru", "errors", n), format!("ошибок: {n}"));
let pages = tr("ru", "{pages} Pages in {spreads} Spreads").replace("{pages}", &n.to_string()).replace("{spreads}", &n.to_string());
assert_eq!(pages, format!("Страниц: {n}; разворотов: {n}"));
}
}
#[test]
fn japanese_vertical_type_commands_are_translated() {
for (en, ja) in [
File diff suppressed because it is too large Load Diff
+26 -1
View File
@@ -249,6 +249,7 @@ pub const MENUS: &[(&str, &[&str])] = &[
"ui:app.language|Português (Brasil)|{\"lang\": \"pt-br\"}",
"ui:app.language|Italiano|{\"lang\": \"it\"}",
"ui:app.language|Українська|{\"lang\": \"uk\"}",
"ui:app.language|Русский|{\"lang\": \"ru\"}",
"<",
">Transparency Flattener Presets",
"ui:app.flattener|None (keep transparency)|{\"preset\": \"\"}",
@@ -2514,6 +2515,30 @@ mod tests {
}));
}
#[test]
fn russian_language_is_selectable_persisted_and_keeps_document_data() {
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"pages": 4, "facingPages": true})).unwrap();
app.run("frame.create", json!({"rect": [36, 36, 200, 200], "content": "text", "text": "My words · Мои слова"})).unwrap();
let before = serde_json::to_value(&app.session.doc().unwrap().doc).unwrap();
run_ui(&mut app, "app.language", &json!({"lang": "ru"})).unwrap().unwrap();
assert_eq!(checked(&app, "app.language", &json!({"lang": "ru"})), Some(true));
assert_eq!(before, serde_json::to_value(&app.session.doc().unwrap().doc).unwrap());
for (title, _) in menu_tree() {
assert_ne!(crate::i18n::tr("ru", title), title, "{title}");
}
let bytes = serde_json::to_vec(&app.ui).unwrap();
let restored: crate::UiState = serde_json::from_slice(&bytes).unwrap();
assert_eq!(restored.language, "ru");
assert_eq!(crate::i18n::tr(&restored.language, "File"), "Файл");
assert_eq!(crate::i18n::tr(&restored.language, "A custom name"), "A custom name");
assert!(menu_tree().iter().flat_map(|(_, entries)| entries.iter()).any(|entry| {
matches!(entry, Item::Sub(label, children) if label == "Interface Language" && children.iter().any(|child| {
matches!(child, Item::Cmd { label, id, params, .. } if label == "Русский" && id == "app.language" && params["lang"] == "ru")
}))
}));
}
#[test]
fn arabic_dialog_and_language_switch_keep_finite_widget_geometry() {
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
@@ -2541,7 +2566,7 @@ mod tests {
crate::dialogs::confirm(&mut app).unwrap();
frame(&mut app);
assert_eq!(app.session.documents().len(), 1);
for lang in ["uk", "zh", "", "ar", "pt-br", "it"] {
for lang in ["uk", "ru", "zh", "", "ar", "pt-br", "it"] {
app.run("app.language", json!({"lang": lang})).unwrap();
frame(&mut app);
}
+27
View File
@@ -452,3 +452,30 @@ mod ukrainian_font_tests {
}
}
}
#[cfg(test)]
mod russian_font_tests {
#[test]
fn every_catalog_cyrillic_glyph_exists_in_the_bundled_ui_fonts() {
// Same check as for Ukrainian: the installed face data only, without craft-fonts or system fonts.
let definitions = super::font_definitions(&[], "ru");
let db = designcraft_fonts::FontDb::with_font_dirs(Vec::new());
db.set_system_fallback(false);
let characters: std::collections::HashSet<_> = crate::i18n::russian_catalog()
.iter()
.flat_map(|text| text.chars())
.chain("АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдеёжзийклмнопрстуфхцчшщъыьэюя".chars())
.filter(|c| matches!(*c, '\u{0400}'..='\u{052f}'))
.collect();
for (name, family, style) in
[("ui", "Source Sans 3", "Regular"), ("ui-semibold", "Source Sans 3", "Semibold"), ("mono", "JetBrains Mono", "Regular")]
{
let data = &definitions.font_data[name].font;
let face = db.face(family, style);
assert_eq!(face.data(), data.as_ref(), "configured UI face {name}");
for character in &characters {
assert_ne!(face.glyph_for(*character), 0, "missing {character} in {name}");
}
}
}
}