#867: Indonesian (id) interface translation

Problem: Indonesian, one of the 12 key languages in
docs/localization-parity.md, had no catalog (0%), so Indonesian users got
the interface in English and System Language never picked Indonesian.

Fix: add crates/ui-egui/src/i18n/id.tsv, clean-room translations of every
entry in the Spanish catalog plus the native macOS Settings menu (3,541),
with a glossary in its header. Language::Id ("Bahasa Indonesia") is
persisted as `id`; it appears in Edit > Language (also the native macOS
menu), Settings > General, and System Language picks it for `id-*` locales
and the legacy `in-*` code older Java/Android runtimes report. Tests cover
the core menus, reject entries without a Spanish counterpart, check glyph
coverage with the bundled fonts, switching/persistence and locale
matching. Attribution sidecar, ATTRIBUTION.md row, docs (gaps.md G14,
localization-parity, ROADMAP, ui-egui README) updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76 2026-10-11 02:41:31 +02:00
parent 2ff3f5e266
commit 615b89a364
12 changed files with 3685 additions and 9 deletions

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/id.tsv` | FilmCraft contributors | Original work: clean-room Indonesian interface translations | MIT OR Apache-2.0 |
| `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 |

View File

@ -80,7 +80,7 @@ Premiere ships 10 interface languages. Measured from our catalogs (3,498 strings
| Arabic | none (no RTL interface) | 0% |
| French | partial (whole interface; native review welcome) | ~100% |
| Portuguese (Brazil) | menus only | 9.9% |
| Indonesian | none | 0% |
| Indonesian | partial (whole interface; native review welcome) | ~100% |
| Japanese | partial (whole interface) | 99.7% |
| German | none | 0% |
| Korean | none | 0% |

View File

@ -109,6 +109,8 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver<String>, Sho
filmcraft_ui_egui::i18n::Language::Ru
} else if checked("app.language.french") {
filmcraft_ui_egui::i18n::Language::Fr
} else if checked("app.language.indonesian") {
filmcraft_ui_egui::i18n::Language::Id
} else {
filmcraft_ui_egui::i18n::Language::En
};

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`, `ru`, `fr`, or `system` (the default: the operating system's preferred language when the
/// `zh-cn`, `de`, `ru`, `fr`, `id`, 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`.
@ -777,6 +777,7 @@ static CATEGORIES: &[Category] = &[
("de", "Deutsch"),
("ru", "Русский"),
("fr", "Français"),
("id", "Bahasa Indonesia"),
]),
true,
),

View File

@ -38,6 +38,12 @@ fn interface_language_persists_and_rejects_hostile_values() {
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");
// Indonesian is stored as `id`, not a locale tag or the legacy `in` code
for value in [json!("id-ID"), json!("in"), json!("ID")] {
assert!(s.execute("prefs.set", json!({"key": "general.interfaceLanguage", "value": value})).is_err());
}
set(&mut s, "general.interfaceLanguage", json!("id"));
assert_eq!(Preferences::load(&path).general.interface_language, "id");
// back to following the operating system (#218)
set(&mut s, "general.interfaceLanguage", json!("system"));
assert_eq!(Preferences::load(&path).general.interface_language, "system");

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, German, Russian and French. The language is stored in the engine's `general.interfaceLanguage` preference,
Chinese, German, Russian, French and Indonesian (which covers the whole interface). 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

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,8 @@
asset: id.tsv
title: Indonesian 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-11 by vangel76
notes: Original English-to-Indonesian interface translations using clean-room NLE vocabulary.

View File

@ -45,6 +45,7 @@ pub enum Language {
De,
Ru,
Fr,
Id,
}
static JAPANESE: OnceLock<Catalog> = OnceLock::new();
@ -55,9 +56,10 @@ static CHINESE: OnceLock<Catalog> = OnceLock::new();
static GERMAN: OnceLock<Catalog> = OnceLock::new();
static RUSSIAN: OnceLock<Catalog> = OnceLock::new();
static FRENCH: OnceLock<Catalog> = OnceLock::new();
static INDONESIAN: OnceLock<Catalog> = OnceLock::new();
impl Language {
pub const ALL: [Self; 9] = [Self::En, Self::Ja, Self::Es, Self::PtBr, Self::Uk, Self::ZhCn, Self::De, Self::Ru, Self::Fr];
pub const ALL: [Self; 10] = [Self::En, Self::Ja, Self::Es, Self::PtBr, Self::Uk, Self::ZhCn, Self::De, Self::Ru, Self::Fr, Self::Id];
pub fn name(self) -> &'static str {
match self {
@ -70,6 +72,7 @@ impl Language {
Self::De => "Deutsch",
Self::Ru => "Русский",
Self::Fr => "Français",
Self::Id => "Bahasa Indonesia",
}
}
@ -84,6 +87,7 @@ impl Language {
"de" => Some(Self::De),
"ru" => Some(Self::Ru),
"fr" => Some(Self::Fr),
"id" => Some(Self::Id),
_ => None,
}
}
@ -92,11 +96,12 @@ impl Language {
/// (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 and any French (`fr-CA`, `fr_BE.UTF-8`…)
/// the French one. Chinese gets the Simplified catalog
/// the French one; any Indonesian (`id-ID`, `id_ID.UTF-8`, and the legacy `in-ID` that older Java
/// and Android runtimes report) the Indonesian 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); 8] = [
const PRIMARY: [(&str, Language); 10] = [
("en", Language::En),
("ja", Language::Ja),
("es", Language::Es),
@ -105,6 +110,8 @@ impl Language {
("de", Language::De),
("ru", Language::Ru),
("fr", Language::Fr),
("id", Language::Id),
("in", Language::Id),
];
tags.iter()
.find_map(|tag| {
@ -134,6 +141,7 @@ impl Language {
Self::De => "de",
Self::Ru => "ru",
Self::Fr => "fr",
Self::Id => "id",
}
}
@ -149,6 +157,7 @@ impl Language {
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")))),
Self::Id => Some(INDONESIAN.get_or_init(|| Catalog::parse(include_str!("id.tsv")))),
}
}
@ -319,6 +328,7 @@ mod tests {
("de", include_str!("de.tsv")),
("ru", include_str!("ru.tsv")),
("fr", include_str!("fr.tsv")),
("id", include_str!("id.tsv")),
] {
let (entries, errors) = catalog::parse_entries(text);
assert!(errors.is_empty(), "{code}: {errors:?}");
@ -366,6 +376,13 @@ mod tests {
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");
assert_eq!(Language::Id.name(), "Bahasa Indonesia");
assert_eq!(Language::Id.tr("File"), "Berkas");
assert_eq!(Language::Id.tr("Save As…"), "Simpan Sebagai…");
assert_eq!(Language::Id.tr("Liburan keluarga.mp4"), "Liburan keluarga.mp4");
assert_eq!(Language::Id.tr("An untranslated label"), "An untranslated label");
assert_eq!(Language::parse("id"), Some(Language::Id));
assert_eq!(Language::parse("id-ID"), 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();
@ -834,6 +851,24 @@ 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::Fr);
let result = crate::menus::invoke(&mut app, &ctx, "app.language.indonesian", serde_json::json!({})).unwrap();
assert_eq!(result, serde_json::json!("id"));
assert_eq!((app.ui.language, current()), (Language::Id, Language::Id));
assert_eq!(app.session.prefs.general.interface_language, "id");
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.indonesian"), "{}", 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("Simpan Sebagai…"));
assert_eq!(label("app.language.french"), Some("Français"));
assert_eq!(label("app.language.indonesian"), Some("Bahasa Indonesia"));
let saved = serde_json::to_string(&app.ui).unwrap();
assert!(saved.contains("\"language\":\"id\""), "{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::Id);
crate::menus::invoke(&mut app, &ctx, "app.language.english", serde_json::json!({})).unwrap();
assert_eq!(app.ui.language, Language::En);
set_current(Language::En);
@ -865,6 +900,13 @@ mod tests {
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}");
}
for tag in ["id", "ID", "id-ID", "id_ID.UTF-8", "id_ID", "in", "in-ID", "in_ID.UTF-8"] {
assert_eq!(l(&[tag]), Language::Id, "{tag}");
}
assert_eq!(l(&["jv-ID", "ms-MY", "id-ID", "en"]), Language::Id, "Javanese and Malay have no catalog");
for none in [&["ind"][..], &["i"], &["-id"], &["ms"], &["ms-ID"], &["jv"]] {
assert_eq!(l(none), Language::En, "{none:?}");
}
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);
@ -1000,6 +1042,41 @@ mod tests {
}
}
/// Indonesian covers the whole interface (the Spanish catalog's entries and the native macOS
/// Settings menu), but, like Japanese and French, the tests require only the core menus (the
/// menus-only Ukrainian catalog, the top-level menus and the native macOS Settings menu), 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 entry.
#[test]
fn indonesian_covers_the_menus_and_has_no_dead_entries() {
let (entries, errors) = catalog::parse_entries(include_str!("id.tsv"));
assert!(errors.is_empty(), "{errors:?}");
for (ctx, en, _) in &entries {
assert!(ctx.is_empty() && (Language::Es.has(en) || en == "Settings"), "Indonesian entry without a Spanish one: {ctx:?} {en:?}");
}
let (core, _) = catalog::parse_entries(include_str!("uk.tsv"));
for (_, en, _) in core {
assert!(Language::Id.has(&en), "missing Indonesian menu label: {en}");
}
for en in crate::menus::MENUS.into_iter().chain(["Audio", "Settings"]) {
assert!(Language::Id.has(en), "untranslated Indonesian menu: {en}");
}
// beyond the menus: panels, effects and their parameters, settings, status messages
for (en, id) in [
("Settings", "Preferensi"),
("Gaussian Blur", "Buram Gaussian"),
("Audio Track Mixer", "Mixer Trek Audio"),
("Sequence", "Sekuens"),
("Playhead", "Kepala Putar"),
] {
assert_eq!(Language::Id.tr(en), id);
}
set_current(Language::Id);
assert!(matches_query("Gaussian Blur", "buram"));
assert!(matches_query("Gaussian Blur", "gaussian"));
set_current(Language::En);
}
/// 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();
@ -1043,6 +1120,16 @@ mod tests {
assert_bundled_fonts_cover(alphabet.chars().chain(Language::Fr.name().chars()).chain(letters));
}
#[test]
fn indonesian_renders_with_bundled_fonts() {
let (entries, _) = catalog::parse_entries(include_str!("id.tsv"));
// Indonesian is written in basic Latin letters; any other letter an entry uses, and its
// curly quotes, dashes and ellipses, must still render (symbols such as ✔ come from the
// English sources)
let letters = entries.iter().flat_map(|(_, _, id)| id.chars()).filter(|ch| ch.is_alphabetic() || "“”‘’–—…".contains(*ch));
assert_bundled_fonts_cover(Language::Id.name().chars().chain(letters));
}
#[test]
fn japanese_needs_an_installed_font() {
let mut app = crate::FilmcraftApp::new(filmcraft_engine::Session::default());

View File

@ -31,6 +31,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[
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!("app.language.indonesian", "Bahasa Indonesia", ["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),
@ -186,6 +187,7 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params:
| "app.language.german"
| "app.language.russian"
| "app.language.french"
| "app.language.indonesian"
) {
// 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) {
@ -204,6 +206,7 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params:
"app.language.german" => crate::i18n::Language::De,
"app.language.russian" => crate::i18n::Language::Ru,
"app.language.french" => crate::i18n::Language::Fr,
"app.language.indonesian" => crate::i18n::Language::Id,
_ => crate::i18n::Language::En,
};
// The preference is updated in memory before it is written, so a failed write (read-only
@ -629,6 +632,7 @@ pub fn menu_items(app: &FilmcraftApp) -> Vec<MenuItem> {
"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),
"app.language.indonesian" => it.checked = Some(app.ui.language == crate::i18n::Language::Id),
_ => {}
}
if it.id.starts_with("view.") {

View File

@ -204,9 +204,12 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement**
- **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),
Indonesian, German beyond menus (#490), Korean (#508; needs Hangul fonts in craft-fonts),
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.
- **Done:** Indonesian (#867): `id.tsv` has every entry of the Spanish catalog (~100%), in Edit ▸
Language, Settings ▸ General and the native macOS menu; System Language picks it for `id-*` (and
legacy `in-*`) locales. Native-speaker review welcome.
- **Estimate:** 150–230 h. Doc: [localization-parity.md](localization-parity.md).
## G15. Interchange never validated in Avid / Pro Tools; slow XML import

View File

@ -10,7 +10,7 @@ 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|german|russian|french>`
languages and falls back to English. Commands `app.language.<english|japanese|spanish|portuguese|ukrainian|chinese|german|russian|french|indonesian>`
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,
@ -44,7 +44,7 @@ whole interface, one PR) at ~4–8 h per language for the catalog plus tests.
| Arabic | ar | 0 (0%) | — | **no RTL layout, no shaping in the interface**; titles broken (#395) | — | none | 40–70 h |
| 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 |
| Indonesian | id | 3,541 (same entries as Spanish, plus the macOS Settings item); must cover the core menus, no entry without a Spanish one | as Spanish | Latin (bundled fonts); System Language also maps the legacy `in` code | not recorded; welcome | partial (#867) | 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 |
| German | de | 0 (0%) | — | Latin | — | none (requested #490) | 6–10 h + review |
| Korean | ko | 0 (0%) | — | needs Hangul fonts in craft-fonts and IME testing | — | none (requested #508) | 10–20 h + review |