i18n(sr): add a Serbian (Latin, Ekavian) UI catalog (#4436)

* i18n(sr): add a Serbian (Latin, Ekavian) UI catalog

Adds sr.tsv with 4,022 rows: every key the i18n-coverage tool measures
plus the union of the dynamic labels the other catalogs translate. The
catalog is written in Latin script, Ekavian; every Serbian locale
(sr-RS, sr-Latn-*, sr_RS@latin and Cyrillic sr-Cyrl-*) resolves to it.
Plurals use the one/few/many rule (plural_russian) with three forms per
@plural entry.

Original translations from the plain meaning of the English labels; no
proprietary localisation resources were used (ATTRIBUTION.md and
LICENSE-translations.txt updated). Tests cover locale resolution,
contextual and id overrides, placeholders, and plurals across teens and
compound counts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Info.plist: declare the Serbian localization

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dejan Port <snork1982@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-10-12 22:50:20 -04:00
1 parent 0a32b3593d
commit 20539b5dc3
10 files changed
+4254 -7

No files matched your search

+1
View File
@@ -23,6 +23,7 @@ next to them.
| `crates/ui-egui/src/i18n/hu.tsv` | Hungarian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/tr.tsv` | Turkish UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `crates/ui-egui/src/i18n/sr.tsv` | Serbian (Latin, Ekavian) UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | <https://github.com/rsms/inter> | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) |
| `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | <https://github.com/JetBrains/JetBrainsMono> | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) |
| `assets/icons/*.svg` except `slice-knife.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
@@ -1,6 +1,6 @@
PhotoCraft's Korean, German, Brazilian Portuguese, Italian, Ukrainian, Dutch, Czech, Turkish and
Hungarian UI translation catalogs are licensed under MIT OR Apache-2.0, at your option. The full
license texts are in ../../../../LICENSE-MIT and ../../../../LICENSE-APACHE.
PhotoCraft's Korean, German, Brazilian Portuguese, Italian, Ukrainian, Dutch, Czech, Turkish,
Hungarian and Serbian UI translation catalogs are licensed under MIT OR Apache-2.0, at your option.
The full license texts are in ../../../../LICENSE-MIT and ../../../../LICENSE-APACHE.
Korean translations: PhotoCraft contributors, original translations of
PhotoCraft's English labels; no proprietary translation resources were used.
@@ -28,3 +28,6 @@ PhotoCraft's English labels; no proprietary translation resources were used.
Hungarian translations: PhotoCraft contributors, original translations of
PhotoCraft's English labels; no proprietary translation resources were used.
Serbian translations: PhotoCraft contributors, original translations of
PhotoCraft's English labels; no proprietary translation resources were used.
+1
View File
@@ -20,6 +20,7 @@ fn requested_languages_resolve_regional_preferences() {
("fr-CA", "fr"),
("id-ID", "id"),
("vi-VN", "vi"),
("sr-RS", "sr"),
] {
assert_eq!(Lang::from_pref(tag), lang(code), "{tag}");
}
+65 -1
View File
@@ -187,7 +187,7 @@ fn expand(packed: &str, keys: &[&str]) -> Result<String, String> {
}
/// The registry. English first: it is the fallback and the source language.
pub static LANGUAGES: [LangInfo; 20] = [
pub static LANGUAGES: [LangInfo; 21] = [
LangInfo { code: "en", name: "English", source: b"", plural: plural_one_other, complete_menus: false, catalog: OnceLock::new() },
LangInfo { code: "ja", name: "日本語", source: catalog!("ja"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() },
LangInfo { code: "zh-hans", name: "简体中文", source: catalog!("zh-hans"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() },
@@ -198,6 +198,10 @@ pub static LANGUAGES: [LangInfo; 20] = [
LangInfo { code: "ru", name: "Русский", source: catalog!("ru"), plural: plural_russian, complete_menus: true, catalog: OnceLock::new() },
// Ukrainian has the same one/few/many rule for integer counts.
LangInfo { code: "uk", name: "Українська", source: catalog!("uk"), plural: plural_russian, complete_menus: true, catalog: OnceLock::new() },
// Serbian (Latin script, Ekavian) has the same one/few/many rule for integer counts. `sr-RS`,
// `sr-Latn-*` and `sr_RS@latin` resolve here; so do Cyrillic `sr` locales, which read Latin
// rather than English.
LangInfo { code: "sr", name: "Srpski", source: catalog!("sr"), plural: plural_russian, complete_menus: true, catalog: OnceLock::new() },
LangInfo { code: "cs", name: "Čeština", source: catalog!("cs"), plural: plural_cs, complete_menus: true, catalog: OnceLock::new() },
LangInfo { code: "fr", name: "Français", source: catalog!("fr"), plural: plural_fr, complete_menus: true, catalog: OnceLock::new() },
LangInfo { code: "id", name: "Bahasa Indonesia", source: catalog!("id"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() },
@@ -680,6 +684,66 @@ mod tests {
}
}
#[test]
fn serbian_resolves_locales_and_preferences() {
let sr = Lang::from_code("sr").expect("sr registered");
assert_eq!(sr.name(), "Srpski");
assert!(sr.complete_menus());
for tag in ["sr", "SR", "sr-RS", "sr_RS", "sr_RS.UTF-8", "sr_RS@latin", "sr-Latn", "sr-Latn-RS", "sr-Cyrl-RS", "sr-ME"] {
assert_eq!(lang_from_tag(tag), Some(sr), "{tag}");
assert_eq!(Lang::from_pref(tag), sr, "{tag}");
}
assert_eq!(tr(sr, "File"), "Datoteka");
assert_eq!(tr(sr, "Layer"), "Sloj");
assert_eq!(tr(sr, "New document…"), "Novi dokument…");
assert_eq!(tr_id(sr, "select.all", "All"), "Izaberi sve", "an id override wins over the plain label");
assert_eq!(tr(sr, "All"), "Sve");
assert_eq!(tr_ctx(sr, "cameraRaw", "Light"), "Svetlo");
assert_eq!(tr_ctx(sr, "fontWeight", "Light"), "Lagano");
assert_eq!(fmt(tr(sr, "Camera Raw Filter ({layer})"), &[("layer", "Background")]), "Camera Raw filter (Background)");
for key in ["Alt", "⌥"] {
assert_eq!(
fmt(tr(sr, "Add a mask (from the selection; {key} inverts)"), &[("key", key)]),
format!("Dodaj masku (iz selekcije; {key} invertuje)")
);
}
}
#[test]
fn serbian_plural_messages_handle_teens_and_compound_counts() {
let sr = Lang::from_code("sr").expect("sr registered");
for (n, item, layer) in [
(0, "stavki", "slojeva"),
(1, "stavka", "sloj"),
(2, "stavke", "sloja"),
(4, "stavke", "sloja"),
(5, "stavki", "slojeva"),
(11, "stavki", "slojeva"),
(12, "stavki", "slojeva"),
(14, "stavki", "slojeva"),
(21, "stavka", "sloj"),
(22, "stavke", "sloja"),
(25, "stavki", "slojeva"),
(101, "stavka", "sloj"),
(111, "stavki", "slojeva"),
(112, "stavki", "slojeva"),
(122, "stavke", "sloja"),
] {
assert_eq!(trn(sr, n, "{n} item", "{n} items"), format!("{n} {item}"));
assert_eq!(trn(sr, n, "{n} layer", "{n} layers"), format!("{n} {layer}"));
assert_eq!(trn(sr, n, "Group · {n} layer", "Group · {n} layers"), format!("Grupa · {n} {layer}"));
}
for (n, expected) in [
(1, "Uklonjena je 1 tačka uzorkovanja"),
(3, "Uklonjene su 3 tačke uzorkovanja"),
(5, "Uklonjeno je 5 tačaka uzorkovanja"),
(13, "Uklonjeno je 13 tačaka uzorkovanja"),
(21, "Uklonjena je 21 tačka uzorkovanja"),
] {
assert_eq!(trn(sr, n, "{n} sample point removed", "{n} sample points removed"), expected);
}
}
#[test]
fn catalog_kinds_are_parsed_and_looked_up() {
let c = Catalog::parse("# c\n\tHello\tこんにちは\n@id\tfile.save\t保存する\nmenu\tWindows\tウィンドウ群\n@plural\t{n} file|{n} files\t{n} 個\n\n");
File diff suppressed because it is too large. Load diff
+2
View File
@@ -98,6 +98,8 @@ mod tests {
(vec!["nl-BE", "fr-BE"], "nl"),
(vec!["hu-HU", "en-US"], "hu"),
(vec!["tr-TR", "en-US"], "tr"),
(vec!["sr-Latn-RS", "en-US"], "sr"),
(vec!["sv-SE", "sr_RS@latin", "ru-RU"], "sr"),
(vec![], "en"),
] {
with_system_tags(&tags, || assert_eq!(system_lang().code(), expected));
+5 -2
View File
@@ -1,6 +1,6 @@
# Localization parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Italian native review; previously minor: Turkish catalog) · **Target:** Adobe Photoshop 2026
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Serbian catalog; previously minor: Italian native review) · **Target:** Adobe Photoshop 2026
Per-language status of PhotoCraft's UI. How catalogs work and how to add one:
[`localization.md`](localization.md). Language notes: [Korean](localization-ko.md), [Spanish](localization-es.md),
@@ -49,7 +49,9 @@ speakers, ~55% ready. Remaining: 80–140 h, plus native-speaker review (recorde
Chinese `zh-hant` (2,329 rows), Russian `ru` (2,544), Ukrainian `uk` (3,375), Czech `cs` (3,522),
Polish `pl` (3,366), Greek `el` (3,481), Dutch `nl` (3,568), Italian `it` (3,928; also every dynamic label the
other catalogs translate; reviewed by a native speaker, [notes](localization-it.md)). Turkish `tr`
(3,731 rows) also covers every measured key.
(3,731 rows) also covers every measured key. Serbian `sr` (4,022 rows, Latin script, Ekavian, added 2026-10-12) covers every
measured key and the union of the other catalogs' dynamic labels; its one/few/many plurals reuse
the Russian rule.
Czech UI follow-up (2026-10-11): status and notice wrappers, hardware labels, brush and
transform accessibility names, rasterization prompts, profile mismatch messages and preset
@@ -82,3 +84,4 @@ engine localization or native review is complete.
| 2026-10-11 | minor | Italian: 1,287 dynamic labels added (filter parameters, Camera Raw, lighting, panels, dialogs) and terminology made consistent |
| 2026-10-12 | minor | Added Turkish (`tr`) catalog with 100% measured UI coverage |
| 2026-10-12 | minor | Italian native-speaker review: mistranslations (Trap, HDR Toning, Display Text) and phrasing fixed |
| 2026-10-12 | minor | Serbian (`sr`, Latin, Ekavian) catalog: every measured key plus the dynamic labels of the other catalogs |
+3 -1
View File
@@ -27,6 +27,7 @@ contextual, command-ID and plural translations; missing entries fall back to Eng
| Nederlands | `nl` | one / other |
| Magyar | `hu` | one form |
| Tiếng Việt | `vi` | one form |
| Srpski | `sr` | one / few / many (teens use many) |
Languages registered with `complete_menus` cover the current menu labels, `tl!` literals, blend
mode names, and generated preference labels; tests enforce that coverage. Vietnamese (`vi`) is
@@ -47,7 +48,8 @@ The existing preference store saves the selection for future launches. `auto` fo
native system locale; an unsupported code follows the same fallback. Regional tags such as
`fr-CA`, `ko-KR`, `vi-VN` and `zh-CN` resolve to the corresponding registered catalog. Traditional and
Simplified Chinese remain distinct. Every Portuguese locale (`pt`, `pt-BR`, `pt-PT`) uses the
Brazilian Portuguese catalog.
Brazilian Portuguese catalog. The Serbian catalog is written in Latin script (Ekavian); every
Serbian locale (`sr-RS`, `sr-Latn-RS`, `sr_RS@latin`, and Cyrillic `sr-Cyrl-RS`) resolves to it.
### First launch and system language
+1
View File
@@ -17,6 +17,7 @@
<string>es</string>
<string>ru</string>
<string>uk</string>
<string>sr</string>
<string>cs</string>
<string>fr</string>
<string>id</string>
+1
View File
@@ -582,6 +582,7 @@ mod tests {
("hu", "hu.tsv"),
("tr", "tr.tsv"),
("vi", "vi.tsv"),
("sr", "sr.tsv"),
] {
let catalog_path = root.join("crates/ui-egui/src/i18n").join(path);
let text = fs::read_to_string(&catalog_path).unwrap_or_default();