ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

CADCraft 界面本地化实战:17 种语言、显示时目录、字体回退与自动化切换

CADCraft 界面本地化实战:17 种语言、显示时目录、字体回退与自动化切换

【免费下载链接】cadcraft

CADCraft: computer-aided design and drafting — an open-source, clean-room AutoCAD-style app in pure Rust

项目地址:https://gitcode.com/gh_mirrors/ca/cadcraft
点击查看免费下载

CADCraft 把界面文案与绘图数据彻底解耦:英文源码字符串始终是查找键,翻译只在“显示边界”发生,因此切换语言既不会改动图纸内容,也不会改变命令 ID 与自动化协议。本文以 docs/localization.md 为主线,结合 crates/ui-egui/src/i18n.rs 的真实实现,讲清语言选择、Auto 解析、CADCRAFT_LOCALE覆盖、控制通道调用、偏好持久化、字体与字形回退,以及如何新增一门语言目录。

语言清单与入口

菜单路径固定为Edit → Interface Language,下拉框列出以下 17 个偏好值(外加一个 “auto” 语义项):

显示名偏好代码说明
Auto (system language)auto跟随操作系统或浏览器语言
Englishen源语言,缺翻译时的兜底
Ελληνικάel希腊语
Українськаuk乌克兰语
Português (Brasil)pt-BR巴西葡萄牙语
Deutschde德语
Españoles西班牙语
Češtinacs捷克语
Русскийru俄语
Françaisfr法语
简体中文zh中文(需要 CJK 字体)
Italianoit意大利语
한국어 / Koreanko韩语(无 Hangul 字体时显示英文名)
Svenskasv瑞典语
العربيةar阿拉伯语(从右到左书写)
Magyarhu匈牙利语
Türkçetr土耳其语

这些取值是“偏好”而不是“实际显示语言”:Preference枚举(crates/ui-egui/src/i18n.rs#L11-L48)通过#[serde(rename = ...)]与字符串一一对应,Preference::code()给出序列化/命令行代码,Preference::name()给出界面显示名。选择结果立即生效,无需重启,包括 macOS 的原生菜单——原生菜单在语言变化时逐项重写标题(apps/cadcraft/src/native_menu.rs#L114-L140)。

下拉框本身的排序并非数组顺序,而是Preference::for_picker()的结果(crates/ui-egui/src/i18n.rs#L71-L86):先Auto与English,其余按“该语言自己的名字”做去重音折叠后排序,因此 Čeština 落在 C 下、Magyar 落在 M 下;非拉丁文字(希腊文、西里尔文、阿拉伯文、CJK)排在拉丁名之后。测试picker_lists_auto_and_english_first_then_names_alphabetically固化了这一顺序,并特别断言Türkçe的排序键是turkce(crates/ui-egui/src/i18n.rs#L425-L441)。

对话框实现非常薄:选中项直接调用 UI 命令ui.language(crates/ui-egui/src/dialogs.rs#L431-L443)。

let _ = app.run("ui.language", serde_json::json!({"lang": preference.code()}));

也就是说,界面操作、命令行调用、控制通道调用走的是同一条代码路径,不存在“只有菜单能用”的隐藏分支。

Auto 模式:BCP 47 与 POSIX 标签如何收敛到主语言

Auto 的语义是:在系统偏好语言列表中,取第一个受支持的语言。桌面端读取sys_locale::get_locales(),Web 端读取navigator.languages:

  • 桌面:apps/cadcraft/src/main.rs#L317
  • Web:apps/cadcraft-web/src/main.rs#L155-L156

解析函数只有寥寥数行,但覆盖了 BCP 47、POSIX 与.UTF-8/@euro修饰符(crates/ui-egui/src/i18n.rs#L239-L244):

pub fn locale_language(tag: &str) -> Option<&'static str> { let primary = tag.trim().split(['-', '_', '.', '@']).next()?; LANGUAGES.iter() .find(|l| l.code.split('-').next().is_some_and(|p| p.eq_ignore_ascii_case(primary))) .map(|l| l.code) }

关键行为与文档一致,并被测试逐一锁定(crates/ui-egui/src/i18n/tests.rs#L160-L206):

输入标签解析结果原因
uk-UA、uk_UA.UTF-8、uk_UA@euro、" uk-UA "uk取主语言并按大小写不敏感比较
de_AT.UTF-8、DE-atde同上
es-MX、ES-ares同上
pt_BR.UTF-8、pt-PT、PT-brpt-BR任意pt-*都落到唯一的葡萄牙语目录
cs_CZ.UTF-8、cs_CZ@eurocs同上
it_CH.UTF-8、ko_KR.UTF-8、sv_FI、hu_HU.UTF-8、tr-CYit/ko/sv/hu/tr只看主语言
el-GR、EL-cyel同上
zh-CNzh任意zh-*
ua、ukulele、cz、csharp、kr、se、swenska、magyar、elvish无匹配 →en不做模糊/近邻猜测

注意最后一行的细节:se是北萨米语、kr不是韩语、cz不是捷克语,实现刻意不做“看起来像”的匹配,避免把用户带到错误的语言上。Auto.resolve()的语义是“列表里第一个能解析的”,例如["pl-PL", "pt-BR"]→pt-BR,列表为空则回落到en(crates/ui-egui/src/i18n.rs#L156-L176)。

启动期覆盖:CADCRAFT_LOCALE

若只想临时以某语言启动桌面版:

CADCRAFT_LOCALE=cs-CZ cadcraft

该变量在 apps/cadcraft/src/env.rs#L12 定义,并在启动时被包装成只含一个元素的“系统语言列表”:

app.system_languages = vars.locale.map(|s| vec![s]).unwrap_or_else(|| sys_locale::get_locales().collect());

配套规则来自实现与文档:

  • 变量值为空白(""或全空格)时视为未设置,Env::from_vars用filter(|v| !v.trim().is_empty())处理,env.rs的测试直接断言这一点。
  • 该变量只影响本次启动的“系统语言”输入,不写入偏好。
  • 已保存的显式语言选择优先于该覆盖。原因是load_prefs在启动流程中随后执行,会把interfaceLanguage写回app.ui.interface_language(crates/ui-egui/src/lib.rs#L455-L462)。因此要把CADCRAFT_LOCALE真正“生效”,要么此前保持 Auto,要么先在界面里选回 Auto。

自动化切换:控制通道的两种写法

文档给出的 JSON-RPC 风格请求:

{"id":1,"method":"engine.execute","params":{"command":"ui.language","params":{"lang":"cs"}}}

它由run_language_command处理(crates/ui-egui/src/menus.rs#L258-L275),要点有三:

  1. 参数校验严格:lang必须能Preference::parse成功,否则整个请求返回Err,错误信息为language must be auto, en, el, uk, …,当前选择保持不变。
  2. 返回值带shown字段:{"interfaceLanguage": "cs", "shown": "cs"}。shown是“实际显示语言”,可能因缺字体而回落为en。
  3. 回落时给出可读状态提示:韩语缺 Hangul 字体与中文缺 CJK 字体分别有专门文案,直接写进状态栏。

通用偏好通道ui.set也接受同一键名(见 docs/control-protocol.md 与 crates/ui-egui/src/options.rs#L163-L172):

{"id":1,"method":"ui.set","params":{"interfaceLanguage":"cs"}}

options命令会在返回的 UI 偏好中回显interfaceLanguage(crates/ui-egui/src/options.rs#L210-L222),可用于自动化脚本确认最终状态。非法值同样被拒绝,错误格式为interfaceLanguage must be <codes>。

另外ui.resetpalettes(Window → Reset Palettes,crates/ui-egui/src/menus.rs#L404)只重置面板布局,不会清掉语言选择;测试live_switch_translates_the_shell_without_mutating_drawing_or_automation在每种语言下都调用了该命令并断言语言不变。

偏好持久化:cadcraft.prefs

语言选择与其他界面偏好一起存在键cadcraft.prefs下(crates/ui-egui/src/lib.rs#L69):

  • 桌面端由 eframe storage 承载,写入点在 apps/cadcraft/src/main.rs#L133,读取点在 apps/cadcraft/src/main.rs#L322-L323;
  • Web 端使用浏览器 local storage;
  • 内容形如{"theme":..., "animations":..., "interfaceLanguage":"auto", "saveFormat":..., "saveVersion":...},由prefs_json()序列化(crates/ui-egui/src/lib.rs#L427-L447)。

容错策略值得单独说明:load_prefs对 JSON 解析失败、字段缺失、值未知都只是“忽略”,不会崩;Preference::restore在保存值无法解析时回落到Auto(crates/ui-egui/src/i18n.rs#L147-L154)。测试里直接写入了{"interfaceLanguage":"klingon"},断言原偏好保持不变(crates/ui-egui/src/i18n/tests.rs#L238-L247)。没有保存过选择的安装即使用 Auto。

翻译覆盖范围

按文档,目录覆盖命令与菜单标签、工具组与工具提示、Start 页面、文件与模型空间标签、状态栏控件、对象捕捉、绘图提示、属性标签、对话框以及 About 窗口的控件。命令参考窗口(Command Reference,默认 F1)的过滤器同时匹配英文命令 ID、英文标签与翻译标签:

if !f.is_empty() && !c.id.contains(&f) && !c.label.to_ascii_lowercase().contains(&f) && !crate::i18n::t(c.label).to_lowercase().contains(&f) { continue; }

(crates/ui-egui/src/dialogs.rs#L358-L396)

各语言的规模与质量状态(数字取自 docs/localization.md,目录仍在增长——例如cs.tsv当前 1229 行,含 5 行注释):

语言目录行数菜单类目(menu行)复数形式数备注
捷克语cs1,20243四个菜单标题与对象标签区分:Zobrazení / Pohled、Kreslení / Kreslit、Kótování / Kóta、Modifikace / Upravit
韩语ko1,23541原创 AI 辅助韩语 CAD 术语(도면층、치수、모깎기、모따기、간격띄우기、객체 스냅)
巴西葡萄牙语pt-BR1,23102复数规则one|other(0 和 1 都算one),缺口为零
匈牙利语hu1,32641仅Méretezés / Méret一词在菜单标题与对象标签间不同
土耳其语tr1,33041Ölçülendirme(菜单)/Ölçü(对象);无可回落字面量
希腊语el1,330421,324 条普通标签 + 4 条菜单标题 + 2 条整数计数模板
乌克兰语uk—03参考目录(reference catalog)

希腊语还给出了一个值得借鉴的术语策略:专业词汇保留英文括注,便于熟悉 CAD 命令的用户对上号,例如Επίπεδο (Layer)、Πολυγραμμή (Polyline)、Διαγράμμιση (Hatch);而 Open、Save 这类常用动作直接用希腊语。土耳其语则把提示语固定为 “… girin”(Enter)与 “… belirtin”(Specify),并让可点击关键字(Top、Zero、Bottom、_tan to、nX/nXP)保持引擎可接受的拼写。

所有目录都是基于 CADCraft 英文原文的原创翻译,许可为MIT OR Apache-2.0,作者与状态登记在 ATTRIBUTION.md。文档明确提醒:不得复制专有本地化资源。

显示边界:什么会被翻译,什么不会

i18n::with_language用线程局部Cell<&'static str>在一帧渲染内设置当前语言,并用Drop守卫还原(crates/ui-egui/src/i18n.rs#L246-L260):

pub fn with_language<R>(language: &'static str, draw: impl FnOnce() -> R) -> R { struct Restore(&'static str); impl Drop for Restore { fn drop(&mut self) { CURRENT.set(self.0); } } let _restore = Restore(CURRENT.replace(language)); draw() }

应用绘制入口就是i18n::with_language(self.language(), || self.draw(ui))(crates/ui-egui/src/lib.rs#L749)。这条边界带来几个直接后果:

  • 无条件翻译:tl!("Properties")、i18n::t(label)、tf!("Version {version}", version = v)只在渲染期生效;同一进程中的无头调用、并行测试默认英文。
  • 不翻译用户数据:图纸标题、图层/布局/块/样式名、参数名与表达式、图纸中的文字、命令行输入都保持原样。测试里把图纸标题设为"Open",然后在每种语言下断言界面出现翻译后的Open、而标题仍是"Open"。
  • 不翻译自动化面:CLI/MCP/控制通道响应、引擎诊断、命令历史保持英文;ui.inspect与菜单树返回的英文标签与 ID 不变。
  • 未收录文本回落英文,且永远不会让构建失败。

菜单标题单独走一条路径:i18n::menu/i18n::tr_menu先查("menu", source)键,查不到再退回普通翻译(crates/ui-egui/src/i18n.rs#L297-L305)。这就是捷克语能同时拥有 “Kótování”(菜单)与 “Kóta”(对象)的原因。窗口与工具组仍以英文源字符串作为 ID/键,所以切换语言不会丢状态——文档中“Windows retain explicit IDs based on their English source”说的正是这一点,dialogs_render_in_every_language_and_keep_their_source_ids覆盖了 10 个对话框。

命名占位符由format单遍扫描实现,替换进去的值不会再次被当作模板解析,因此用户名里含{n}也不会被误替换(crates/ui-egui/src/i18n.rs#L307-L331)。

字体、字形与脚本回退

翻译不新增字体资产,全部依赖 egui 内置字体 + 已安装字体 + 可选构建输入。can_show决定某语言能否真正显示(crates/ui-egui/src/theme.rs#L655-L668):

pub fn can_show(language: &str) -> bool { match language { "ko" => shows_hangul("ko"), "ar" => arabic_font().is_some() || embedded_script("Arab"), l if l.starts_with("zh") => cjk_font().is_some() || embedded_script("Hans") || embedded_script("Hant"), _ => true, } }

App::language()因此是“偏好 + 字体可用性”的合成结果:拿不到字体就显示英文,而不是画一堆豆腐块(crates/ui-egui/src/lib.rs#L350-L356)。

语言字形来源缺失时行为
乌克兰语内置 egui 字体覆盖Ґґ Єє Іі Її无需额外字体
德语 / 捷克语 / 匈牙利语内置字体覆盖ÄÖÜ ß、ÁČĎÉĚÍŇÓŘŠŤÚŮÝŽ(含„…“)、ÁÉÍÓÖŐÚÜŰ无需额外字体
中文zh已安装 CJK 字体(Noto Sans CJK、Microsoft YaHei、PingFang),或构建期嵌入的 CJK 字体保持zh偏好,界面显示英文,状态栏提示安装 CJK 字体
韩语ko任一语言的 CJK 字体若已覆盖 Hangul 则直接可用;否则仅在选择韩语时加载 Hangul 面保持ko偏好,界面英文,语言选择器显示可读标签Korean
阿拉伯语ar已安装阿拉伯字体,或嵌入的 Arab 字体未覆盖时回落英文

韩语的字体查找顺序(文档明确列出):CRAFT_FONTS_DIR输入的Hang/Kore面 → 系统字体(Windows:Malgun Gothic/Gulim;macOS:Apple SD Gothic Neo/AppleGothic;Linux/FreeBSD:Nanum Gothic 或 Noto CJK)。集合字体(.ttc)逐个 face 检查,文件读取与 face 扫描都有上限(MAX_UI_FONT_BYTES、read_capped);不重分发任何系统字体或新字体资产。

Web 构建不能读取系统字体,也不内嵌韩语字体面(受托管体积限制),因此网页端选韩语时界面保持英文;这条限制在文档中被显式写出,测试korean_without_a_hangul_font_stays_english_and_says_why断言shown == "en"且状态栏文案含No font covers the Korean interface。阿拉伯语在i18n.rs的注释里说明:字形由已安装字体提供,但外壳保持 egui 的从左到右 run 顺序(crates/ui-egui/src/i18n.rs#L227-L232)。

字体的安装是“帧级关注点”:draw中检测到显示语言变化才调用theme::install_fonts,并把fonts_language记账(crates/ui-egui/src/lib.rs#L766-L775)。同一处还会ctx.set_language(...),让 AccessKit 根节点带上界面实际语言。

复数规则与计数模板

计数不用{}拼接,而由@plural行 + 语言级规则决定。count()的实现(crates/ui-egui/src/i18n.rs#L379-L385):

pub fn count(n: u64, one: &str, other: &str) -> String { let language = CURRENT.get(); let source = catalog(language).and_then(|c| c.get(&("@plural", one))).copied(); let plural = LANGUAGES.iter().find(|l| l.code == language).map_or(plural_en as fn(u64) -> usize, |l| l.plural); let form = source.and_then(|s| s.split('|').nth(plural(n))).unwrap_or(if n == 1 { one } else { other }); format(form, &[("n", n.to_string())]) }

调用点例如贡献者统计(crates/ui-egui/src/credits.rs#L249-L250)。目录里的实际行:

@plural {n} commit {n} commit|{n} commity|{n} commitů @plural {n} contributor {n} přispěvatel|{n} přispěvatelé|{n} přispěvatelů
规则函数形式数判定
plural_en2n != 1→ other(英、德、西、意、瑞、希腊、中文复用)
plural_uk3(1, 11) 与 (2–4, 12–14) 特殊处理;1/21 → one,2–4 → few,其余 many(俄语复用)
plural_cs31 → one;2–4 → few;其余(含 0、11、21、22)→ other
plural_pt2n > 1→ other;0 与 1 都是 one(法语复用,符合 CLDRfr)
plural_ko1始终第 0 形式(韩、匈、土复用:数词后名词保持单数)
plural_arabic6zero / one / two / few(3–10, 103–110) / many(11–99) / other

API 接收u64,不接受小数计数。测试plural_rules_pick_the_cldr_form用0, 1, 2, 4, 5, 11, 12, 14, 21, 22, 25, 101, 111, 114, u64::MAX等边界值逐一校验(crates/ui-egui/src/i18n/tests.rs#L267-L280)。

新增一门语言:完整操作清单

TSV 目录位于 crates/ui-egui/src/i18n/,UTF-8、三列制表符分隔:context、英文源、译文。普通标签的 context 为空。

Open Otevřít menu View Zobrazení @plural {n} commit {n} commit|{n} commity|{n} commitů

按文档与实现,步骤是:

  1. 写目录文件crates/ui-egui/src/i18n/<code>.tsv。注释行以#开头;解析器只认三种 context:空、menu、@plural,其它 context 会被测试拒绝。
  2. 注册目录:在LANGUAGES数组加一行(crates/ui-egui/src/i18n.rs#L190-L237),填写code、name(该语言的自称)、source: include_str!(...)、plural函数与plural_forms数量。目录在首次使用时由OnceLock+parse懒加载一次。
  3. 加偏好变体:在Preference枚举中加变体与serde(rename)(crates/ui-egui/src/i18n.rs#L11-L48),同步更新ALL、code()、name()、resolve()三处 match——Rust 编译器会强制覆盖。
  4. 标出 UI 字面量:静态文案用crate::tl!("English label"),已知的动态 UI 标签用i18n::t(label),带命名占位符用crate::tf!("Version {version}", version = value)。只在显示边界翻译,不要对用户名或标识符做查表。
  5. 文案归属:在 ATTRIBUTION.md 增加一行;文档同时要求更新语言列表与复数规则。
  6. 跑门禁:
cargo test -p cadcraft-ui-egui i18n cargo xtask i18n cargo xtask assets cargo xtask ci

其中cargo xtask i18n是 CI 的一个步骤,内部执行被#[ignore]的i18n::tests::translation_gaps报告任务(xtask/src/main.rs#L172-L184):

cargo test --workspace --lib -q -- --ignored --exact i18n::tests::translation_gaps --nocapture

它把每种语言“仍显示英文”的字符串打印出来。缺翻译不算失败,报告失败也只打印一条 note,不阻断流水线。

目录解析的容错与测试约束

parse会跳过空行与#注释,把非法行收集为错误而不是 panic(crates/ui-egui/src/i18n.rs#L262-L283):

match columns.as_slice() { [context, key, value] if !key.is_empty() && !value.is_empty() => { if entries.insert((*context, *key), *value).is_some() { errors.push(format!("line {}: duplicate key {key}", i + 1)); } } _ => errors.push(format!("line {}: expected context, source, translation", i + 1)), }

运行期忽略坏行、测试期报错,这种“双轨”设计保证了应用永不因翻译数据崩溃。测试catalog_is_valid_and_preserves_placeholders_and_ellipses对每种语言校验(crates/ui-egui/src/i18n/tests.rs#L18-L45):

  • 行格式与 context 合法性;
  • @plural行的形式数必须等于该Language::plural_forms;
  • 占位符集合与英文源完全一致({n}不能写成{m});
  • 末尾省略号必须同形(源是...译文也得是...,源是…译文也得是…);
  • 未收录键返回原文;
  • 一份故意写坏的三行目录能产生 3 条错误但仍解析出 1 条有效记录。

渲染级测试

除了数据校验,还有一组真正跑 egui 渲染的测试:

测试验证内容位置
live_switch_translates_the_shell_without_mutating_drawing_or_automation逐语言切换后:Properties/File被翻译、图纸标题不变、命令行 prompt 不变、all_commands不变、菜单树不变、ui.language非法值报错、ui.resetpalettes不重置语言、ui.set同样生效crates/ui-egui/src/i18n/tests.rs#L357-L405
dialogs_render_in_every_language_and_keep_their_source_ids10 个对话框标题在各语言下可渲染,且app.ui.dialog的 ID 不变crates/ui-egui/src/i18n/tests.rs#L407-L425
interface_fonts_cover_every_letter_in_each_catalog把catalog第 3 列所有非 ASCII 字母收集起来,在 Proportional 与 Monospace 两个家族里查字符表,要求零缺失crates/ui-egui/src/i18n/tests.rs#L437-L478
chinese_needs_a_cjk_font/korean_without_a_hangul_font_stays_english_and_says_why缺字体时shown == "en",偏好保留,状态栏给出原因crates/ui-egui/src/i18n/tests.rs#L479-L505
korean_ui_fonts_cover_the_language_picker_in_both_families韩语选择器标签한국어在两个字体家族都可见,否则英文界面显示Koreancrates/ui-egui/src/i18n/tests.rs#L507-L535

字体覆盖测试里有一段值得注意的注释:在 egui 0.36 中,has_glyph对与替换字符同面的字形可能误报缺失,因此测试改用view.characters(family)的真实字符表判断。这是排查“文字显示为方块却查不到原因”时的重要线索。

排障速查

现象可能原因处理
选了韩语仍是英文无任何字体覆盖目录中全部 Hangul;Web 构建固定如此安装 Malgun Gothic / Nanum Gothic / Noto CJK,或用CRAFT_FONTS_DIR提供Hang/Kore面;控制通道返回的shown会说明
选了中文仍是英文无 CJK 字体且构建未嵌入安装 Noto Sans CJK 等;状态栏提示No Chinese font is installed
CADCRAFT_LOCALE不生效存在已保存的显式语言偏好先切回 Auto,或删除/改写cadcraft.prefs中的interfaceLanguage
菜单与对象文字不一致该语言提供了menu行,菜单标题与对象标签本就有别属预期,例如捷克语 Kótování / Kóta
自动化返回英文标签控制通道响应、命令历史、菜单树为英文契约的一部分属预期,按 ID 而非标签做断言
某个新字符串显示英文未收录,或标签没包tl!/t跑cargo xtask i18n看缺口清单
译文里出现{x}原样输出占位符名与英文源不一致修正 TSV,测试会因占位符集合不等而失败

小结

CADCraft 的本地化是一条清晰的单向管线:英文源码字符串 → 显示时目录查找 → 帧级语言作用域 → 字体可用性裁决 → 实际显示语言。偏好只记录“用户想要什么”,shown才记录“这帧实际显示什么”,两者分离使得缺字体时既不丢用户选择、也不至于画出乱码。目录数据的所有约束(占位符、省略号、@plural形式数)都由测试守住,运行期只做容错跳过;而ui.language与ui.set让界面点击、脚本与 MCP 自动化共用同一条路径。若要继续深入,可以从 crates/ui-egui/src/i18n.rs、crates/ui-egui/src/theme.rs 与 crates/ui-egui/src/i18n/tests.rs 三处接着读,配合 docs/localization-parity.md 了解与目标应用的差距清单。

【免费下载链接】cadcraft

CADCraft: computer-aided design and drafting — an open-source, clean-room AutoCAD-style app in pure Rust

项目地址:https://gitcode.com/gh_mirrors/ca/cadcraft
点击查看免费下载
上一篇:解放双手!CopyQ语音控制剪贴板全攻略:用声音管理你的复制内容
下一篇:paraphrase-multilingual-MiniLM-L12-v2模型安全加固:输入过滤与输出验证策略

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

返回列表