Improve Korean terminology and dynamic UI translation coverage (#604)

* Improve Korean Photoshop terminology and dynamic UI translations

* Keep untranslated form labels in sentence case; add the new strings to de and pt-br

label_of fell back to the Title Case filter label whenever the sentence-case label had no
translation, which changed English form labels ("Use antialias" -> "Use Antialias"). It now
uses the Title Case translation only when one exists. German and Brazilian Portuguese (merged
since this branch was opened) get "No patterns" and "Enable {name}".

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: kijoong <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-07 16:32:47 -04:00
1 parent ac5338360f
commit 06ec4044ea
25 files changed
+1337 -539

No files matched your search

+1 -1
View File
@@ -582,7 +582,7 @@ pub fn windows(app: &mut PhotocraftApp, ctx: &egui::Context) {
egui::Grid::new("mlog-grid").striped(true).spacing(vec2(10.0, 2.0)).show(ui, |ui| {
for c in &cols {
let name = photocraft_engine::analysis_cmds::COLUMNS.iter().find(|x| x.0 == *c).map_or(*c, |x| x.1);
ui.label(RichText::new(name).color(t.text_dim).size(10.5).strong());
ui.label(RichText::new(tl!(name)).color(t.text_dim).size(10.5).strong());
}
ui.end_row();
for r in &rows {
+4 -2
View File
@@ -231,7 +231,7 @@ fn section_list(app: &mut PhotocraftApp, ui: &mut egui::Ui, b: &mut BrushSetting
let sel = app.ui.brush_section == i;
let (r, resp) = ui.allocate_exact_size(vec2(150.0, 23.0), Sense::click());
// Named for accessibility and for tests and agents that look sections up by name.
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, sel, *name));
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, sel, tl!(*name)));
if sel {
ui.painter().rect_filled(r, t.radius_sm, t.accent_soft);
} else if resp.hovered() {
@@ -242,7 +242,9 @@ fn section_list(app: &mut PhotocraftApp, ui: &mut egui::Ui, b: &mut BrushSetting
let br = egui::Rect::from_center_size(egui::pos2(x + 6.0, r.center().y), vec2(13.0, 13.0));
let box_resp = ui.interact(br, ui.id().with(("brush-sec-box", i)), Sense::click());
let checked = *flag;
box_resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, checked, format!("Enable {name}")));
box_resp.widget_info(|| {
egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, checked, crate::i18n::fmt(tl!("Enable {name}"), &[("name", tl!(*name))]))
});
if *flag {
ui.painter().rect_filled(br, 2.0, t.accent);
let (a, m, c) = (br.left_center() + vec2(3.0, 0.5), br.center_bottom() + vec2(-1.0, -3.5), br.right_top() + vec2(-3.0, 3.5));
+1 -1
View File
@@ -99,7 +99,7 @@ fn dynamic(ui: &mut egui::Ui, id: &str, label: &str, d: &mut Dynamic, max: f32,
fn size_row(ui: &mut egui::Ui, label: &str, size: &mut f32, max: f32, restore: Option<f32>) {
let t = Tokens::get(ui.ctx());
ui.horizontal(|ui| {
ui.label(RichText::new(label).color(t.text_dim));
ui.label(RichText::new(tl!(label)).color(t.text_dim));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let mut s = *size;
if widgets::value_field(ui, &mut s, 1.0..=max, "px", 74.0).changed() {
+1
View File
@@ -124,6 +124,7 @@ pub(crate) fn draw_readout(ctx: &egui::Context, id: &str, cursor: Pos2, labels:
let t = crate::theme::Tokens::get(ctx);
let font = egui::FontId::proportional(11.5);
let width = |text: &str| ctx.fonts_mut(|f| f.layout_no_wrap(text.to_owned(), font.clone(), t.text).size().x);
let labels = labels.map(|label| tl!(label));
let (lw, vw) = (labels.map(width), [width(&values[0]), width(&values[1])]);
let (label_col, value_col) = (lw[0].max(lw[1]), vw[0].max(vw[1]));
egui::Area::new(egui::Id::new(id)).order(egui::Order::Tooltip).fixed_pos(cursor + vec2(16.0, 18.0)).interactable(false).constrain(true).show(ctx, |ui| {
+1 -1
View File
@@ -157,7 +157,7 @@ pub fn body(app: &PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Value>)
let mut pat = get_str(f, "pattern", "");
let opts: Vec<(String, &str)> = app.session.patterns.items.iter().map(|p| (p.id.clone(), p.display_name())).collect();
if opts.is_empty() {
ui.label(egui::RichText::new("No patterns").color(Tokens::get(ui.ctx()).text_faint));
ui.label(egui::RichText::new(tl!("No patterns")).color(Tokens::get(ui.ctx()).text_faint));
} else if crate::widgets::dropdown(ui, "fill-pattern", &mut pat, &opts, 170.0) {
f.insert("pattern".into(), json!(pat));
}
+39
View File
@@ -224,6 +224,10 @@ pub fn open_with_spec(app: &mut PhotocraftApp, command: &str, label: &str, spec:
}
pub(crate) fn label(key: &str) -> String {
tl!(&source_label(key)).to_owned()
}
pub(crate) fn source_label(key: &str) -> String {
// camelCase → "Camel Case"
let mut s = String::new();
for (i, ch) in key.chars().enumerate() {
@@ -469,6 +473,41 @@ mod tests {
assert!(!has_dialog("filter.stylize.findEdges"));
}
#[test]
fn korean_covers_generated_filter_options_and_gallery_names() {
let ko = crate::i18n::lang_from_tag("ko-KR").unwrap();
let mut missing = std::collections::BTreeSet::new();
let mut check = |s: String| {
if !matches!(s.as_str(), "X" | "Y" | "A" | "B") && !crate::i18n::has(ko, &s) {
missing.insert(s);
}
};
for c in photocraft_engine::command_specs().iter().filter(|c| c.id.starts_with("filter.") || c.id.starts_with("image.adjustments.")) {
for p in parse_spec(c.params) {
if !p.key.chars().all(|c| c.is_ascii_alphanumeric()) || matches!(p.kind, Kind::Json) {
continue;
}
check(source_label(&p.key));
if let Kind::Choice(choices) = p.kind {
for choice in choices {
// Only symbolic options are UI choices; registry docs also contain
// colour syntax and array notation, which are not translatable names.
if choice.chars().all(|c| c.is_ascii_alphanumeric()) {
check(source_label(&choice));
}
}
}
}
}
for f in photocraft_algo::GalleryFilter::ALL {
check(f.name().to_string());
}
for cat in photocraft_algo::GALLERY_CATEGORIES {
check(cat.to_string());
}
assert!(missing.is_empty(), "missing Korean dynamic labels: {missing:#?}");
}
#[test]
fn preview_runs_engine_command_on_proxy() {
let mut doc = Document::with_background(
+5 -5
View File
@@ -357,7 +357,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
painter.rect_filled(title, 0.0, t.dock);
painter.line_segment([title.left_bottom(), title.right_bottom()], Stroke::new(1.0, t.separator));
let pct = if d.zoom > 0.0 { format!("{:.0}%", d.zoom * 100.0) } else { tl!("Fit").into() };
let name = d.effects.get(d.selected).map_or("", |e| e.filter.name());
let name = d.effects.get(d.selected).map_or("", |e| tl!(e.filter.name()));
painter.text(title.center(), Align2::CENTER_CENTER, format!("{name} ({}, {pct})", d.layer_name), FontId::proportional(13.0), t.text);
let body = ERect::from_min_max(pos2(full.left(), title.bottom()), full.max);
let right = ERect::from_min_size(pos2(body.right() - RIGHT_W, body.top()), vec2(RIGHT_W, body.height()));
@@ -395,7 +395,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
if widgets::dropdown(&mut zb, "gallery-zoom", &mut z, &opts, 110.0) {
d.zoom = z;
}
zb.label(egui::RichText::new(format!("preview {:.0} ms", d.preview_ms)).color(t.text_faint).size(11.0));
zb.label(egui::RichText::new(format!("{} {:.0} ms", tl!("Preview"), d.preview_ms)).color(t.text_faint).size(11.0));
// ---- Category folders with thumbnails ----
painter.rect_filled(mid, 0.0, t.dock);
@@ -408,7 +408,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
let r = ui.horizontal(|ui| {
let (ir, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover());
crate::icons::paint(ui, ir, icon, 14.0, t.icon);
ui.add(egui::Label::new(egui::RichText::new(*cat).color(t.text)).sense(Sense::click()))
ui.add(egui::Label::new(egui::RichText::new(tl!(*cat)).color(t.text)).sense(Sense::click()))
});
if r.inner.clicked() {
d.open[ci] = !d.open[ci];
@@ -434,7 +434,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
ui.painter().rect_stroke(r, 0.0, stroke, egui::StrokeKind::Outside);
ui.add_sized(
[THUMB[0] as f32, 14.0],
egui::Label::new(egui::RichText::new(f.name()).size(10.5).color(if selected { t.text } else { t.text_dim })).truncate(),
egui::Label::new(egui::RichText::new(tl!(f.name())).size(10.5).color(if selected { t.text } else { t.text_dim })).truncate(),
);
resp
});
@@ -539,7 +539,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
ui.painter().text(
row.left_center() + vec2(28.0, 0.0),
Align2::LEFT_CENTER,
d.effects[i].filter.name(),
tl!(d.effects[i].filter.name()),
FontId::proportional(12.5),
if d.effects[i].visible { t.text } else { t.text_faint },
);
+2
View File
@@ -2067,3 +2067,5 @@ cameraRaw Color Barvy
Patch: Záplata:
Lasso around an area, then drag the selection Obtáhněte oblast lasem a pak přetáhněte výběr
Edit Type Upravit text
No patterns Žádné vzorky
Enable {name} Povolit {name}
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -2108,3 +2108,5 @@
175% 175 %
250% 250 %
300% 300 %
No patterns Keine Muster
Enable {name} {name} aktivieren
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -2144,3 +2144,5 @@ cameraRaw Amount Cantidad
Patch: Parche:
Lasso around an area, then drag the selection Rodee un área con el lazo y arrastre la selección
Edit Type Editar texto
No patterns Sin motivos
Enable {name} Activar {name}
Can't render this file because it contains an unexpected character in line 4 and column 47.
+2
View File
@@ -2497,3 +2497,5 @@ cameraRaw Blending Fusion
5 x 5 5 × 5
Grid: Grille :
Edit Type Modifier le texte
No patterns Aucun motif
Enable {name} Activer {name}
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -2484,3 +2484,5 @@ cameraRaw Blending Pembauran
5 x 5 5 × 5
Grid: Kisi-kisi:
Edit Type Edit Teks
No patterns Tidak ada pola
Enable {name} Aktifkan {name}
Can't render this file because it contains an unexpected character in line 4 and column 47.
+2
View File
@@ -2050,3 +2050,5 @@ cameraRaw Color カラー
Patch: パッチ:
Lasso around an area, then drag the selection 投げ縄で範囲を囲み、選択範囲をドラッグします
Edit Type 文字を編集
No patterns パターンなし
Enable {name} {name}を有効にする
Can't render this file because it contains an unexpected character in line 6 and column 47.
File diff suppressed because it is too large. Load diff
+31
View File
@@ -438,6 +438,37 @@ mod tests {
assert_eq!(tr(zh, "no such label"), "no such label");
}
/// Keep the Korean tool/menu vocabulary aligned with the Photoshop equivalents.
/// Sources and the product-specific vocabulary policy are recorded in `ko.tsv`.
#[test]
fn korean_uses_photoshop_terminology() {
let ko = Lang::from_code("ko").expect("ko registered");
for (source, expected) in [
("Shape", "모양"),
("Stroke", "획"),
("Smudge Tool", "손가락 도구"),
("Eyedropper Tool", "스포이드 도구"),
("Rectangular Marquee Tool", "사각형 선택 윤곽 도구"),
("Elliptical Marquee Tool", "원형 선택 윤곽 도구"),
("Zoom Tool", "돋보기 도구"),
("Horizontal Type Tool", "수평 문자 도구"),
("Puppet Warp", "퍼펫 뒤틀기"),
("Liquify…", "픽셀 유동화…"),
("Gaussian Blur…", "가우시안 흐림 효과…"),
("Gaussian Blur", "가우시안 흐림 효과"),
("Adaptive Wide Angle…", "응용 광각…"),
("Render", "렌더"),
("Sharpen", "선명 효과"),
("Vibrance", "활기"),
("Layer Comps", "레이어 구성 요소"),
("Vivid Light", "선명한 라이트"),
("Hard Mix", "하드 혼합"),
] {
assert_eq!(tr(ko, source), expected, "{source}");
}
assert_eq!(tr_id(ko, "filter.sharpen.sharpen", "Sharpen"), "선명하게");
}
#[test]
fn spanish_resolves_and_pluralises() {
let es = Lang::from_code("es").expect("es registered");
+2
View File
@@ -2507,3 +2507,5 @@ cameraRaw Luminance Detail Detalhe da luminância
cameraRaw Color Detail Detalhe da cor
cameraRaw Roughness Aspereza
cameraRaw Whites Brancos
No patterns Nenhum padrão
Enable {name} Ativar {name}
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -2345,3 +2345,5 @@ cameraRaw Roughness Неровность
Patch: Заплатка:
Lasso around an area, then drag the selection Обведите область лассо, затем перетащите выделение
Edit Type Редактировать текст
No patterns Нет узоров
Enable {name} Включить {name}
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -2076,3 +2076,5 @@ cameraRaw Blending 混合
Details 详细信息
Rendering mode 渲染模式
Edit Type 编辑文字
No patterns 无图案
Enable {name} 启用{name}
1 # Simplified Chinese (zh-hans) catalog. UTF-8, TAB-separated columns:
2076
2077
2078
2079
2080
+2
View File
@@ -2045,3 +2045,5 @@ cameraRaw Blending 混合
Patch: 修補:
Lasso around an area, then drag the selection 用套索圈出區域,然後拖曳選取範圍
Edit Type 編輯文字
No patterns 無圖樣
Enable {name} 啟用{name}
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2 -2
View File
@@ -263,7 +263,7 @@ pub fn status_progress(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
ui.label(RichText::new(percent(&j)).color(t.text_dim).size(11.5).monospace());
let (br, _) = ui.allocate_exact_size(vec2(bar_w, bar_h), Sense::hover());
bar(ui, br, shown_fraction(&j), &t);
let label = if more > 0 { format!("{} (+{more})", j.label) } else { j.label.clone() };
let label = if more > 0 { format!("{} (+{more})", tl!(&j.label)) } else { tl!(&j.label).to_owned() };
ui.label(RichText::new(label).color(t.text_dim).size(12.0));
if xresp.clicked() {
cancel(app, j.id);
@@ -283,7 +283,7 @@ pub fn dialog(app: &mut PhotocraftApp, ctx: &egui::Context) {
// Photoshop doesn't dim the window behind its progress dialog either.
let modal = egui::Modal::new(egui::Id::new("job-progress")).backdrop_color(Color32::from_black_alpha(36)).show(ctx, |ui| {
ui.set_width(360.0);
ui.label(RichText::new(&j.label).font(crate::theme::semibold(15.0)));
ui.label(RichText::new(tl!(&j.label)).font(crate::theme::semibold(15.0)));
ui.add_space(4.0);
crate::widgets::hairline(ui);
ui.add_space(10.0);
+7 -1
View File
@@ -590,7 +590,13 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
painter.rect_filled(title, 0.0, t.dock);
painter.line_segment([title.left_bottom(), title.right_bottom()], Stroke::new(1.0, t.separator));
let pct = if d.zoom > 0.0 { d.zoom * 100.0 } else { 100.0 };
painter.text(title.center(), Align2::CENTER_CENTER, format!("Liquify ({}, {:.0}%)", d.layer_name, pct), FontId::proportional(13.0), t.text);
painter.text(
title.center(),
Align2::CENTER_CENTER,
format!("{} ({}, {:.0}%)", tl!("Liquify…").trim_end_matches('…'), d.layer_name, pct),
FontId::proportional(13.0),
t.text,
);
let body = ERect::from_min_max(pos2(full.left(), title.bottom()), full.max);
// Left tool strip.
let left = ERect::from_min_size(body.min, vec2(LEFT_W, body.height()));
+3 -3
View File
@@ -740,7 +740,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let mut section = |ui: &mut egui::Ui, title: &str, prefix: &str| {
ui.label(egui::RichText::new(tl!(&title)).small().color(t.text_dim));
for c in photocraft_engine::command_specs().iter().filter(|c| c.id.starts_with(prefix)) {
if ui.add_enabled(app.session.is_enabled(c.id), egui::Button::new(c.label)).clicked() {
if ui.add_enabled(app.session.is_enabled(c.id), egui::Button::new(tl!(c.label))).clicked() {
let _ = app.run(c.id, json!({}));
ui.close();
}
@@ -1462,7 +1462,7 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
egui::Popup::menu(&adj).show(|ui| {
ui.set_min_width(190.0);
for c in photocraft_engine::command_specs().iter().filter(|c| c.id.starts_with("layer.newAdjustmentLayer.")) {
if ui.button(c.label.trim_end_matches('…')).clicked() {
if ui.button(tl!(c.label).trim_end_matches('…')).clicked() {
actions.push((c.id.into(), json!({})));
ui.close();
}
@@ -1498,7 +1498,7 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
}
ui.separator();
for &(kind, label) in crate::layer_style::KINDS {
if ui.button(format!("{label}…")).clicked() {
if ui.button(format!("{}…", tl!(label))).clicked() {
crate::layer_style::open(app, Some(kind));
ui.close();
}
+1 -1
View File
@@ -29,7 +29,7 @@ fn filter_label(f: &photocraft_doc::SmartFilter) -> String {
return format!("{} (kept, not editable)", name.trim_end_matches("...").trim_end_matches('…'));
}
photocraft_engine::commands::find(command)
.map_or_else(|| command.rsplit('.').next().unwrap_or(command).to_string(), |c| c.label.trim_end_matches('…').to_string())
.map_or_else(|| command.rsplit('.').next().unwrap_or(command).to_string(), |c| tl!(c.label).trim_end_matches('…').to_string())
}
/// Smart Filters header + one row per filter (top filter first, as in Photoshop) under a smart
+21 -1
View File
@@ -704,7 +704,27 @@ fn label_of(key: &str) -> String {
out.push(c);
}
}
out
let translated = tl!(&out);
if translated != out {
return translated.to_owned();
}
// Fall back to the Title Case filter label's translation, but keep the sentence-case
// English when that is untranslated too (English and partial catalogs read as before).
let title = crate::filter_dialog::label(key);
if title == crate::filter_dialog::source_label(key) { out } else { title }
}
#[cfg(test)]
mod label_tests {
use super::label_of;
#[test]
fn untranslated_form_labels_stay_sentence_case() {
crate::i18n::with_language(crate::i18n::Lang::EN, || {
assert_eq!(label_of("useAntialias"), "Use antialias");
assert_eq!(label_of("radius"), "Radius");
});
}
}
/// Body of a `__form` dialog: text fields, number fields, checkboxes and `__choices` dropdowns.
+61
View File
@@ -0,0 +1,61 @@
# Korean terminology and dynamic UI labels
This supplements the Korean catalog merged in #582. The existing language
registry, live language switching and Korean font loading are retained.
Translations use the meaning of PhotoCraft's English labels and terminology
observed in the Korean Photoshop UI in October 2026. No proprietary translation
resources, fonts, screenshots or user documents are included.
## Terminology
| English source | Korean UI |
|---|---|
| Gaussian Blur | 가우시안 흐림 효과 |
| Liquify | 픽셀 유동화 |
| Adaptive Wide Angle | 응용 광각 |
| Brush Tip Shape | 브러시 끝 모양 |
| Smudge Tool | 손가락 도구 |
| Puppet Warp | 퍼펫 뒤틀기 |
| Render (menu) | 렌더 |
| Sharpen (menu) | 선명 효과 |
| Sharpen (command) | 선명하게 |
Command-ID translations disambiguate menu names from actions. Photoshop terms
are used when the operation matches; PhotoCraft-specific features retain their
own meaning. Technology names, units and user-provided names are not translated.
The terminology test protects representative tool names and this distinction.
## Dynamic coverage
Generated filter parameter names, symbolic choices, gallery categories/effects,
job titles, layer-style menus and shortcut labels now go through the catalog.
The filter test enumerates engine filter/adjustment parameter specs and gallery
names to catch new Korean omissions that literal-only scans cannot find.
Two newly introduced literals, `Enable {name}` and `No patterns`, are present in
all nine non-English catalogs. Existing fallback behavior is preserved for
untranslated dynamic labels in other languages.
This is not a claim that all application output is localized. Engine error
messages, arbitrary plug-in labels and user document/layer/preset names remain
outside this translation change. The source coverage report measures its
scanned key set, not every possible runtime string.
## Validation
On main 7055f0c plus this change:
- UI tests: 728 passed, 3 ignored, including live language-switch tests.
- Clippy for the UI crate and all targets: passed with warnings denied.
- i18n coverage: 1,406/1,406 scanned keys for all ten registered languages;
all seven coverage-tool tests passed.
- Dependency-layer and WebAssembly checks passed. Existing CMS dead-code
warnings appear during WebAssembly checks.
- Offscreen French-to-Korean language switching and the Korean brush settings
panel were rendered and visually inspected for missing glyphs/clipping.
Re-run with `cargo test -p photocraft-ui-egui --locked`,
`cargo clippy -p photocraft-ui-egui --all-targets --locked -- -D warnings`,
`cargo test -p xtask i18n_coverage`, `cargo xtask i18n-coverage`,
`cargo xtask layers`, and `cargo xtask wasm`.
IME composition and PSD font matching are separate contributions.