Merge pull request #804 from vangel76/gap-french-localization

#498: French (fr) interface translation
This commit is contained in:
Brandon Thomas
2026-10-10 18:35:20 -04:00
committed by GitHub
12 changed files with 3672 additions and 32 deletions
+1
View File
@@ -9,6 +9,7 @@ iconography, images or other Adobe assets; open licences only; every asset attri
| Asset | Author | Source | Licence |
|---|---|---|---|
| `crates/ui-egui/src/i18n/fr.tsv` | FilmCraft contributors | Original work: clean-room French interface translations | MIT OR Apache-2.0 |
| `crates/ui-egui/src/i18n/ru.tsv` | FilmCraft contributors | Original work: clean-room Russian interface translations | MIT OR Apache-2.0 |
| `crates/ui-egui/src/i18n/uk.tsv` | FilmCraft contributors | Original work: clean-room Ukrainian interface translations | MIT OR Apache-2.0 |
| `assets/fonts/Inter-Regular.ttf` | The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) |
+1 -1
View File
@@ -78,7 +78,7 @@ Premiere ships 10 interface languages. Measured from our catalogs (3,498 strings
| Spanish | partial (whole interface) | 99.9% |
| Hindi | none (no Devanagari shaping in the interface) | 0% |
| Arabic | none (no RTL interface) | 0% |
| French | none | 0% |
| French | partial (whole interface; native review welcome) | ~100% |
| Portuguese (Brazil) | menus only | 9.9% |
| Indonesian | none | 0% |
| Japanese | partial (whole interface) | 99.7% |
+2
View File
@@ -107,6 +107,8 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver<String>, Sho
filmcraft_ui_egui::i18n::Language::De
} else if checked("app.language.russian") {
filmcraft_ui_egui::i18n::Language::Ru
} else if checked("app.language.french") {
filmcraft_ui_egui::i18n::Language::Fr
} else {
filmcraft_ui_egui::i18n::Language::En
};
+2 -1
View File
@@ -34,7 +34,7 @@ pub const PREFS_VERSION: u32 = 2;
#[serde(rename_all = "camelCase", default)]
pub struct GeneralPrefs {
/// Interface language (persisted independently of projects): `en`, `ja`, `es`, `pt-br`, `uk`,
/// `zh-cn`, `de`, or `system` (the default: the operating system's preferred language when the
/// `zh-cn`, `de`, `ru`, `fr`, or `system` (the default: the operating system's preferred language when the
/// interface has it, otherwise English).
pub interface_language: String,
/// "At Startup": `showHome` (FilmCraft: the demo project), `openMostRecent`, `emptyProject`.
@@ -776,6 +776,7 @@ static CATEGORIES: &[Category] = &[
("zh-cn", "简体中文"),
("de", "Deutsch"),
("ru", "Русский"),
("fr", "Français"),
]),
true,
),
+3 -1
View File
@@ -29,13 +29,15 @@ fn interface_language_persists_and_rejects_hostile_values() {
let mut s = Session { prefs_path: Some(path.clone()), ..Session::default() };
set(&mut s, "general.interfaceLanguage", json!("es"));
assert_eq!(Preferences::load(&path).general.interface_language, "es");
for value in [json!("xx"), json!("es-MX"), json!("de-DE"), json!(null), json!(-1), json!({}), json!("x".repeat(4096))] {
for value in [json!("xx"), json!("es-MX"), json!("de-DE"), json!("fr-FR"), json!(null), json!(-1), json!({}), json!("x".repeat(4096))] {
assert!(s.execute("prefs.set", json!({"key": "general.interfaceLanguage", "value": value})).is_err());
assert_eq!(s.prefs.general.interface_language, "es");
}
assert_eq!(Preferences::load(&path).general.interface_language, "es");
set(&mut s, "general.interfaceLanguage", json!("de"));
assert_eq!(Preferences::load(&path).general.interface_language, "de");
set(&mut s, "general.interfaceLanguage", json!("fr"));
assert_eq!(Preferences::load(&path).general.interface_language, "fr");
// back to following the operating system (#218)
set(&mut s, "general.interfaceLanguage", json!("system"));
assert_eq!(Preferences::load(&path).general.interface_language, "system");
+2 -2
View File
@@ -18,7 +18,7 @@ the committed span and undo. Set `FILMCRAFT_UI_SNAPSHOT_DIR` to render screensho
## Localisation
Edit > Language offers English, Japanese, Spanish, Brazilian Portuguese, Ukrainian, Simplified
Chinese and German. The language is stored in the engine's `general.interfaceLanguage` preference,
Chinese, German, Russian and French. The language is stored in the engine's `general.interfaceLanguage` preference,
restored on startup, and also available in Settings > General.
Its default, System Language (`system`), follows the operating system: the first of the user's
preferred languages that the interface has (the host supplies them through
@@ -32,7 +32,7 @@ fills translated templates, and `i18n::t` translates names from registries. Plac
(including user filenames containing braces) are inserted literally. Catalog translations are
original work using ordinary language, without proprietary localisation resources.
Spanish and Japanese cover menus, panels, dialogs, settings and registry labels. Searches accept both the
Spanish, Japanese, Simplified Chinese and French cover menus, panels, dialogs, settings and registry labels. Searches accept both the
translated label and its English source, including Unicode capitals. Project content, command ids
and preference values retain their original values. Engine errors, CLI and MCP messages remain
English. Brazilian Portuguese and Ukrainian currently cover the core menus, German every menu; they
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,8 @@
asset: fr.tsv
title: French interface translation catalog
author: FilmCraft contributors
source: original work
license: MIT OR Apache-2.0
license-file: LICENSE-MIT and LICENSE-APACHE
added: 2026-10-10 by vangel76
notes: Original English-to-French interface translations using clean-room NLE vocabulary.
+95 -18
View File
@@ -44,6 +44,7 @@ pub enum Language {
ZhCn,
De,
Ru,
Fr,
}
static JAPANESE: OnceLock<Catalog> = OnceLock::new();
@@ -53,9 +54,10 @@ static UKRAINIAN: OnceLock<Catalog> = OnceLock::new();
static CHINESE: OnceLock<Catalog> = OnceLock::new();
static GERMAN: OnceLock<Catalog> = OnceLock::new();
static RUSSIAN: OnceLock<Catalog> = OnceLock::new();
static FRENCH: OnceLock<Catalog> = OnceLock::new();
impl Language {
pub const ALL: [Self; 8] = [Self::En, Self::Ja, Self::Es, Self::PtBr, Self::Uk, Self::ZhCn, Self::De, Self::Ru];
pub const ALL: [Self; 9] = [Self::En, Self::Ja, Self::Es, Self::PtBr, Self::Uk, Self::ZhCn, Self::De, Self::Ru, Self::Fr];
pub fn name(self) -> &'static str {
match self {
@@ -67,6 +69,7 @@ impl Language {
Self::ZhCn => "简体中文",
Self::De => "Deutsch",
Self::Ru => "Русский",
Self::Fr => "Français",
}
}
@@ -80,6 +83,7 @@ impl Language {
"zh-cn" => Some(Self::ZhCn),
"de" => Some(Self::De),
"ru" => Some(Self::Ru),
"fr" => Some(Self::Fr),
_ => None,
}
}
@@ -87,11 +91,12 @@ impl Language {
/// Interface Language ▸ System Language (#218): the first of the user's preferred languages
/// (BCP 47 or POSIX locale tags such as `es-419`, `pt_BR.UTF-8`, most preferred first) that the
/// interface has, else English. Any Portuguese gets the Brazilian catalog, the only one there is;
/// any German (`de-AT`, `de_CH.UTF-8`…) gets the German one. Chinese gets the Simplified catalog
/// any German (`de-AT`, `de_CH.UTF-8`…) gets the German one and any French (`fr-CA`, `fr_BE.UTF-8`…)
/// the French one. Chinese gets the Simplified catalog
/// only for Simplified locales (`zh`, `zh-CN`, `zh-SG`, `zh-Hans…`); Traditional ones (`zh-TW`,
/// `zh-HK`, `zh-MO`, `zh-Hant…`) are skipped.
pub fn from_locales(tags: &[String]) -> Self {
const PRIMARY: [(&str, Language); 7] = [
const PRIMARY: [(&str, Language); 8] = [
("en", Language::En),
("ja", Language::Ja),
("es", Language::Es),
@@ -99,6 +104,7 @@ impl Language {
("uk", Language::Uk),
("de", Language::De),
("ru", Language::Ru),
("fr", Language::Fr),
];
tags.iter()
.find_map(|tag| {
@@ -127,6 +133,7 @@ impl Language {
Self::ZhCn => "zh-cn",
Self::De => "de",
Self::Ru => "ru",
Self::Fr => "fr",
}
}
@@ -141,6 +148,7 @@ impl Language {
Self::ZhCn => Some(CHINESE.get_or_init(|| Catalog::parse(include_str!("zh-cn.tsv")))),
Self::De => Some(GERMAN.get_or_init(|| Catalog::parse(include_str!("de.tsv")))),
Self::Ru => Some(RUSSIAN.get_or_init(|| Catalog::parse(include_str!("ru.tsv")))),
Self::Fr => Some(FRENCH.get_or_init(|| Catalog::parse(include_str!("fr.tsv")))),
}
}
@@ -310,6 +318,7 @@ mod tests {
("zh-cn", include_str!("zh-cn.tsv")),
("de", include_str!("de.tsv")),
("ru", include_str!("ru.tsv")),
("fr", include_str!("fr.tsv")),
] {
let (entries, errors) = catalog::parse_entries(text);
assert!(errors.is_empty(), "{code}: {errors:?}");
@@ -350,6 +359,13 @@ mod tests {
assert_eq!(Language::Ru.tr("File"), "Файл");
assert_eq!(Language::Ru.tr("мой клип.mp4"), "мой клип.mp4");
assert_eq!(Language::Ru.tr("An untranslated label"), "An untranslated label");
assert_eq!(Language::Fr.name(), "Français");
assert_eq!(Language::Fr.tr("File"), "Fichier");
assert_eq!(Language::Fr.tr("Save As…"), "Enregistrer sous…");
assert_eq!(Language::Fr.tr("Montage d'été.mp4"), "Montage d'été.mp4");
assert_eq!(Language::Fr.tr("An untranslated label"), "An untranslated label");
assert_eq!(Language::parse("fr"), Some(Language::Fr));
assert_eq!(Language::parse("fr-FR"), None, "the preference stores the bare code");
for l in Language::ALL {
assert_eq!(Language::parse(l.code()), Some(l));
let json = serde_json::to_string(&l).unwrap();
@@ -801,6 +817,23 @@ mod tests {
let mut restarted = filmcraft_engine::Session::default();
restarted.prefs = serde_json::from_str(&prefs).unwrap();
assert_eq!(crate::FilmcraftApp::new(restarted).ui.language, Language::De);
let result = crate::menus::invoke(&mut app, &ctx, "app.language.french", serde_json::json!({})).unwrap();
assert_eq!(result, serde_json::json!("fr"));
assert_eq!((app.ui.language, current()), (Language::Fr, Language::Fr));
assert_eq!(app.session.prefs.general.interface_language, "fr");
for it in crate::menus::menu_items(&app).iter().filter(|it| it.id.starts_with("app.language.")) {
assert_eq!(it.checked, Some(it.id == "app.language.french"), "{}", it.id);
}
let items = crate::menus::menu_items(&app);
let label = |id: &str| items.iter().find(|it| it.id == id).map(|it| it.label.as_str());
assert_eq!(label("file.saveAs"), Some("Enregistrer sous…"));
assert_eq!(label("app.language.german"), Some("Deutsch"));
let saved = serde_json::to_string(&app.ui).unwrap();
assert!(saved.contains("\"language\":\"fr\""), "{saved}");
let prefs = serde_json::to_string(&app.session.prefs).unwrap();
let mut restarted = filmcraft_engine::Session::default();
restarted.prefs = serde_json::from_str(&prefs).unwrap();
assert_eq!(crate::FilmcraftApp::new(restarted).ui.language, Language::Fr);
crate::menus::invoke(&mut app, &ctx, "app.language.english", serde_json::json!({})).unwrap();
assert_eq!(app.ui.language, Language::En);
set_current(Language::En);
@@ -829,11 +862,15 @@ mod tests {
for tag in ["de", "DE", "de-DE", "de-AT", "de-CH", "de-LI", "de-LU", "de_DE.UTF-8", "de_AT.UTF-8@euro", "de_CH"] {
assert_eq!(l(&[tag]), Language::De, "{tag}");
}
assert_eq!(l(&["fr-FR", "it", "es-MX", "ja"]), Language::Es, "the first one the interface has");
assert_eq!(l(&["fr-CH", "de-CH", "en"]), Language::De);
for tag in ["fr", "FR", "fr-FR", "fr-CA", "fr-BE", "fr-CH", "fr_FR.UTF-8", "fr_BE.UTF-8@euro", "fr_CA"] {
assert_eq!(l(&[tag]), Language::Fr, "{tag}");
}
assert_eq!(l(&["it-IT", "nl", "es-MX", "ja"]), Language::Es, "the first one the interface has");
assert_eq!(l(&["it-CH", "de-CH", "fr-CH", "en"]), Language::De);
assert_eq!(l(&["it-CH", "fr-CH", "de-CH", "en"]), Language::Fr);
assert_eq!(l(&["en-GB", "es"]), Language::En);
assert_eq!(l(&["en-US", "de"]), Language::En);
for none in [&[][..], &["fr"], &["C"], &["POSIX"], &[""], &["e"], &["esp"], &["-es"], &["deu"], &["d"], &["-de"]] {
for none in [&[][..], &["it"], &["C"], &["POSIX"], &[""], &["e"], &["esp"], &["-es"], &["deu"], &["d"], &["-de"], &["fra"], &["f"], &["-fr"]] {
assert_eq!(l(none), Language::En, "{none:?}");
}
assert_eq!(l(&[&"x".repeat(1 << 20), "es"]), Language::Es);
@@ -856,7 +893,7 @@ mod tests {
let count = asked.clone();
app.hooks.system_languages = Some(Box::new(move || {
count.set(count.get() + 1);
vec!["fr-FR".into(), "es-ES".into()]
vec!["it-IT".into(), "es-ES".into()]
}));
app.apply_prefs(&ctx);
assert_eq!(app.ui.language, Language::Es);
@@ -915,6 +952,36 @@ mod tests {
assert_eq!(Language::De.tr("Settings"), "Voreinstellungen");
}
/// French covers the whole interface (the Spanish catalog's entries and the native macOS
/// Settings menu), but, like Japanese, the tests require only the core menus, so strings added
/// later show in English until they are translated. Every entry must still have a Spanish
/// counterpart: a renamed or removed source would otherwise leave a dead French entry.
#[test]
fn french_covers_the_menus_and_has_no_dead_entries() {
let (entries, errors) = catalog::parse_entries(include_str!("fr.tsv"));
assert!(errors.is_empty(), "{errors:?}");
for (ctx, en, _) in &entries {
assert!(ctx.is_empty() && (Language::Es.has(en) || en == "Settings"), "French entry without a Spanish one: {ctx:?} {en:?}");
}
let (portuguese, _) = catalog::parse_entries(include_str!("pt-br.tsv"));
for (_, en, _) in portuguese {
assert!(Language::Fr.has(&en), "missing French menu label: {en}");
}
for en in crate::menus::MENUS.into_iter().chain(["Audio", "Settings"]) {
assert!(Language::Fr.has(en), "untranslated French menu: {en}");
}
// beyond the menus: panels, effects and their parameters, settings, status messages
for (en, fr) in
[("Settings", "Préférences"), ("Gaussian Blur", "Flou gaussien"), ("Audio Track Mixer", "Mixeur de pistes audio"), ("Sequence", "Séquence")]
{
assert_eq!(Language::Fr.tr(en), fr);
}
set_current(Language::Fr);
assert!(matches_query("Gaussian Blur", "flou"));
assert!(matches_query("Gaussian Blur", "gaussian"));
set_current(Language::En);
}
#[test]
fn russian_entries_cover_the_original_menu_catalog() {
let app = crate::FilmcraftApp::new(filmcraft_engine::Session::default());
@@ -933,22 +1000,14 @@ mod tests {
}
}
#[test]
fn ukrainian_renders_with_bundled_fonts() {
/// Every non-ASCII character of `text` renders with the bundled interface fonts, in every family.
fn assert_bundled_fonts_cover(text: impl Iterator<Item = char>) {
let ctx = egui::Context::default();
crate::theme::install(&ctx, &crate::theme::Tokens::for_kind(crate::theme::ThemeKind::default()));
let mut output = ctx.run_ui(egui::RawInput::default(), |_| {});
output.textures_delta.clear();
let (entries, _) = catalog::parse_entries(include_str!("uk.tsv"));
let chars: std::collections::BTreeSet<_> = text.filter(|ch| !ch.is_ascii()).collect();
ctx.fonts_mut(|fonts| {
// Include the entire alphabet, even letters not yet used by a translated menu label.
let alphabet = "АБВГҐДЕЄЖЗИІЇЙКЛМНОПРСТУФХЦЧШЩЬЮЯабвгґдеєжзиіїйклмнопрстуфхцчшщьюя";
let chars: std::collections::BTreeSet<_> = alphabet
.chars()
.chain(Language::Uk.name().chars())
.chain(entries.iter().flat_map(|(_, _, uk)| uk.chars()))
.filter(|ch| !ch.is_ascii())
.collect();
for family in crate::theme::font_families() {
let font = egui::FontId::new(13.0, family);
// has_glyph compares font faces, giving false negatives when Inter also supplies
@@ -966,6 +1025,24 @@ mod tests {
});
}
#[test]
fn ukrainian_renders_with_bundled_fonts() {
let (entries, _) = catalog::parse_entries(include_str!("uk.tsv"));
// Include the entire alphabet, even letters not yet used by a translated menu label.
let alphabet = "АБВГҐДЕЄЖЗИІЇЙКЛМНОПРСТУФХЦЧШЩЬЮЯабвгґдеєжзиіїйклмнопрстуфхцчшщьюя";
assert_bundled_fonts_cover(alphabet.chars().chain(Language::Uk.name().chars()).chain(entries.iter().flat_map(|(_, _, uk)| uk.chars())));
}
#[test]
fn french_renders_with_bundled_fonts() {
let (entries, _) = catalog::parse_entries(include_str!("fr.tsv"));
// every French letter and quotation mark, even ones no entry uses yet, and every letter the
// entries use (symbols such as ✔ come from the English sources)
let alphabet = "ÀÂÆÇÉÈÊËÎÏÔŒÙÛÜŸàâæçéèêëîïôœùûüÿ«»’";
let letters = entries.iter().flat_map(|(_, _, fr)| fr.chars()).filter(|ch| ch.is_alphabetic());
assert_bundled_fonts_cover(alphabet.chars().chain(Language::Fr.name().chars()).chain(letters));
}
#[test]
fn japanese_needs_an_installed_font() {
let mut app = crate::FilmcraftApp::new(filmcraft_engine::Session::default());
+4
View File
@@ -30,6 +30,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[
uic!("app.language.chinese", "简体中文", ["Edit", "Language"], None),
uic!("app.language.german", "Deutsch", ["Edit", "Language"], None),
uic!("app.language.russian", "Русский", ["Edit", "Language"], None),
uic!("app.language.french", "Français", ["Edit", "Language"], None),
uic!("source.playback.toggle", "Source Play/Stop", [], None),
uic!("source.playback.play", "Play Source", [], None),
uic!("source.playback.stop", "Stop Source", [], None),
@@ -184,6 +185,7 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params:
| "app.language.chinese"
| "app.language.german"
| "app.language.russian"
| "app.language.french"
) {
// Japanese needs the craft-fonts (built with CRAFT_FONTS_DIR) or a font installed on the system
if id == "app.language.japanese" && !crate::i18n::install_japanese_font(ctx) {
@@ -201,6 +203,7 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params:
"app.language.chinese" => crate::i18n::Language::ZhCn,
"app.language.german" => crate::i18n::Language::De,
"app.language.russian" => crate::i18n::Language::Ru,
"app.language.french" => crate::i18n::Language::Fr,
_ => crate::i18n::Language::En,
};
// The preference is updated in memory before it is written, so a failed write (read-only
@@ -625,6 +628,7 @@ pub fn menu_items(app: &FilmcraftApp) -> Vec<MenuItem> {
"app.language.chinese" => it.checked = Some(app.ui.language == crate::i18n::Language::ZhCn),
"app.language.german" => it.checked = Some(app.ui.language == crate::i18n::Language::De),
"app.language.russian" => it.checked = Some(app.ui.language == crate::i18n::Language::Ru),
"app.language.french" => it.checked = Some(app.ui.language == crate::i18n::Language::Fr),
_ => {}
}
if it.id.starts_with("view.") {
+3 -3
View File
@@ -195,10 +195,10 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement**
## G14. Localization: 8 of the 12 key languages missing
- **Have:** Spanish, Japanese and Simplified Chinese catalogs cover ~99.6–99.9% of the ~3,500
cataloged strings; Portuguese (Brazil) and Ukrainian ~10% (menus).
- **Have:** Spanish, Japanese, Simplified Chinese and French (#498) catalogs cover ~99.6–100% of
the ~3,500 cataloged strings; Portuguese (Brazil) and Ukrainian ~10% (menus).
- **Missing:** Hindi, Arabic (no RTL interface layout; Arabic titles reported broken, #395),
French (#498), Indonesian, German (#490), Korean (#508; needs Hangul fonts in craft-fonts),
Indonesian, German beyond menus (#490), Korean (#508; needs Hangul fonts in craft-fonts),
Vietnamese, Portuguese beyond menus; Premiere also ships Italian and Russian. Engine errors,
CLI and MCP stay English. No language has a recorded native-speaker review.
- **Estimate:** 150–230 h. Doc: [localization-parity.md](localization-parity.md).
+7 -6
View File
@@ -10,14 +10,15 @@ Portuguese (Brazil), Russian, Simplified Chinese.
Edit ▸ Language (also Settings ▸ General, `general.interfaceLanguage`) switches the interface and
persists across restarts; System Language (the default) follows the OS's or browser's preferred
languages and falls back to English. Commands `app.language.<english|japanese|spanish|portuguese|ukrainian|chinese>`
languages and falls back to English. Commands `app.language.<english|japanese|spanish|portuguese|ukrainian|chinese|german|russian|french>`
are reachable over the control channel. One catalog per language,
`crates/ui-egui/src/i18n/<code>.tsv` (format in the header of `es.tsv`); UI code wraps strings in
`tl!("…")` / `tlf!("…{name}", name)`, and registry names (effects and their parameters, settings,
commands, panels, workspaces, presets) are translated where they are drawn. Searches match the
displayed labels as well as English sources. Tests fail when a `tl!` literal, a menu label or a
registry name has no Spanish entry; `zh-cn.tsv` must also cover every `tl!` literal and menu
label; `ja.tsv` has the same entries but is not yet required by tests. Engine error messages, the
label; `ja.tsv` and `fr.tsv` have the same entries but are not yet required by tests (French must
cover the core menus and keep no entry without a Spanish one). Engine error messages, the
control channel, the CLI and MCP stay English.
Fonts come from [craft-fonts](https://github.com/storytold/craft-fonts) (`CRAFT_FONTS_DIR`, all
@@ -41,7 +42,7 @@ whole interface, one PR) at ~4–8 h per language for the catalog plus tests.
| Spanish | es | 3,496 (99.9%) | as above | Latin | not recorded | partial | 4–8 h + review |
| Hindi | hi | 0 (0%) | — | **no Devanagari shaping in the interface** | — | none | 30–50 h (shaping in UI + catalog + fonts) |
| Arabic | ar | 0 (0%) | — | **no RTL layout, no shaping in the interface**; titles broken (#395) | — | none | 40–70 h |
| French | fr | 0 (0%) | — | Latin | — | none (requested #498) | 6–10 h + review |
| French | fr | 3,526 (same entries as Spanish, plus the macOS Settings item) | as Spanish | Latin (bundled fonts) | not recorded; welcome | partial (#498) | review |
| Portuguese (Brazil) | pt-br | 348 (9.9%) | menus only | Latin | not recorded | menus only | 5–9 h + review |
| Indonesian | id | 0 (0%) | — | Latin | — | none | 6–10 h + review |
| Japanese | ja | 3,487 (99.7%) | as zh-cn; system-font kanji may pick the Chinese fallback (#547) | CJK fonts, vertical title text | not recorded | partial | 4–8 h + review |
@@ -52,9 +53,9 @@ whole interface, one PR) at ~4–8 h per language for the catalog plus tests.
Premiere also ships Italian and Russian, which we don't (6–10 h each).
**Localization dimension:** 4 of the 12 key languages near-complete (English, Spanish, Japanese,
Simplified Chinese), 1 menus-only, 7 none; against Premiere's 10 languages we cover 4 well and 1
partly. **~35%, 150–230 h** for all twelve to `full` plus Italian and Russian, of which the script
**Localization dimension:** 5 of the 12 key languages near-complete (English, Spanish, Japanese,
Simplified Chinese, French), 1 menus-only, 6 none; against Premiere's 10 languages we cover 5 well
and 1 partly. **~35%, 150–230 h** for all twelve to `full` plus Italian and Russian, of which the script
work (RTL and complex shaping in the egui interface) is the largest and least parallel part.
Every language needs native-speaker review (human).