mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-28 20:26:57 -06:00
feat(i18n): apply russian language wiring patch
This commit is contained in:
@@ -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
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user