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 <noreply@anthropic.com>
This commit is contained in:
parent
d814a31497
commit
313c555b50
|
|
@ -1,85 +1,41 @@
|
|||
//! Compile the translated format strings into the `tr_format!` macro.
|
||||
//!
|
||||
//! `locales/<code>-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<dyn std::error::Error>> {
|
||||
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<String, Box<dyn std::error::Error>> {
|
||||
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<String, String> = serde_json::from_str(&fs::read_to_string(&path)?)?;
|
||||
let variant = code.split(['-', '_']).filter(|part| !part.is_empty()).map(capitalize).collect::<String>();
|
||||
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<String, String> {
|
||||
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/<code>-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<String, Box<dyn std::error::Error>> {
|
||||
let mut catalogs: Vec<(String, String, BTreeMap<String, String>)> = 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<String, String> =
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} · 右键单击 ▸ 恢复,或永久删除后重新导入"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": "从磁盘重新读取"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -185,44 +185,8 @@ fn catalog_verbatim(language: Locale) -> BTreeMap<String, &'static str> {
|
|||
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
|
||||
|
|
|
|||
|
|
@ -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の保持は
|
||||
|
|
|
|||
|
|
@ -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 ...` 可以渲染中文界面用于检查。
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue