i18n: the effect catalog takes a language's tables, with Japanese's slot reserved (#821)

The Japanese data point in #821 (zitann2020) offered to write its rows against whatever mechanism
this PR lands, so both languages share one catalog instead of inventing two. This makes adding a
language a data change and reserves `ja`:

- `Catalog` holds a language's effect rows and its category labels, and `catalog()` resolves a
  language code to one; adding a language is two tables and one arm, with no change to the lookups
  or to any panel.
- `JAPANESE_EFFECTS` and `JAPANESE_CATEGORIES` are declared, keyed by the same effect ids and by
  the registry's English category names, and `ja` resolves to them. Both are empty, so a Japanese
  interface draws exactly what it drew before — the slot changes no behaviour on its own.
- `a_pending_language_only_uses_known_ids` guards the pending tables (Japanese and Traditional
  Chinese) as rows arrive: an id Simplified Chinese does not know, a different English name for one
  of them, an empty translation or a repeated id fails. `languages_without_rows_draw_the_registry_name`
  asserts each pending language has an arm in `catalog()` (a table with no arm would leave a
  translator's rows dead) and is still row-less, so the day one gets rows the test says where its
  coverage moved.
- `only_the_brand_names_stay_in_english` now documents that it pins *Simplified Chinese's* five
  brand names and is deliberately not shared: AE's Japanese interface keeps the whole `CC`, `VR`
  and `OCIO` sets in English while its Chinese one translates them, so repeating the English name
  is a language's choice and not an unfinished row. The row check reflects that (a repeated English
  name is accepted, an empty one is not).

docs/localization-parity.md records the two empty tables and that each language decides for itself
which names stay in English.

Refs #821
This commit is contained in:
WaWu committed 2026-10-13 14:13:28 +08:00
1 parent 40a8e50de9
commit e655159382
2 files changed
+139 -65

No files matched your search

+133 -64
View File
@@ -17,15 +17,18 @@
//! [`tests::every_effect_has_a_row`] instead of silently pointing a translation at another
//! effect.
//!
//! Only Simplified Chinese has rows. Every other language — Traditional Chinese included —
//! draws the registry's English name; a translator adds rows to [`TRADITIONAL_CHINESE`] keyed
//! by the same ids (the test below refuses an id that Simplified Chinese does not have).
//! A language decides for itself which names stay English: After Effects' Japanese interface
//! leaves the `CC` series, the `VR` set and the `OCIO` set in English, while its Simplified
//! Chinese one translates them (the `CC` names come from Adobe's Chinese *documentation*, which
//! names 68 of the 69). Repeating the English name is therefore a language-level choice, not a
//! missing row: [`tests::only_the_brand_names_stay_in_english`] pins *Simplified Chinese's* five
//! brand names and is deliberately not shared by the other languages.
//!
//! The names come from the installed After Effects 2026 (26.5.0.89) Simplified Chinese
//! interface; the `CC` series follows Adobe's Chinese *documentation*, which names 68 of the 69
//! that the interface leaves in English (see the PR for #821). `Cryptomatte`, `EXtractoR`,
//! `IDentifier`, `Key Light` and `Unmult` stay in English as brand names, and `Face
//! Measurements` / `Face Track Points` are EffectCraft's own effects, so their wording is ours.
//! The Simplified Chinese names come from the installed After Effects 2026 (26.5.0.89)
//! Simplified Chinese interface, its 21 category folder names included; `Cryptomatte`,
//! `EXtractoR`, `IDentifier`, `Key Light` and `Unmult` stay in English as brand names, and
//! `Face Measurements` / `Face Track Points` are EffectCraft's own effects, so their wording is
//! ours.
use std::collections::HashMap;
use std::sync::OnceLock;
@@ -35,9 +38,51 @@ use super::ui::current;
/// One row: effect id, the registry's English name, the translation.
type Row = (&'static str, &'static str, &'static str);
/// One language's catalog: its effect rows and its category labels, the latter keyed by the
/// registry's English category name.
struct Catalog {
rows: &'static [Row],
categories: &'static [(&'static str, &'static str)],
/// `rows` indexed by effect id, built on first use: the panels look a name up once per drawn
/// row and per frame.
index: OnceLock<HashMap<&'static str, (&'static str, &'static str)>>,
}
impl Catalog {
fn row(&self, id: &str) -> Option<(&'static str, &'static str)> {
self.index.get_or_init(|| self.rows.iter().map(|(id, english, translated)| (*id, (*english, *translated))).collect()).get(id).copied()
}
fn category(&self, source: &str) -> Option<&'static str> {
self.categories.iter().find(|(english, _)| *english == source).map(|(_, translated)| *translated)
}
}
/// The catalog for `language`; `None` for English (the source of every name, needing no lookup)
/// and for a language with no table.
///
/// **Adding a language** is two tables and one arm: write its rows (one per effect, ids from
/// [`SIMPLIFIED_CHINESE`]) and its category labels, name them in the `static` below and in this
/// `match`. Nothing else changes — not the lookups, not one panel. `zh-hant` and `ja` are listed
/// with empty tables, so a translator fills rows in without touching this function, and until
/// then those languages draw the registry's English name.
fn catalog(language: &str) -> Option<&'static Catalog> {
static SIMPLIFIED: Catalog = Catalog { rows: SIMPLIFIED_CHINESE, categories: SIMPLIFIED_CHINESE_CATEGORIES, index: OnceLock::new() };
static TRADITIONAL: Catalog = Catalog { rows: TRADITIONAL_CHINESE, categories: TRADITIONAL_CHINESE_CATEGORIES, index: OnceLock::new() };
static JAPANESE: Catalog = Catalog { rows: JAPANESE_EFFECTS, categories: JAPANESE_CATEGORIES, index: OnceLock::new() };
match language {
"zh-hans" => Some(&SIMPLIFIED),
"zh-hant" => Some(&TRADITIONAL),
// Japanese: the slot the Japanese data point on #821 asked for, so both languages share
// this one catalog instead of inventing a second mechanism.
"ja" => Some(&JAPANESE),
_ => None,
}
}
/// The effect's name to draw: the catalog row for `id`, else the registry's `english`.
pub(crate) fn name(id: &str, english: &str) -> String {
row(id).map_or_else(|| english.to_string(), |(_, translated)| translated.to_string())
catalog(current()).and_then(|c| c.row(id)).map_or_else(|| english.to_string(), |(_, translated)| translated.to_string())
}
/// The name to draw for an effect group the property tree stores as `stored`: the catalog name,
@@ -47,7 +92,7 @@ pub(crate) fn name(id: &str, english: &str) -> String {
/// (Effect Controls ▸ Rename), so it stays verbatim — as does the whole string in a language
/// with no row.
pub(crate) fn group(id: &str, stored: &str) -> String {
let Some((english, translated)) = row(id) else { return stored.to_string() };
let Some((english, translated)) = catalog(current()).and_then(|c| c.row(id)) else { return stored.to_string() };
if stored == english {
return translated.to_string();
}
@@ -61,38 +106,16 @@ pub(crate) fn group(id: &str, stored: &str) -> String {
}
/// The label to draw for the effect category `source` (the generated Effect menu's submenus and
/// Effects & Presets' folder rows).
/// Effects & Presets' folder rows), else `source`.
pub(crate) fn category(source: &str) -> String {
if current() != "zh-hans" {
return source.to_string();
}
CATEGORIES.iter().find(|(english, _)| *english == source).map_or_else(|| source.to_string(), |(_, translated)| translated.to_string())
catalog(current()).and_then(|c| c.category(source)).map_or_else(|| source.to_string(), str::to_string)
}
/// Does effect `id` (`english` when the language has no row) match the Effects & Presets search
/// `query` (lowercase)? Both names match, so a Chinese interface can be searched either way.
/// `query` (lowercase)? Both names match, so a translated interface can be searched either way.
pub(crate) fn matches(id: &str, english: &str, query: &str) -> bool {
english.to_lowercase().contains(query) || row(id).is_some_and(|(_, translated)| translated.to_lowercase().contains(query))
}
/// `id`'s (English name, translation) in the current language.
fn row(id: &str) -> Option<(&'static str, &'static str)> {
index(current()).and_then(|rows| rows.get(id).copied())
}
type Index = HashMap<&'static str, (&'static str, &'static str)>;
/// The catalog for `language`, indexed by effect id (built on first use); `None` for English and
/// any language with no rows.
fn index(language: &str) -> Option<&'static Index> {
static HANS: OnceLock<Index> = OnceLock::new();
static HANT: OnceLock<Index> = OnceLock::new();
let (slot, rows) = match language {
"zh-hans" => (&HANS, SIMPLIFIED_CHINESE),
"zh-hant" => (&HANT, TRADITIONAL_CHINESE),
_ => return None,
};
Some(slot.get_or_init(|| rows.iter().map(|(id, english, translated)| (*id, (*english, *translated))).collect()))
english.to_lowercase().contains(query)
|| catalog(current()).and_then(|c| c.row(id)).is_some_and(|(_, translated)| translated.to_lowercase().contains(query))
}
/// Simplified Chinese (`zh-hans`): one row per effect in the registry.
@@ -404,15 +427,30 @@ const SIMPLIFIED_CHINESE: &[Row] = &[
("ec.vr.sharpen", "VR Sharpen", "VR 锐化"),
("ec.vr.spheretoplane", "VR Sphere to Plane", "VR 球面到平面"),
];
/// Traditional Chinese (`zh-hant`): no rows yet, so the language draws the English names.
/// Traditional Chinese (`zh-hant`): no rows yet. A translator fills one row per effect, ids from
/// [`SIMPLIFIED_CHINESE`]; a row may repeat the English name, which is what the language draws
/// for that effect.
const TRADITIONAL_CHINESE: &[Row] = &[];
/// The 21 category folder names the generated Effect menu and Effects & Presets draw. Read from
/// the installed After Effects 2026 (26.5.0.89) Simplified Chinese interface (the checklist's
/// `07-类别` sheet); two of them are the interface's own wording where our first suggestion
/// differed (`Keying` is 抠像, not 键控; `Obsolete` is 过时, not 已废弃).
const CATEGORIES: &[(&str, &str)] = &[
/// Japanese (`ja`): reserved for the Japanese data point on #821, so both languages share this
/// one catalog instead of inventing a second mechanism. A translator fills one row per effect,
/// ids from [`SIMPLIFIED_CHINESE`]; the names Adobe's Japanese interface leaves in English (the
/// `CC` series, the `VR` set, the `OCIO` set, `Cryptomatte`, `EXtractoR`, `IDentifier`,
/// `Key Light`, `Unmult`, `Twirl (Legacy)` and the two EffectCraft-only effects) repeat that
/// English name here rather than being left out.
const JAPANESE_EFFECTS: &[Row] = &[];
/// Traditional Chinese (`zh-hant`) category labels, keyed by the registry's English category.
const TRADITIONAL_CHINESE_CATEGORIES: &[(&str, &str)] = &[];
/// Japanese (`ja`) category labels, keyed by the registry's English category.
const JAPANESE_CATEGORIES: &[(&str, &str)] = &[];
/// The 21 category folder names the generated Effect menu and Effects & Presets draw, in
/// Simplified Chinese. Read from the installed After Effects 2026 (26.5.0.89) Simplified Chinese
/// interface (the checklist's `07-类别` sheet); two of them are the interface's own wording where
/// our first suggestion differed (`Keying` is 抠像, not 键控; `Obsolete` is 过时, not 已废弃).
const SIMPLIFIED_CHINESE_CATEGORIES: &[(&str, &str)] = &[
("3D Channel", "3D 通道"),
("Audio", "音频"),
("Blur & Sharpen", "模糊和锐化"),
@@ -468,8 +506,10 @@ mod tests {
assert_eq!(listed, ids.len(), "an effect id is listed twice");
}
/// The five names Adobe leaves in English as brand names, and the only rows that may: any
/// other row repeating its English name is a translation that never happened.
/// The five names Adobe's Simplified Chinese interface leaves in English as brand names, and
/// the only zh-hans rows that may repeat their English name. Deliberately not shared: another
/// language keeps different names in English (Japanese leaves the whole `CC`, `VR` and `OCIO`
/// sets in English), so this is zh-hans's own choice, not an invariant of the tables.
#[test]
fn only_the_brand_names_stay_in_english() {
const BRANDS: [&str; 5] = ["ec.3d.cryptomatte", "ec.3d.extractor", "ec.3d.identifier", "ec.keying.keylight", "ec.key.unmult"];
@@ -478,30 +518,46 @@ mod tests {
}
}
/// Traditional Chinese may lag Simplified Chinese but may not invent ids or disagree about
/// which effect an id names: a translator fills this table row by row.
/// The languages that have no rows yet, with the table a translator fills in for each. A new
/// language is added here and to [`catalog`]; the test then guards its rows as they arrive.
const PENDING: &[(&str, &[Row])] = &[("zh-hant", TRADITIONAL_CHINESE), ("ja", JAPANESE_EFFECTS)];
/// A pending language may lag Simplified Chinese but may not invent ids or disagree about
/// which effect an id names: a translator fills these tables row by row. While they are
/// empty the test is a no-op, which is why the slot ships before the data does.
#[test]
fn traditional_chinese_only_uses_known_ids() {
for (id, english, translated) in TRADITIONAL_CHINESE {
let Some((_, hans_english, _)) = SIMPLIFIED_CHINESE.iter().find(|(hans_id, _, _)| hans_id == id) else {
panic!("zh-hant row {id} is not an effect the zh-hans catalog knows");
};
assert_eq!(*english, *hans_english, "{id}: the two catalogs name the effect differently");
assert!(!translated.is_empty(), "{id}: empty translation");
fn a_pending_language_only_uses_known_ids() {
for (language, rows) in PENDING {
let mut ids: Vec<&str> = vec![];
for (id, english, translated) in *rows {
let Some((_, hans_english, _)) = SIMPLIFIED_CHINESE.iter().find(|(hans_id, _, _)| hans_id == id) else {
panic!("{language} row {id} is not an effect the zh-hans catalog knows");
};
assert_eq!(*english, *hans_english, "{id}: {language} and zh-hans name the effect differently");
assert!(!translated.is_empty(), "{id}: empty {language} translation (repeat the English name if it stays)");
ids.push(id);
}
let listed = ids.len();
ids.sort_unstable();
ids.dedup();
assert_eq!(listed, ids.len(), "{language} lists an effect id twice");
}
}
/// Every category the registry and its plug-ins report has a row, and every row names a
/// category that exists: the generated Effect menu and Effects & Presets draw both.
/// Every category the registry and its plug-ins report has a Simplified Chinese row, and
/// every row of every language names a category that exists — else the generated Effect menu
/// and Effects & Presets would draw a label no folder has.
#[test]
fn every_category_has_a_row() {
let categories = effects::categories();
for category in &categories {
assert!(CATEGORIES.iter().any(|(english, _)| english == category), "no catalog row for category {category}");
assert!(SIMPLIFIED_CHINESE_CATEGORIES.iter().any(|(english, _)| english == category), "no catalog row for category {category}");
}
for (english, translated) in CATEGORIES {
assert!(categories.contains(english), "the catalog lists {english}, which is not a category");
assert!(!translated.is_empty(), "empty translation for {english}");
for (language, rows) in [("zh-hans", SIMPLIFIED_CHINESE_CATEGORIES), ("zh-hant", TRADITIONAL_CHINESE_CATEGORIES), ("ja", JAPANESE_CATEGORIES)] {
for (english, translated) in rows {
assert!(categories.contains(english), "{language} lists {english}, which is not a category");
assert!(!translated.is_empty(), "empty {language} translation for {english}");
}
}
}
@@ -520,11 +576,24 @@ mod tests {
set_current("en");
}
/// English, and every language without rows (Traditional Chinese included), draws the
/// registry's name; an id with no row keeps it too rather than drawing an empty label.
/// English, and a language with no catalog or no row for this effect, draws the registry's
/// name rather than an empty label.
///
/// The pending languages are also checked to still be row-less and to have an arm in
/// [`catalog`]: an arm with no table would leave a translator's rows dead, and the day a
/// pending language gets rows this fails so its slot moves out of [`PENDING`], where the
/// coverage for it lives.
#[test]
fn languages_without_rows_draw_the_registry_name() {
for language in ["en", "ja", "zh-hant", "uk", "cs"] {
for language in ["en", "uk", "cs"] {
set_current(language);
assert_eq!(name("ec.blur.gaussian", "Gaussian Blur"), "Gaussian Blur", "{language}");
assert_eq!(group("ec.blur.gaussian", "Gaussian Blur 2"), "Gaussian Blur 2", "{language}");
assert_eq!(category("Blur & Sharpen"), "Blur & Sharpen", "{language}");
}
for (language, rows) in PENDING {
assert!(catalog(language).is_some(), "{language} has a table but no arm in `catalog`: its rows would be dead");
assert!(rows.is_empty(), "{language} has rows now: move it out of PENDING and cover it here");
set_current(language);
assert_eq!(name("ec.blur.gaussian", "Gaussian Blur"), "Gaussian Blur", "{language}");
assert_eq!(group("ec.blur.gaussian", "Gaussian Blur 2"), "Gaussian Blur 2", "{language}");
+6 -1
View File
@@ -31,7 +31,12 @@ interface; Arabic and Hebrew text *in layers* is supported through its Middle Ea
the drawing — the Effect menu, Effect Controls, the Timeline and Effects & Presets — goes
through the catalog, so a project file is identical whichever interface language opens it.
A repeat (`Gaussian Blur 2`) keeps the index the engine appends, and a group the user renamed
in Effect Controls stays verbatim. No other language has rows yet, so they draw English.
in Effect Controls stays verbatim. Adding a language there is two tables and one arm, and no
panel changes: Traditional Chinese and Japanese have their tables in place and empty, so those
languages draw English until a translator fills them (the Japanese set for #821 is what that
slot is for). Each language decides for itself which names stay English — Adobe's Japanese
interface leaves the `CC`, `VR` and `OCIO` sets in English while its Chinese one translates
them — so a row repeating the English name is a choice, not a missing row.
- **Not translated anywhere yet:** effect *parameter* names (≈ 1,650, `crates/effects`),
engine-generated text (property names, errors, undo labels), the Home screen's unwrapped
literals (#574), Learn tutorials and documentation.