From 313c555b5040aea97e3a67b4f5a522418ddaec98 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 7 Oct 2026 06:13:44 -0400 Subject: [PATCH] LR-BEHAV-LOCALIZE: compile-checked tr_format! for every language catalog Restore main's compile-checked formatting, generalised to N catalogs: build.rs writes one tr_format! arm per English message that matches on the active language and calls format! with that language's translation (English is the fallback arm). The runtime substitution dropped format specs ({:.1}, {:+}, {:.0}%), e.g. the smart-preview size and the merge progress percentage. - formats catalogs use main's convention again ({0}/{1} for reordering, {:.0} to consume an unused plural suffix); zh-hans converted, with the size and percentage placeholders fixed by hand - build.rs: auto-discovers locales/*-formats.json, validates the code (^[a-z]+(-[a-z]+)*$), fails when the catalogs' keys differ, and reruns when the locales directory changes - zh-hans: translate the strings main added since the PR branched - test: precision, sign and reordered positional values survive in every language Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/build.rs | 147 +++++++++++--------- crates/ui-egui/locales/zh-hans-formats.json | 125 +++++++++-------- crates/ui-egui/locales/zh-hans.json | 4 +- crates/ui-egui/src/i18n.rs | 60 +++----- docs/localization-ja.md | 7 +- docs/localization-zh-hans.md | 12 +- docs/localization.md | 21 +-- 7 files changed, 183 insertions(+), 193 deletions(-) diff --git a/crates/ui-egui/build.rs b/crates/ui-egui/build.rs index 600997fd..ebdfa047 100644 --- a/crates/ui-egui/build.rs +++ b/crates/ui-egui/build.rs @@ -1,85 +1,41 @@ //! Compile the translated format strings into the `tr_format!` macro. //! //! `locales/-formats.json` maps an English format string to its translation for one language. -//! Each catalog becomes a `translated_format` match arm, so every language's placeholders are -//! checked by Rust's `format!` — a wrong placeholder is a compile error, not a runtime `{}`. -//! Adding a language means adding its file; this script picks it up by name. -use std::{collections::BTreeMap, env, fs, path::PathBuf}; +//! Every English message becomes one `tr_format!` arm that picks the active language and formats +//! that language's translation with `format!` — so Rust checks every catalog's placeholders against +//! the call site's arguments (a wrong or forgotten placeholder is a compile error, and `{:.1}`, +//! `{:+}`… keep their meaning). The English source is the fallback arm. Adding a language means +//! adding its file (and its `language_table!` entry); this script picks the file up by name. +//! +//! Every catalog must carry the same messages: a `tr_format!` call whose message is in no catalog +//! does not compile, so a message missing from one language would otherwise go unnoticed until it +//! shows English. +use std::{ + collections::{BTreeMap, BTreeSet}, + env, fs, + path::PathBuf, +}; fn main() -> Result<(), Box> { println!("cargo:rerun-if-changed=build.rs"); + // The directory itself, so a catalog added or removed reruns this script. + println!("cargo:rerun-if-changed=locales"); let out = PathBuf::from(env::var("OUT_DIR")?); fs::write(out.join("tr-formats.rs"), generate()?)?; Ok(()) } -fn generate() -> Result> { - let mut catalogs = Vec::new(); - for entry in fs::read_dir("locales")? { - let path = entry?.path(); - let name = path.file_name().and_then(|name| name.to_str()).unwrap_or_default().to_string(); - // `en` is the source language: it has no catalog, its formats are the source themselves. - let Some(code) = name.strip_suffix("-formats.json").filter(|code| *code != "en") else { continue }; - println!("cargo:rerun-if-changed=locales/{name}"); - let messages: BTreeMap = serde_json::from_str(&fs::read_to_string(&path)?)?; - let variant = code.split(['-', '_']).filter(|part| !part.is_empty()).map(capitalize).collect::(); - catalogs.push((variant, messages)); +/// `ja` → `Ja`, `zh-hans` → `ZhHans`: the `Locale` variant the `language_table!` entry declares. +/// The code must be a lowercase BCP-47-like tag (`^[a-z]+(-[a-z]+)*$`), so the variant is always a +/// valid Rust identifier. +fn variant_for(code: &str) -> Result { + let valid = !code.is_empty() && code.split('-').all(|part| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_lowercase())); + if !valid { + return Err(format!("locales/{code}-formats.json: the language code must match ^[a-z]+(-[a-z]+)*$")); } - catalogs.sort_by(|a, b| a.0.cmp(&b.0)); - - // `tr_format!` formats the English source at the call site (where `format!` can check it) and - // also names every value, which is what lets a translation place them. Positional values are - // addressed as `_1`, `_2`, … in call-site order; `macro_rules!` cannot count, so the slot - // numbering is spelled out here and grows with `POSITIONAL_SLOTS` if a call site ever needs more. - const POSITIONAL_SLOTS: usize = 12; - let mut macro_source = String::from(concat!( - "/// Translate a format string with runtime values, keeping the English source's placeholders\n", - "/// checked by `format!` at the call site. `build.rs` writes this macro from `locales/`.\n", - "macro_rules! tr_format {\n", - " ($format:literal $(, $($args:tt)*)?) => {\n", - " $crate::i18n::tr_format!(@scan () () (_positional) ; $format; $($($args)*)?)\n", - " };\n", - " (@scan ($($raw:tt)*) ($($values:tt)*) $position:tt ; $format:literal; $name:ident = $value:expr $(, $($rest:tt)*)?) => {\n", - " $crate::i18n::tr_format!(@scan ($($raw)* $name = $value,) ($($values)* (stringify!($name), format!(\"{}\", $value)),)\n", - " $position ; $format; $($($rest)*)?)\n", - " };\n", - )); - for slot in 1..=POSITIONAL_SLOTS { - macro_source.push_str(&format!( - " (@scan ($($raw:tt)*) ($($values:tt)*) (_positional) ; $format:literal; $value:expr $(, $($rest:tt)*)?) => {{\n $crate::i18n::tr_format!(@scan ($($raw)* $value,) ($($values)* (\"_{slot}\", format!(\"{{}}\", $value)),)\n (_positional) ; $format; $($($rest)*)?)\n }};\n (@scan ($($raw:tt)*) ($($values:tt)*) (_{slot}) ; $format:literal; $value:expr $(, $($rest:tt)*)?) => {{\n $crate::i18n::tr_format!(@scan ($($raw)* $value,) ($($values)* (\"_{next}\", format!(\"{{}}\", $value)),)\n (_positional) ; $format; $($($rest)*)?)\n }};\n", - next = slot + 1 - )); - } - macro_source.push_str(concat!( - " (@scan ($($raw:tt)*) ($($values:tt)*) $position:tt ; $format:literal;) => {\n", - " $crate::i18n::format_message($format, format!($format, $($raw)*), &[$($values)*])\n", - " };\n", - "}\n", - "\n", - "// `macro_rules!` is textual: the module re-export is what lets call sites keep saying\n", - "// `crate::i18n::tr_format!`.\n", - "pub(crate) use tr_format;\n", - "\n", - "impl Locale {\n", - " /// The translated format string for `source`, or `None` when this language has none.\n", - " ///\n", - " /// Generated by `build.rs` from `locales/-formats.json`.\n", - " pub fn translated_format(self, source: &str) -> Option<&'static str> {\n", - " match self {\n", - )); - let mut source = macro_source; - for (variant, messages) in &catalogs { - source.push_str(&format!(" Locale::{variant} => match source {{\n")); - for (english, translated) in messages { - source.push_str(&format!(" {} => Some({}),\n", serde_json::to_string(english)?, serde_json::to_string(translated)?)); - } - source.push_str(" _ => None,\n },\n"); - } - source.push_str(" _ => None,\n }\n }\n}\n"); - Ok(source) + Ok(code.split('-').map(capitalize).collect()) } -/// `zh-hans` → `ZhHans`, the enum variant the `language_table!` entry declares. fn capitalize(part: &str) -> String { let mut chars = part.chars(); match chars.next() { @@ -87,3 +43,58 @@ fn capitalize(part: &str) -> String { None => String::new(), } } + +fn generate() -> Result> { + let mut catalogs: Vec<(String, String, BTreeMap)> = Vec::new(); + for entry in fs::read_dir("locales")? { + let path = entry?.path(); + let name = path.file_name().and_then(|name| name.to_str()).unwrap_or_default().to_string(); + // `en` is the source language: it has no catalog, its formats are the source themselves. + let Some(code) = name.strip_suffix("-formats.json").filter(|code| *code != "en") else { continue }; + println!("cargo:rerun-if-changed=locales/{name}"); + let variant = variant_for(code)?; + let messages: BTreeMap = + serde_json::from_str(&fs::read_to_string(&path)?).map_err(|error| format!("locales/{name}: {error}"))?; + catalogs.push((code.to_string(), variant, messages)); + } + catalogs.sort_by(|a, b| a.0.cmp(&b.0)); + + // Every catalog translates the same messages. + let all: BTreeSet<&String> = catalogs.iter().flat_map(|(_, _, messages)| messages.keys()).collect(); + let mut problems = Vec::new(); + for (code, _, messages) in &catalogs { + for key in &all { + if !messages.contains_key(*key) { + problems.push(format!("locales/{code}-formats.json is missing {key:?}")); + } + } + } + if !problems.is_empty() { + return Err(problems.join("\n").into()); + } + + let mut source = String::from(concat!( + "/// Translate a format string, checked by `format!` in every language.\n", + "/// `build.rs` writes one arm per message from `locales/*-formats.json`.\n", + "macro_rules! tr_format {\n", + )); + for english in all { + let en = serde_json::to_string(english)?; + source.push_str(&format!(" ({en} $(, $($args:tt)*)?) => {{\n match $crate::i18n::language() {{\n")); + for (_, variant, messages) in &catalogs { + if let Some(translated) = messages.get(english) { + let translated = serde_json::to_string(translated)?; + source.push_str(&format!(" $crate::i18n::Locale::{variant} => format!({translated} $(, $($args)*)?),\n")); + } + } + source.push_str(&format!(" _ => format!({en} $(, $($args)*)?),\n }}\n }};\n")); + } + source.push_str(concat!( + "}\n", + "\n", + "// `macro_rules!` is textual: the module re-export is what lets call sites say\n", + "// `crate::i18n::tr_format!`.\n", + "pub(crate) use tr_format;\n", + )); + Ok(source) +} diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json index a1e9f86c..c30f92ea 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -1,19 +1,19 @@ { " · {skipped} skipped (file exists)": " · 已跳过 {skipped} 个(文件已存在)", - " · {} duplicate{} skipped": " · 已跳过 {_1} 个重复项", - " · {} failed: {}": " · {_1} 个失败:{_2}", - " · {} moved": " · 已移动 {_1} 个", - " · {} not readable": " · {_1} 个无法读取", - ", {failed} file{} not readable": ",{failed} 个文件无法读取", + " · {} duplicate{} skipped": " · 已跳过 {0} 个重复项{1:.0}", + " · {} failed: {}": " · {0} 个失败:{1}", + " · {} moved": " · 已移动 {0} 个", + " · {} not readable": " · {0} 个无法读取", + ", {failed} file{} not readable": ",{failed} 个文件无法读取{0:.0}", "Add {local_n} to My Photos": "将 {local_n} 张照片添加到我的照片", - "Add {} Photo{}": "添加 {_1} 张照片", - "Add “{}” to Favorites": "将“{_1}”添加到收藏夹", - "Added {n} photo{} to My Photos": "已将 {n} 张照片添加到我的照片", - "Added {n} photo{} to “{}”": "已将 {n} 张照片添加到“{_2}”", + "Add {} Photo{}": "添加 {0} 张照片{1:.0}", + "Add “{}” to Favorites": "将“{0}”添加到收藏夹", + "Added {n} photo{} to My Photos": "已将 {n} 张照片添加到我的照片{0:.0}", + "Added {n} photo{} to “{}”": "已将 {n} 张照片添加到“{1}”{0:.0}", "All {n} selected photos move by the same amount.": "所选 {n} 张照片将按相同幅度移动。", "Auto Sync: edits apply to {n} photos": "自动同步:编辑将应用到 {n} 张照片", - "Automatic ({} MB)": "自动({_1} MB)", - "Click {}:1": "单击 {_1}:1", + "Automatic ({} MB)": "自动({0} MB)", + "Click {}:1": "单击 {0}:1", "Create {n} Virtual Copies": "创建 {n} 个虚拟副本", "Decoder: {reason}": "解码器:{reason}", "Delete \"{label}\"": "删除 \"{label}\"", @@ -22,83 +22,86 @@ "Delete “{current}”": "删除“{current}”", "Duplicate \"{label}\"": "复制 \"{label}\"", "Example: {example}": "示例:{example}", - "Exported {n} photo{}": "已导出 {n} 张照片", - "Exported {ok} of {total} photo{}": "已导出 {total} 张中的 {ok} 张照片", - "Exported {} point curve preset{}": "已导出 {_1} 个点曲线预设", - "Exported {} preset{}": "已导出 {_1} 个预设", - "Exporting {} of {total}": "正在导出 {total} 张中的第 {_1} 张", - "Filter bar — {active} active filter{}": "筛选栏 — 已启用 {active} 个筛选", - "Healed {n} dust spot{}": "已修复 {n} 处尘点", - "Imported {n} point curve preset{}": "已导入 {n} 个点曲线预设", - "Imported {n} preset{}": "已导入 {n} 个预设", - "Imported {n} preset{}, {f} file{} not readable": "已导入 {n} 个预设,{f} 个文件无法读取", - "Imported {} photo{}": "已导入 {_1} 张照片", - "Matches: {}": "匹配项:{_1}", - "Merging… {stage} {:.0}%": "合成中… {stage} %", + "Exported {n} photo{}": "已导出 {n} 张照片{0:.0}", + "Exported {ok} of {total} photo{}": "已导出 {total} 张中的 {ok} 张照片{0:.0}", + "Exported {} point curve preset{}": "已导出 {0} 个点曲线预设{1:.0}", + "Exported {} preset{}": "已导出 {0} 个预设{1:.0}", + "Exporting {} of {total}": "正在导出 {total} 张中的第 {0} 张", + "Filter bar — {active} active filter{}": "筛选栏 — 已启用 {active} 个筛选{0:.0}", + "Healed {n} dust spot{}": "已修复 {n} 处尘点{0:.0}", + "Imported {n} point curve preset{}": "已导入 {n} 个点曲线预设{0:.0}", + "Imported {n} preset{}": "已导入 {n} 个预设{0:.0}", + "Imported {n} preset{}, {f} file{} not readable": "已导入 {n} 个预设,{f} 个文件无法读取{0:.0}{1:.0}", + "Imported {} photo{}": "已导入 {0} 张照片{1:.0}", + "Matches: {}": "匹配项:{0}", + "Merging… {stage} {:.0}%": "合成中… {stage} {0:.0}%", "Move {what} to Recently Deleted?": "将 {what} 移到最近删除?", "Named ({named_n})": "已命名({named_n})", - "Paste into {n} photo{} — only settings that were copied are pasted": "粘贴到 {n} 张照片 — 仅粘贴已复制的设置", + "Paste into {n} photo{} — only settings that were copied are pasted": "粘贴到 {n} 张照片 — 仅粘贴已复制的设置{0:.0}", "Preset: {name}": "预设:{name}", - "Preview only — editing the camera's embedded JPEG ({})": "仅预览 — 正在编辑相机内嵌的 JPEG({_1})", - "Preview only — {}": "仅预览 — {_1}", - "Profile: {}": "配置文件:{_1}", - "Proof Copy ({})": "校样副本({_1})", - "Proof Preview · {}": "校样预览 · {_1}", + "Preview only — editing the camera's embedded JPEG ({})": "仅预览 — 正在编辑相机内嵌的 JPEG({0})", + "Preview only — {}": "仅预览 — {0}", + "Profile: {}": "配置文件:{0}", + "Proof Copy ({})": "校样副本({0})", + "Proof Preview · {}": "校样预览 · {0}", "Quick Develop · {n} photos": "快速编辑 · {n} 张照片", - "Rated {}": "已评级 {_1}", + "Rated {}": "已评级 {0}", "Reading photos… {done} of {total}": "正在读取照片… {total} 张中的 {done} 张", - "Redo {}": "重做:{_1}", - "Remove “{}” from Favorites": "从收藏夹中移除“{_1}”", + "Redo {}": "重做:{0}", + "Remove “{}” from Favorites": "从收藏夹中移除“{0}”", "Rename {n} Photos…": "重命名 {n} 张照片…", "Rename “{name}”": "重命名“{name}”", "Rendering on the CPU: {why}": "正在使用 CPU 渲染:{why}", - "Reset {} Channel": "重置 {_1} 通道", - "Saved export preset “{}”": "已保存导出预设“{_1}”", - "Show {hidden} hidden location{}": "显示 {hidden} 个隐藏位置", - "Showing {n} of {}": "显示 {_1} 张中的 {n} 张", - "Smart album: {}": "智能相册:{_1}", - "Undo {}": "撤销 {_1}", - "Version {} — a clean-room, pure-Rust photo library and raw developer.": "版本 {_1} — 净室实现的纯 Rust 照片图库与 RAW 编辑工具。", - "Version: {}": "版本:{_1}", + "Reset {} Channel": "重置 {0} 通道", + "Saved export preset “{}”": "已保存导出预设“{0}”", + "Show {hidden} hidden location{}": "显示 {hidden} 个隐藏位置{0:.0}", + "Showing {n} of {}": "显示 {0} 张中的 {n} 张", + "Smart album: {}": "智能相册:{0}", + "Undo {}": "撤销 {0}", + "Version {} — a clean-room, pure-Rust photo library and raw developer.": "版本 {0} — 净室实现的纯 Rust 照片图库与 RAW 编辑工具。", + "Version: {}": "版本:{0}", "none (CPU rendering): {why}": "无(CPU 渲染):{why}", "the {n} selected photos": "选中的 {n} 张照片", - "the {} photos in view": "视图中的 {_1} 张照片", + "the {} photos in view": "视图中的 {0} 张照片", "these {count} photos": "这 {count} 张照片", - "{count} smart preview{} · {:.1} MB": "{count} 个智能预览 · {_1} MB", + "{count} smart preview{} · {:.1} MB": "{count} 个智能预览{0:.0} · {1:.1} MB", "{f} folders (and their subfolders)": "{f} 个文件夹(及其子文件夹)", "{label} Grading": "{label} 颜色分级", "{label} {d:+} on every selected photo": "每张选中照片的 {label} {d:+}", "{label}, {n} photos": "{label},{n} 张照片", "{matching} of {t} photos": "{t} 张照片中的 {matching} 张", "{matching} photos": "{matching} 张照片", - "{mode} spot {} of {}": "{mode}:第 {_1} 个污点,共 {_2} 个", + "{mode} spot {} of {}": "{mode}:第 {0} 个污点,共 {1} 个", "{msg} ({failed} failed)": "{msg}({failed} 项失败)", "{name} Label": "{name} 标签", "{name} Off": "已关闭 {name}", "{name} On": "已开启 {name}", "{name} — double-click to reset it": "{name} — 双击可重置", - "{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.": "{n} 项更改已保存在内存中,但尚未写入磁盘:{e}\nLightCraft 会自动重试;现在退出将丢失这些更改。", + "{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.": "{n} 项更改已保存在内存中,但尚未写入磁盘:{e}\nLightCraft 会自动重试;现在退出将丢失这些更改。{0:.0}{1:.0}", "{n} found · {sel} selected": "找到 {n} 张 · 已选 {sel} 张", "{n} photos": "{n} 张照片", - "{n} photos in {} are hidden by the filters above": "{_1} 中的 {n} 张照片被上方的过滤器隐藏", - "{n} photo{}": "{n} 张照片", - "{n} photo{} match · updates automatically as photos change": "{n} 张照片符合 · 随照片变化自动更新", - "{n} photo{} now · updates automatically as photos change": "当前 {n} 张照片 · 随照片变化自动更新", + "{n} photos in {} are hidden by the filters above": "{0} 中的 {n} 张照片被上方的过滤器隐藏", + "{n} photo{}": "{n} 张照片{0:.0}", + "{n} photo{} match · updates automatically as photos change": "{n} 张照片符合 · 随照片变化自动更新{0:.0}", + "{n} photo{} now · updates automatically as photos change": "当前 {n} 张照片 · 随照片变化自动更新{0:.0}", "{sel_n} selected · {counted}": "已选择 {sel_n} · {counted}", - "{stage}… {:.0}%": "{stage}… %", + "{stage}… {:.0}%": "{stage}… {0:.0}%", "{what} merge added": "已添加 {what} 合并", "{what} merge failed: {e}": "{what} 合并失败:{e}", - "{} and higher": "{_1} 及以上", - "{} files": "{_1} 个文件", - "{} files and {f} folder{}": "{_1} 个文件和 {f} 个文件夹", - "{} of 4 guides — drag along lines that should be vertical or horizontal.": "已画 {_1} 条参考线(共 4 条)— 沿应为垂直或水平的线拖动。", - "{} photos": "{_1} 张照片", - "{} photo{} not readable": "{_1} 张照片无法读取", - "{} spot(s) on this photo": "此照片上有 {_1} 处污点", + "{} and higher": "{0} 及以上", + "{} files": "{0} 个文件", + "{} files and {f} folder{}": "{0} 个文件和 {f} 个文件夹{1:.0}", + "{} of 4 guides — drag along lines that should be vertical or horizontal.": "已画 {0} 条参考线(共 4 条)— 沿应为垂直或水平的线拖动。", + "{} photos": "{0} 张照片", + "{} photo{} not readable": "{0} 张照片无法读取{1:.0}", + "{} spot(s) on this photo": "此照片上有 {0} 处污点", "· {dups} already in the library": "· {dups} 张已在图库中", - "· {n} photo{}": "· {n} 张照片", + "· {n} photo{}": "· {n} 张照片{0:.0}", "… and {more} more": "…以及另外 {more} 项", - "Import cancelled · {} photo{} added": "导入已取消 · 已添加 {_1} 张照片", - "Reading photos… {} of {}": "正在读取照片… 第 {_1} 张,共 {_2} 张", - "Adding photos… {} of {}": "正在添加照片… 第 {_1} 张,共 {_2} 张" + "Import cancelled · {} photo{} added": "导入已取消 · 已添加 {0} 张照片{1:.0}", + "Reading photos… {} of {}": "正在读取照片… 第 {0} 张,共 {1} 张", + "Adding photos… {} of {}": "正在添加照片… 第 {0} 张,共 {1} 张", + " ({} in Recently Deleted)": "(其中 {0} 张在“最近删除”中)", + "Already in the library: {} photo{} selected in All Photos": "已在图库中:已在“所有照片”中选择 {0} 张照片{1:.0}", + "Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "已在图库的“最近删除”中:已选择 {0} 张照片{1:.0} · 右键单击 ▸ 恢复,或永久删除后重新导入" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 3ebd08d2..5cac9dfe 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1017,5 +1017,7 @@ "Quit": "退出", "Temporary session — your library isn't open, and nothing you do here is saved.": "临时会话 — 图库未打开,此处所做的任何操作都不会保存。", "Back Up Library…": "备份图库…", - "Restore Library from Backup…": "从备份恢复图库…" + "Restore Library from Backup…": "从备份恢复图库…", + "In Recently Deleted": "在“最近删除”中", + "Reload from Disk": "从磁盘重新读取" } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 55f52d52..92ac47ee 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -185,44 +185,8 @@ fn catalog_verbatim(language: Locale) -> BTreeMap { language.catalog().iter().map(|(key, value)| (key.clone(), value.as_str())).collect() } -/// Whether the language translates anything at all (the `tr_format!` fast path). -pub fn has_translations(language: Locale) -> bool { - !language.catalog().is_empty() -} - -/// Translate a format string with runtime values, as `tr_format!` calls it. -/// -/// `english` is the source text already formatted with the call site's values, and `values` names -/// those values so the translation can place them. A message with no translation, or a translation -/// without placeholders, keeps `english` as it is. -pub fn format_message(source: &str, english: String, values: &[(&str, String)]) -> String { - let Some(translated) = language().translated_format(source) else { - return english; - }; - if !translated.contains('{') { - return translated.to_string(); - } - let mut out = String::with_capacity(translated.len()); - let mut rest = translated; - while let Some(start) = rest.find('{') { - let Some(end) = rest[start..].find('}') else { break }; - let field = &rest[start + 1..start + end]; - let (name, _) = field.split_once(':').unwrap_or((field, "")); - out.push_str(&rest[..start]); - // The catalogs spell every value out by name; anything else (a `{}` that only the English - // text used, or a name no call site passes) is left exactly as the translator wrote it. - match values.iter().find(|(key, _)| *key == name) { - Some((_, value)) => out.push_str(value), - None => out.push_str(&rest[start..start + end + 1]), - } - rest = &rest[start + end + 1..]; - } - out.push_str(rest); - out -} - -// `tr_format!` (one arm per language) and the translated-format lookup are generated by -// `build.rs`; `Locale` is declared above, so the generated `impl Locale` has it in scope. +// `tr_format!` (one arm per message, one `format!` per language) is generated by `build.rs` from +// `locales/*-formats.json`. include!(concat!(env!("OUT_DIR"), "/tr-formats.rs")); #[cfg(test)] @@ -311,8 +275,7 @@ mod tests { /// falls back to the English format rather than dropping the values. #[test] fn formats_render_in_every_language() { - // The English source is what `format!` checks at the call site; each translation then places - // the same values by name (`{n}`, `{total}`) or by position (`{_1}`, `{_2}`). + // Every language's format string is checked by `format!` against the call site's values. set_language(Locale::En); assert_eq!(tr_format!("Imported {} photo{}", 12, "s"), "Imported 12 photos"); assert_eq!(tr_format!("Exported {ok} of {total} photo{}", "s", ok = 4, total = 12), "Exported 4 of 12 photos"); @@ -322,10 +285,21 @@ mod tests { set_language(Locale::Ja); assert_eq!(tr_format!("Imported {} photo{}", 12, "s"), "12枚を読み込みました"); assert_eq!(tr_format!("Exported {ok} of {total} photo{}", "s", ok = 4, total = 12), "12枚中4枚を書き出しました"); + // Format specs survive translation: precision, sign, and values a language reorders. + for language in Locale::ALL { + set_language(*language); + let text = tr_format!("{count} smart preview{} · {:.1} MB", "s", 12.345, count = 3); + assert!(text.contains("12.3") && !text.contains("12.34"), "{language:?}: {text}"); + let merging = tr_format!("Merging… {stage} {:.0}%", 42.4, stage = "Aligning"); + assert!(merging.contains("42%"), "{language:?}: {merging}"); + let delta = tr_format!("{label} {d:+} on every selected photo", label = "Exposure", d = 5); + assert!(delta.contains("+5"), "{language:?}: {delta}"); + let reading = tr_format!("Reading photos… {} of {}", 3, 10); + let (three, ten) = (reading.find('3'), reading.find("10")); + assert!(three.is_some() && ten.is_some(), "{language:?}: {reading}"); + } set_language(Locale::En); - // A format no catalog carries keeps its values, and one with no values keeps its text. - assert_eq!(tr_format!("{n} uncatalogued {thing}", n = 3, thing = "things"), "3 uncatalogued things"); - assert_eq!(tr_format!("Activity"), "Activity"); + assert_eq!(tr_format!("Reading photos… {} of {}", 3, 10), "Reading photos… 3 of 10"); } /// Every character a catalog paints has a glyph in the fonts the UI installs, so a translation diff --git a/docs/localization-ja.md b/docs/localization-ja.md index 9dfa998e..bcfb8a8d 100644 --- a/docs/localization-ja.md +++ b/docs/localization-ja.md @@ -19,10 +19,9 @@ LightCraft は日本語・簡体字中国語・英語(English)の表示に 静的な表示文言は `crates/ui-egui/locales/ja.json`、可変値を含む表示文言は `crates/ui-egui/locales/ja-formats.json` にあります。英文をキーにして翻訳を管理します。 -可変文言は、呼び出し側の英語の書式を `format!` がそのまま検査し、その値を翻訳側が -名前(`{n}`、`{total}`)または位置(`_1`、`_2` …)で参照します。英語の単複数語尾のように -中国語・日本語で不要な値は、**単に参照しないだけ**です(`{:.0}` のような空の引数は不要)。 -両言語の訳文はビルド時に `format!` の検査を受けるため、存在しない名前を書くとコンパイルエラーになります。 +可変文言の訳文はすべてビルド時にRustの `format!` 検査を受けます。値は名前(`{n}`)または +位置(`{0}`、`{1}`)で参照し、`{:.1}` などの書式指定は英文と同じものを使います。 +英語の単複数語尾を日本語で省略する場合、対応する文字列引数は `{:.0}` で空にします。 `LIGHTCRAFT_LANGUAGE=ja lightcraft-cli snapshot ...` で日本語の画面を描画できます。 表示・フォント・言語の切り替え・設定の保存・コマンドIDの保持は diff --git a/docs/localization-zh-hans.md b/docs/localization-zh-hans.md index f643c2aa..2da811bb 100644 --- a/docs/localization-zh-hans.md +++ b/docs/localization-zh-hans.md @@ -22,13 +22,13 @@ LightCraft 的界面支持简体中文、日语和英语。语言设置保存在 含数值的文字遵循以下约定: -- 调用点的英文原文由 `format!` 检查(占位符写错会编译失败),翻译再按名字或位置引用同一个值。 +- 每个译文都是普通的 Rust 格式字符串,构建时由 `format!` 对照调用点的参数检查(占位符写错会编译失败)。 - **按名字**:`{n}`、`{label}`、`{total}` 等与英文原文中的名字一致。 -- **按位置**:`_1`、`_2`、`_3` … 对应调用点参数的先后顺序(从 1 开始),用于调整语序, - 例如 `Added {n} photo{} to “{}”` 译为 `已将 {n} 张照片添加到“{_2}”`。 -- 英文用来表示单复数的那个参数(`"s"` / `""`)在中文里**不需要写**,直接不引用即可; - 不需要 `{:.0}` 之类的空占位符。 -- 译文里的占位符同样会被 `format!` 检查:写了不存在的名字就编译不过。 +- **按位置**:`{0}`、`{1}` … 对应调用点无名参数的先后顺序(从 0 开始),用于调整语序, + 例如 `Added {n} photo{} to “{}”` 译为 `已将 {n} 张照片添加到“{1}”{0:.0}`。 +- 保留英文原文的格式说明(`{:.1}`、`{d:+}`、`{:.0}%`)。 +- `format!` 不允许未使用的参数:英文用来表示单复数的那个参数(`"s"` / `""`)在中文里写成 + `{:.0}`(精度为 0 的字符串不输出任何内容)。 `LIGHTCRAFT_LANGUAGE=zh-hans lightcraft-cli snapshot ...` 可以渲染中文界面用于检查。 diff --git a/docs/localization.md b/docs/localization.md index da3ff77d..b846df7a 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -38,21 +38,22 @@ are in [`localization-zh-hans.md`](localization-zh-hans.md) and [`localization-j text unchanged when there is no entry; `tr_format!` handles text with runtime values. Both are presentation-only: command ids, file names and user-entered metadata are never translated. -`tr_format!` is generated by `crates/ui-egui/build.rs`. It formats the **English source at the call -site**, which is what gives `format!` a literal to check against that call site's own arguments, and -it also names those values so a translation can place them: +`tr_format!` is generated by `crates/ui-egui/build.rs`: one arm per English message, which formats +the active language's translation with `format!` (English is the fallback arm). Every catalog's +format string is therefore checked by Rust against the call site's own arguments: ```rust tr_format!("Added {n} photo{} to “{}”", if n == 1 { "" } else { "s" }, album, n = n) ``` -- A translation refers to a value **by name** exactly as the English source spells it (`{n}`), or - **by position** as `_1`, `_2`, … in call-site order. Reordering is fine: - `"已将 {n} 张照片添加到“{_2}”"`. -- The English plural suffix (`"s"` / `""`) is simply not referenced in languages without plurals. -- Every translation is placeholder-checked at compile time: an unknown name, or a spec that does not - match its value, fails the build rather than printing `{}` at runtime. -- A source string with no translation falls back to the English text, values included. +- A translation is an ordinary Rust format string over the same arguments. Refer to named values by + name (`{n}`) and to positional ones by index when the word order changes (`{0}`, `{1}`): + `"已将 {n} 张照片添加到“{1}”{0:.0}"`. Keep the source's spec (`{:.1}`, `{d:+}`, `{:.0}%`). +- `format!` rejects an unused argument, so a value a language does not need — the English plural + suffix (`"s"` / `""`) — is consumed as `{:.0}` (a string at precision 0 prints nothing). +- Every formats catalog carries the same messages (`build.rs` fails the build otherwise), and a + `tr_format!` call whose message is in no catalog does not compile: add it to every + `*-formats.json` file. ## Testing and looking at it