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:
Brandon Thomas 2026-10-07 06:13:44 -04:00
parent d814a31497
commit 313c555b50
7 changed files with 183 additions and 193 deletions

View File

@ -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)
}

View File

@ -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} · 右键单击 ▸ 恢复,或永久删除后重新导入"
}

View File

@ -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": "从磁盘重新读取"
}

View File

@ -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

View File

@ -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の保持は

View File

@ -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 ...` 可以渲染中文界面用于检查。

View File

@ -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