Add Korean interface localization and Hangul font support (#857)
* Show Thai file names and text in the interface with an installed Thai font A drawing whose file name is Thai showed boxes in its file tab and in the window title: the interface loaded one Latin system font (plus a CJK font for the Chinese interface), and neither has Thai. install_fonts now also adds an installed Thai font (Thonburi, Leelawadee UI, Noto Sans Thai, Loma...) after the Latin one, in any interface language, since file names and drawing text are Thai whatever the interface is in. No font is bundled; without one installed nothing changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Add Korean interface localization and Hangul font support --------- Co-authored-by: Chanthawat Kiriyadee <enwuft@icloud.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
parent
033ee15e01
commit
aed2ae9615
|
|
@ -65,3 +65,4 @@ Generated-in-code assets are original and have no file to list:
|
|||
| `crates/ui-egui/src/i18n/fr.tsv` | @pechnikov2026-star | Original French translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
| `crates/ui-egui/src/i18n/zh.tsv` | @mayloglog | Original Simplified Chinese translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
| `crates/ui-egui/src/i18n/it.tsv` | Emanuele Bottelli (AI-assisted) | Original Italian translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
| `crates/ui-egui/src/i18n/ko.tsv` | @huk082 (AI-assisted) | Original Korean translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; no font assets added |
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ numbers):
|
|||
| Layouts & plotting | Paper-space layouts, viewports (scale, lock, per-viewport layer freeze), MSPACE/PSPACE through viewports, page setups, PLOT and EXPORTPDF |
|
||||
| Parametric | Geometric and dimensional constraints, AUTOCONSTRAIN, PARAMETERS with expressions, conflict detection; constraints re-solve after every edit |
|
||||
| Grips | Hot grips with Space to cycle Stretch, Move, Rotate, Scale and Mirror |
|
||||
| Interface languages | English, Ukrainian (Українська), Brazilian Portuguese (Português (Brasil)), German (Deutsch), Spanish (Español), Czech (Čeština), Russian (Русский), French (Français), Simplified Chinese (简体中文, with an installed CJK font) and Italian (Italiano), live switching and a remembered Auto/system-language choice; [details](docs/localization.md) |
|
||||
| Interface languages | English, Ukrainian (Українська), Brazilian Portuguese (Português (Brasil)), German (Deutsch), Spanish (Español), Czech (Čeština), Russian (Русский), French (Français), Simplified Chinese (简体中文, with an installed CJK font), Italian (Italiano) and Korean (한국어, with an installed Hangul font), live switching and a remembered Auto/system-language choice; [details](docs/localization.md) |
|
||||
| Automation | MCP server, JSON control channel, `cadcraft-cli` (info, convert, run, commands, mcp) |
|
||||
|
||||
## Quick start
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ mod tests {
|
|||
let sample = match f.scripts.first().copied() {
|
||||
Some("Jpan") => '\u{3042}',
|
||||
Some("Arab") => '\u{0628}',
|
||||
Some("Hang" | "Kore") => '가',
|
||||
_ => 'A',
|
||||
};
|
||||
assert!(face.is_some_and(|x| x.has_glyph(sample)), "{} lacks {sample}", f.family);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -33,10 +33,12 @@ pub enum Preference {
|
|||
Chinese,
|
||||
#[serde(rename = "it")]
|
||||
Italian,
|
||||
#[serde(rename = "ko")]
|
||||
Korean,
|
||||
}
|
||||
|
||||
impl Preference {
|
||||
pub const ALL: [Self; 11] = [
|
||||
pub const ALL: [Self; 12] = [
|
||||
Self::Auto,
|
||||
Self::English,
|
||||
Self::Ukrainian,
|
||||
|
|
@ -48,6 +50,7 @@ impl Preference {
|
|||
Self::French,
|
||||
Self::Chinese,
|
||||
Self::Italian,
|
||||
Self::Korean,
|
||||
];
|
||||
|
||||
/// The codes a preference can be given as, for messages ("auto, en or uk").
|
||||
|
|
@ -72,6 +75,7 @@ impl Preference {
|
|||
Self::French => "fr",
|
||||
Self::Chinese => "zh",
|
||||
Self::Italian => "it",
|
||||
Self::Korean => "ko",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -88,6 +92,13 @@ impl Preference {
|
|||
Self::French => "Français",
|
||||
Self::Chinese => "简体中文",
|
||||
Self::Italian => "Italiano",
|
||||
Self::Korean => {
|
||||
if crate::theme::shows_hangul(CURRENT.with(Cell::get)) {
|
||||
"한국어"
|
||||
} else {
|
||||
"Korean"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -112,6 +123,7 @@ impl Preference {
|
|||
Self::French => "fr",
|
||||
Self::Chinese => "zh",
|
||||
Self::Italian => "it",
|
||||
Self::Korean => "ko",
|
||||
Self::Auto => locales.iter().find_map(|s| locale_language(s)).unwrap_or("en"),
|
||||
}
|
||||
}
|
||||
|
|
@ -128,7 +140,7 @@ pub struct Language {
|
|||
catalog: OnceLock<Catalog<'static>>,
|
||||
}
|
||||
|
||||
pub static LANGUAGES: [Language; 10] = [
|
||||
pub static LANGUAGES: [Language; 11] = [
|
||||
Language { code: "en", name: "English", source: "", plural: plural_en, plural_forms: 2, catalog: OnceLock::new() },
|
||||
Language {
|
||||
code: "uk", name: "Українська", source: include_str!("uk.tsv"), plural: plural_uk, plural_forms: 3, catalog: OnceLock::new()
|
||||
|
|
@ -155,6 +167,8 @@ pub static LANGUAGES: [Language; 10] = [
|
|||
Language { code: "zh", name: "简体中文", source: include_str!("zh.tsv"), plural: plural_en, plural_forms: 2, catalog: OnceLock::new() },
|
||||
// Italian integer plurals (CLDR `it`) follow the English rule: one (1), other.
|
||||
Language { code: "it", name: "Italiano", source: include_str!("it.tsv"), plural: plural_en, plural_forms: 2, catalog: OnceLock::new() },
|
||||
// Korean has one integer plural form and needs a Hangul-capable interface font.
|
||||
Language { code: "ko", name: "한국어", source: include_str!("ko.tsv"), plural: plural_ko, plural_forms: 1, catalog: OnceLock::new() },
|
||||
];
|
||||
|
||||
/// BCP 47 and POSIX locale tags, including `uk-UA`, `uk_UA.UTF-8` and `pt_BR.UTF-8`.
|
||||
|
|
@ -266,6 +280,11 @@ pub fn plural_pt(n: u64) -> usize {
|
|||
usize::from(n > 1)
|
||||
}
|
||||
|
||||
/// Korean uses the same count form for every integer.
|
||||
fn plural_ko(_n: u64) -> usize {
|
||||
0
|
||||
}
|
||||
|
||||
fn plural_en(n: u64) -> usize {
|
||||
usize::from(n != 1)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ fn catalog_is_valid_and_preserves_placeholders_and_ellipses() {
|
|||
/// to English for what they don't cover yet; their gaps are reported, not enforced, so adding a
|
||||
/// command doesn't require a row in every language.
|
||||
const REQUIRED: &[&str] = &["uk"];
|
||||
const REPORTED: &[&str] = &["pt-BR", "de", "es", "cs", "ru", "fr", "zh", "it"];
|
||||
const REPORTED: &[&str] = &["pt-BR", "de", "es", "cs", "ru", "fr", "zh", "it", "ko"];
|
||||
|
||||
fn check(language: &str, what: &str, missing: Vec<String>) {
|
||||
if REQUIRED.contains(&language) {
|
||||
|
|
@ -602,3 +602,180 @@ fn chinese_needs_an_installed_cjk_font() {
|
|||
fn hatch_dialog_extents_label_says_extents_in_ukrainian() {
|
||||
assert_eq!(tr("uk", "Default to boundary extents"), "Типово за габаритами контуру");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn korean_representative_translations_remain_available() {
|
||||
let ko = catalog("ko").unwrap();
|
||||
// Fixed regression examples; new source strings remain reported, not required for Korean.
|
||||
assert!(REPORTED.contains(&"ko"));
|
||||
for (context, source, expected) in [
|
||||
("", "File", "파일"),
|
||||
("menu", "View", "보기"),
|
||||
("menu", "Draw", "그리기"),
|
||||
("menu", "Dimension", "치수"),
|
||||
("menu", "Modify", "수정"),
|
||||
("", "Options...", "옵션..."),
|
||||
("", "User Preferences", "사용자 기본 설정"),
|
||||
("", "Interface Language", "인터페이스 언어"),
|
||||
("", "Properties", "특성"),
|
||||
("", "Degree", "도 기호"),
|
||||
("", "Style: Standard", "스타일: Standard"),
|
||||
("", "WCS ⌄", "WCS 선택"),
|
||||
("@plural", "{n} contributor", "기여자 {n}명"),
|
||||
("@plural", "{n} commit", "커밋 {n}개"),
|
||||
] {
|
||||
assert_eq!(ko.get(&(context, source)).copied(), Some(expected), "{context}: {source}");
|
||||
}
|
||||
assert_eq!(tr("ko", "future untranslated label"), "future untranslated label");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn korean_locale_saved_preference_and_single_count_form() {
|
||||
for tag in ["ko", "ko-KR", "ko_KR.UTF-8", "KO-kr", " ko-KR ", "ko_KR@euro"] {
|
||||
assert_eq!(locale_language(tag), Some("ko"), "{tag}");
|
||||
}
|
||||
for tag in ["kr", "korean", "kotlin"] {
|
||||
assert_eq!(locale_language(tag), None, "{tag}");
|
||||
}
|
||||
assert_eq!(Preference::parse("ko"), Some(Preference::Korean));
|
||||
assert_eq!(Preference::Korean.resolve(&["en-US".into()]), "ko");
|
||||
assert_eq!(Preference::Auto.resolve(&["pl-PL".into(), "ko-KR".into(), "en-US".into()]), "ko");
|
||||
let mut app = CadApp::new(Session::new(), Services::default());
|
||||
app.system_languages = vec!["ko-KR".into()];
|
||||
app.run("ui.language", json!({"lang": "ko"})).unwrap();
|
||||
let mut restored = CadApp::new(Session::new(), Services::default());
|
||||
restored.system_languages = vec!["en-US".into()];
|
||||
restored.load_prefs(&app.prefs_json());
|
||||
assert_eq!(restored.ui.interface_language, Preference::Korean);
|
||||
assert_eq!(restored.language(), app.language());
|
||||
with_language("ko", || {
|
||||
for n in [0, 1, 2, 5, 11, 21, 101, u64::MAX] {
|
||||
assert_eq!(plural_ko(n), 0);
|
||||
assert_eq!(count(n, "{n} contributor", "{n} contributors"), format!("기여자 {n}명"));
|
||||
}
|
||||
assert_eq!(format("Current: {color}", &[("color", "{n} Open".into())]), "현재: {n} Open");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn korean_live_switch_keeps_automation_and_drawing_and_falls_back_without_font() {
|
||||
let mut app = CadApp::new(Session::new(), Services::default());
|
||||
app.ui.in_window_menu = true;
|
||||
app.session.state_mut().unwrap().title = "Open".into();
|
||||
let ctx = egui::Context::default();
|
||||
app.logic(&ctx);
|
||||
assert!(render(&mut app, &ctx).contains("Properties"));
|
||||
app.start("line");
|
||||
let prompt = crate::control::cmdline_state(&app);
|
||||
let commands = crate::control::all_commands(&app);
|
||||
let result = app.run("ui.language", json!({"lang": "ko"})).unwrap();
|
||||
let supported = crate::theme::can_show("ko");
|
||||
assert_eq!(result["shown"], if supported { "ko" } else { "en" });
|
||||
assert_eq!(app.ui.interface_language, Preference::Korean);
|
||||
app.logic(&ctx);
|
||||
assert_eq!(app.fonts_language, Some(app.language()));
|
||||
let text = render(&mut app, &ctx);
|
||||
if supported {
|
||||
for label in ["특성", "파일", "첫 번째 점 지정"] {
|
||||
assert!(text.contains(label), "missing {label}: {text}");
|
||||
}
|
||||
} else {
|
||||
assert!(text.contains("Properties"));
|
||||
assert!(app.status.as_ref().is_some_and(|(s, _)| s.contains("No font covers the Korean interface")));
|
||||
assert_eq!(Preference::Korean.name(), "Korean");
|
||||
}
|
||||
assert_eq!(app.session.state().unwrap().title, "Open");
|
||||
assert_eq!(crate::control::cmdline_state(&app), prompt);
|
||||
assert_eq!(crate::control::all_commands(&app), commands);
|
||||
assert_eq!(t("Properties"), "Properties");
|
||||
assert!(app.run("ui.language", json!({"lang": "bad"})).is_err());
|
||||
app.run("ui.resetpalettes", json!({})).unwrap();
|
||||
assert_eq!(app.ui.interface_language, Preference::Korean);
|
||||
// The general ui.set route uses the same saved preference and font installation.
|
||||
let (request, _) = crate::ControlRequest::new("ui.set", json!({"interfaceLanguage": "zh"}));
|
||||
let _ = crate::control::handle(&mut app, &ctx, &request);
|
||||
assert_eq!(app.ui.interface_language, Preference::Chinese);
|
||||
app.logic(&ctx);
|
||||
assert_eq!(app.fonts_language, Some(if crate::theme::can_show("zh") { "zh" } else { "en" }));
|
||||
render(&mut app, &ctx);
|
||||
let (request, _) = crate::ControlRequest::new("ui.set", json!({"interfaceLanguage": "ko"}));
|
||||
let _ = crate::control::handle(&mut app, &ctx, &request);
|
||||
assert_eq!(app.ui.interface_language, Preference::Korean);
|
||||
app.logic(&ctx);
|
||||
assert_eq!(app.fonts_language, Some(app.language()));
|
||||
let text = render(&mut app, &ctx);
|
||||
assert!(text.contains(if supported { "특성" } else { "Properties" }));
|
||||
let (request, _) = crate::ControlRequest::new("ui.set", json!({"interfaceLanguage": "en"}));
|
||||
let _ = crate::control::handle(&mut app, &ctx, &request);
|
||||
app.logic(&ctx);
|
||||
assert_eq!(app.fonts_language, Some("en"));
|
||||
assert_eq!(app.ui.interface_language, Preference::English);
|
||||
assert!(render(&mut app, &ctx).contains("Properties"));
|
||||
assert_eq!(crate::menus::tree(&app).first().unwrap().0, "File");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn korean_dialogs_keep_source_ids() {
|
||||
if !crate::theme::can_show("ko") {
|
||||
return; // The fallback path is covered above; do not claim glyph rendering here.
|
||||
}
|
||||
for (dialog, source) in [
|
||||
("dsettings", "Drafting Settings"),
|
||||
("about", "About CADCraft"),
|
||||
("commands", "Command Reference"),
|
||||
("blocks", "Blocks"),
|
||||
("layers", "Layer Properties Manager"),
|
||||
("qselect", "Quick Select"),
|
||||
("parameters", "Parameters Manager"),
|
||||
("language", "Interface Language"),
|
||||
("options:preferences", "User Preferences"),
|
||||
] {
|
||||
let mut app = CadApp::new(Session::new(), Services::default());
|
||||
app.ui.interface_language = Preference::Korean;
|
||||
app.ui.dialog = Some(dialog.into());
|
||||
let ctx = egui::Context::default();
|
||||
app.logic(&ctx);
|
||||
let text = render(&mut app, &ctx);
|
||||
assert!(text.contains(tr("ko", source)), "{dialog}: {text}");
|
||||
if dialog == "language" {
|
||||
assert!(text.contains("한국어"), "{dialog}: {text}");
|
||||
}
|
||||
assert_eq!(app.ui.dialog.as_deref(), Some(dialog));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn korean_ui_fonts_cover_catalog_and_language_picker_in_both_families() {
|
||||
if !crate::theme::can_show("ko") {
|
||||
return;
|
||||
}
|
||||
let letters: BTreeSet<_> = include_str!("ko.tsv")
|
||||
.lines()
|
||||
.filter_map(|line| line.split('\t').nth(2))
|
||||
.flat_map(str::chars)
|
||||
.chain("한국어".chars())
|
||||
.filter(|c| !c.is_ascii())
|
||||
.collect();
|
||||
// The picker shows 한국어 in an English interface only when the CJK fonts loaded in every
|
||||
// language cover Hangul; the Korean interface itself always does, with a fallback font.
|
||||
let ctx = egui::Context::default();
|
||||
let english = crate::theme::cjk_covers_hangul();
|
||||
assert_eq!(with_language("en", || Preference::Korean.name()), if english { "한국어" } else { "Korean" });
|
||||
assert_eq!(with_language("ko", || Preference::Korean.name()), "한국어");
|
||||
crate::theme::install_fonts(&ctx, if english { "en" } else { "ko" });
|
||||
let mut output = ctx.run_ui(Default::default(), |_| {});
|
||||
output.textures_delta.clear();
|
||||
let missing = ctx.fonts_mut(|view| {
|
||||
let mut missing = Vec::new();
|
||||
for family in [egui::FontFamily::Proportional, egui::FontFamily::Monospace] {
|
||||
let mut font = view.fonts.font(&family);
|
||||
for c in &letters {
|
||||
if !font.characters().contains_key(c) {
|
||||
missing.push(format!("{family:?}: {c} (U+{:04X})", u32::from(*c)));
|
||||
}
|
||||
}
|
||||
}
|
||||
missing
|
||||
});
|
||||
assert!(missing.is_empty(), "missing Korean UI glyphs: {}", missing.join(", "));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -216,8 +216,8 @@ pub struct CadApp {
|
|||
/// Caps the frame rate when presenting without vsync (see [`gpu::FrameCap`]).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub frame_cap: Option<gpu::FrameCap>,
|
||||
/// Interface fonts installed, and whether with a CJK font (`None` until the first frame).
|
||||
fonts_cjk: Option<bool>,
|
||||
/// Language whose interface fonts are installed (`None` until the first frame).
|
||||
fonts_language: Option<&'static str>,
|
||||
/// The theme last installed into egui (`None` until the first frame).
|
||||
shown_theme: Option<egui::Theme>,
|
||||
/// Whether the last applied theme was handed to egui as the System preference.
|
||||
|
|
@ -267,7 +267,7 @@ impl CadApp {
|
|||
synthetic: Vec::new(),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
frame_cap: None,
|
||||
fonts_cjk: None,
|
||||
fonts_language: None,
|
||||
shown_theme: None,
|
||||
shown_follows_system: false,
|
||||
shown_animations: None,
|
||||
|
|
@ -285,7 +285,7 @@ impl CadApp {
|
|||
}
|
||||
|
||||
/// The interface language shown: the chosen one, or English when this computer has no font
|
||||
/// for it (Chinese without an installed CJK font would be boxes).
|
||||
/// for it (Chinese or Korean without a suitable font would be boxes).
|
||||
pub fn language(&self) -> &'static str {
|
||||
let l = self.ui.interface_language.resolve(&self.system_languages);
|
||||
if theme::can_show(l) { l } else { "en" }
|
||||
|
|
@ -535,10 +535,10 @@ impl CadApp {
|
|||
if ctx.input(|i| i.viewport().close_requested()) && self.request_quit() {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
|
||||
}
|
||||
let cjk = self.language().starts_with("zh");
|
||||
if self.fonts_cjk != Some(cjk) {
|
||||
theme::install_fonts(ctx, cjk);
|
||||
self.fonts_cjk = Some(cjk);
|
||||
let language = self.language();
|
||||
if self.fonts_language != Some(language) {
|
||||
theme::install_fonts(ctx, language);
|
||||
self.fonts_language = Some(language);
|
||||
}
|
||||
// Resolved every frame, so a System choice follows the OS appearance live (winit and the
|
||||
// browser report changes as input); the saved choice itself never changes here.
|
||||
|
|
|
|||
|
|
@ -137,9 +137,11 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option<Re
|
|||
app.ui.interface_language = preference;
|
||||
let shown = app.language();
|
||||
if shown != preference.resolve(&app.system_languages) {
|
||||
app.set_status(
|
||||
"No Chinese font is installed, so the interface stays in English. Install one (Noto Sans CJK, for example) to use Chinese.",
|
||||
);
|
||||
app.set_status(if preference.resolve(&app.system_languages) == "ko" {
|
||||
"No font covers the Korean interface, so it stays in English. Install a Hangul font (Malgun Gothic or Nanum Gothic), or build with a Hang/Kore craft-fonts face."
|
||||
} else {
|
||||
"No Chinese font is installed, so the interface stays in English. Install one (Noto Sans CJK, for example) to use Chinese."
|
||||
});
|
||||
}
|
||||
Ok(json!({"interfaceLanguage": preference.code(), "shown": shown}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
//! |---|---|
|
||||
//! | `theme` | `"system"`, `"light"` or `"dark"` |
|
||||
//! | `animations` | `"system"`, `"on"` or `"off"`: dialog and menu fades and smoothed scrolling |
|
||||
//! | `interfaceLanguage` | `"auto"`, `"en"`, `"uk"`, `"pt-BR"`, `"de"`, `"es"`, `"cs"`, `"ru"`, `"fr"`, `"zh"` or `"it"` |
|
||||
//! | `interfaceLanguage` | `"auto"`, `"en"`, `"uk"`, `"pt-BR"`, `"de"`, `"es"`, `"cs"`, `"ru"`, `"fr"`, `"zh"`, `"it"` or `"ko"` |
|
||||
//! | `saveFormat` | `"dxf"` or `"dwg"`: the format Save As suggests for new drawings |
|
||||
//! | `saveVersion` | `"2000"` … `"2018"`: the version drawings with no file yet are saved in |
|
||||
//! | `SAVETIME` | minutes between automatic saves, 0–600 (0: off) |
|
||||
|
|
|
|||
|
|
@ -305,14 +305,16 @@ pub fn mono() -> FontId {
|
|||
/// Load a system UI font at run time when one is installed (not bundled), else keep egui's;
|
||||
/// then CJK fonts after the Latin ones, so Latin text keeps its look, in every interface
|
||||
/// language: file, layout and layer names can be Japanese or Chinese whatever the interface
|
||||
/// shows (#513). With `chinese` (a Chinese interface) the Chinese font comes first, else a
|
||||
/// shows (#513). In a Chinese interface (`language` "zh…") the Chinese font comes first, else a
|
||||
/// Japanese one, so names take their own glyph forms. A font embedded from craft-fonts comes
|
||||
/// last, for systems without any (and the web build, which has no system fonts).
|
||||
pub fn install_fonts(ctx: &egui::Context, chinese: bool) {
|
||||
/// last, for systems without any (and the web build, which has no system fonts). A Korean
|
||||
/// interface adds a Hangul font only when the CJK font found doesn't cover Hangul
|
||||
/// ([`korean_font`]).
|
||||
pub fn install_fonts(ctx: &egui::Context, language: &str) {
|
||||
let mut fonts = egui::FontDefinitions::default();
|
||||
let mut added: Vec<(String, std::sync::Arc<egui::FontData>)> = Vec::new();
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let _ = chinese;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let chinese = language.starts_with("zh");
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let candidates: &[&str] = if cfg!(target_os = "macos") {
|
||||
|
|
@ -357,6 +359,15 @@ pub fn install_fonts(ctx: &egui::Context, chinese: bool) {
|
|||
// A sans-serif face reads best in the interface.
|
||||
embedded.sort_by_key(|f| !f.family.contains("Gothic") && !f.family.contains("Sans"));
|
||||
added.extend(embedded.first().map(|f| (format!("craft-{}", f.stem()), std::sync::Arc::new(egui::FontData::from_static(f.bytes)))));
|
||||
// Korean: Noto Sans CJK and most CJK fonts above already cover Hangul; else a Hangul font.
|
||||
if language == "ko"
|
||||
&& !cjk_covers_hangul()
|
||||
&& let Some(face) = korean_font()
|
||||
{
|
||||
let mut data = egui::FontData::from_owned(face.bytes.as_ref().clone());
|
||||
data.index = face.index;
|
||||
added.push(("korean-ui".into(), std::sync::Arc::new(data)));
|
||||
}
|
||||
for (name, data) in added {
|
||||
fonts.font_data.insert(name.clone(), data);
|
||||
for family in [FontFamily::Proportional, FontFamily::Monospace] {
|
||||
|
|
@ -368,17 +379,21 @@ pub fn install_fonts(ctx: &egui::Context, chinese: bool) {
|
|||
ctx.set_fonts(fonts);
|
||||
}
|
||||
|
||||
/// Largest interface font file read (CJK collections are 15–60 MB).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
/// Largest interface font file read or embedded face copied (CJK collections are 15–60 MB).
|
||||
const MAX_UI_FONT_BYTES: u64 = 96 * 1024 * 1024;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn read_capped(path: &std::path::Path, max: u64) -> Option<Vec<u8>> {
|
||||
let len = std::fs::metadata(path).ok()?.len();
|
||||
use std::io::Read as _;
|
||||
let file = std::fs::File::open(path).ok()?;
|
||||
let len = file.metadata().ok()?.len();
|
||||
if len == 0 || len > max {
|
||||
return None;
|
||||
}
|
||||
std::fs::read(path).ok()
|
||||
// Enforce the cap during the read too, even if the file grows after metadata was read.
|
||||
let mut bytes = Vec::new();
|
||||
file.take(max.saturating_add(1)).read_to_end(&mut bytes).ok()?;
|
||||
(!bytes.is_empty() && bytes.len() as u64 <= max).then_some(bytes)
|
||||
}
|
||||
|
||||
/// An installed font with Chinese characters, for the Chinese interface; fonts are never bundled
|
||||
|
|
@ -442,9 +457,122 @@ fn japanese_font() -> Option<&'static std::path::Path> {
|
|||
None
|
||||
}
|
||||
|
||||
/// Whether the interface can show `language`: Chinese needs an installed CJK font.
|
||||
/// The Hangul letters the Korean interface shows: its catalog and its own name.
|
||||
fn hangul_letters() -> &'static std::collections::BTreeSet<char> {
|
||||
static LETTERS: std::sync::OnceLock<std::collections::BTreeSet<char>> = std::sync::OnceLock::new();
|
||||
LETTERS.get_or_init(|| {
|
||||
include_str!("i18n/ko.tsv")
|
||||
.lines()
|
||||
.filter_map(|line| line.split('\t').nth(2))
|
||||
.flat_map(str::chars)
|
||||
.chain("한국어".chars())
|
||||
.filter(|c| matches!(c, '\u{1100}'..='\u{11ff}' | '\u{3130}'..='\u{318f}' | '\u{a960}'..='\u{a97f}' | '\u{ac00}'..='\u{d7ff}'))
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether `face` has every Hangul letter the Korean interface shows (rejects malformed fonts
|
||||
/// and Chinese- or Japanese-only faces).
|
||||
fn covers_hangul(face: &cadcraft_fonts::ttf::Face) -> bool {
|
||||
hangul_letters().iter().all(|c| face.has_glyph(*c))
|
||||
}
|
||||
|
||||
/// The first face of a font file (the one egui uses) when it covers the Korean interface.
|
||||
fn korean_face(bytes: std::sync::Arc<Vec<u8>>) -> Option<cadcraft_fonts::ttf::Face> {
|
||||
cadcraft_fonts::ttf::faces(&bytes).into_iter().find(covers_hangul)
|
||||
}
|
||||
|
||||
/// Whether the CJK fonts [`install_fonts`] loads in every language already show Hangul (Noto Sans
|
||||
/// CJK, Microsoft YaHei, PingFang do). Only asked about Korean, so other interfaces never read the
|
||||
/// font twice. Checked once.
|
||||
pub fn cjk_covers_hangul() -> bool {
|
||||
static COVERS: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*COVERS.get_or_init(|| {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
// egui uses a collection's first face, and each letter from the first font with it.
|
||||
let first_covers = |p: &std::path::Path| {
|
||||
read_capped(p, MAX_UI_FONT_BYTES)
|
||||
.and_then(|bytes| cadcraft_fonts::ttf::faces(&std::sync::Arc::new(bytes)).into_iter().next())
|
||||
.is_some_and(|face| covers_hangul(&face))
|
||||
};
|
||||
if cjk_font().is_some_and(first_covers) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
cadcraft_fonts::CRAFT_FONTS
|
||||
.iter()
|
||||
.filter(|f| f.style == "Regular" && f.scripts.iter().any(|s| matches!(*s, "Jpan" | "Hans" | "Hant")))
|
||||
.any(|f| cadcraft_fonts::ttf::faces(&std::sync::Arc::new(f.bytes.to_vec())).first().is_some_and(covers_hangul))
|
||||
})
|
||||
}
|
||||
|
||||
/// A Hangul font for the Korean interface when the CJK fonts don't cover it: a Korean face from
|
||||
/// the optional craft-fonts input, else one installed where the OS or common packages put it.
|
||||
/// Nothing is bundled or downloaded; without one the interface stays English. Only looked for
|
||||
/// when Korean is asked about. Checked once; collections are scanned with the fonts crate's
|
||||
/// face-count cap and reads are capped.
|
||||
pub fn korean_font() -> Option<&'static cadcraft_fonts::ttf::Face> {
|
||||
static FOUND: std::sync::OnceLock<Option<cadcraft_fonts::ttf::Face>> = std::sync::OnceLock::new();
|
||||
FOUND
|
||||
.get_or_init(|| {
|
||||
for font in cadcraft_fonts::CRAFT_FONTS.iter().filter(|f| f.scripts.iter().any(|s| matches!(*s, "Hang" | "Kore"))) {
|
||||
if font.bytes.len() as u64 > MAX_UI_FONT_BYTES {
|
||||
continue;
|
||||
}
|
||||
if let Some(face) = korean_face(std::sync::Arc::new(font.bytes.to_vec())) {
|
||||
return Some(face);
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let candidates: &[&str] = if cfg!(target_os = "macos") {
|
||||
&[
|
||||
"/System/Library/Fonts/AppleSDGothicNeo.ttc",
|
||||
"/System/Library/Fonts/Supplemental/AppleGothic.ttf",
|
||||
"/Library/Fonts/NanumGothic.ttf",
|
||||
]
|
||||
} else if cfg!(windows) {
|
||||
&["C:\\Windows\\Fonts\\malgun.ttf", "C:\\Windows\\Fonts\\gulim.ttc"]
|
||||
} else {
|
||||
&[
|
||||
"/usr/share/fonts/truetype/nanum/NanumGothic.ttf",
|
||||
"/usr/share/fonts/nanum/NanumGothic.ttf",
|
||||
"/usr/share/fonts/opentype/noto/NotoSansCJKkr-Regular.otf",
|
||||
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
||||
"/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc",
|
||||
"/usr/share/fonts/google-noto-cjk/NotoSansCJK-Regular.ttc",
|
||||
"/usr/local/share/fonts/nanum/NanumGothic.ttf",
|
||||
"/usr/local/share/fonts/noto/NotoSansCJK-Regular.ttc",
|
||||
]
|
||||
};
|
||||
for path in candidates {
|
||||
if let Some(face) =
|
||||
read_capped(std::path::Path::new(path), MAX_UI_FONT_BYTES).and_then(|bytes| korean_face(std::sync::Arc::new(bytes)))
|
||||
{
|
||||
return Some(face);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
})
|
||||
.as_ref()
|
||||
}
|
||||
|
||||
/// Whether the interface in `language` shows Hangul (the Korean name in the language picker):
|
||||
/// in every language when the CJK fonts cover it, in a Korean interface with [`korean_font`].
|
||||
pub fn shows_hangul(language: &str) -> bool {
|
||||
cjk_covers_hangul() || (language == "ko" && korean_font().is_some())
|
||||
}
|
||||
|
||||
/// Whether the interface can show `language` without missing-glyph boxes: Chinese needs an
|
||||
/// installed CJK font, Korean a font with Hangul.
|
||||
pub fn can_show(language: &str) -> bool {
|
||||
!language.starts_with("zh") || cjk_font().is_some()
|
||||
match language {
|
||||
"ko" => shows_hangul("ko"),
|
||||
l if l.starts_with("zh") => cjk_font().is_some(),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// egui's visuals for `theme`, from its tokens.
|
||||
|
|
@ -498,3 +626,13 @@ pub fn apply(ctx: &egui::Context, theme: Theme, follow_system: bool) {
|
|||
s.text_styles.insert(egui::TextStyle::Monospace, mono());
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn korean_font_rejects_invalid_or_glyphless_files() {
|
||||
for bytes in [vec![], vec![0xff; 32], vec![0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]] {
|
||||
assert!(super::korean_face(std::sync::Arc::new(bytes)).is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -152,7 +152,10 @@ Select a language with `engine.execute` and the UI command `ui.language`:
|
|||
{"id":1,"method":"engine.execute","params":{"command":"ui.language","params":{"lang":"uk"}}}
|
||||
```
|
||||
|
||||
Supported values are `auto`, `en`, `uk`, `pt-BR`, `de`, `es`, `cs`, `ru`, `fr`, `zh` and `it` (Chinese needs an installed CJK font; without one the result's `shown` is `en`). `ui.set` also accepts
|
||||
Supported values are `auto`, `en`, `uk`, `pt-BR`, `de`, `es`, `cs`, `ru`, `fr`, `zh`, `it` and `ko`.
|
||||
Chinese needs an installed CJK font; Korean needs a font covering the Korean interface from the
|
||||
optional craft-fonts input or the operating system. Without a suitable font, the saved choice is
|
||||
retained and the result's `shown` is `en`. `ui.set` also accepts
|
||||
`{"interfaceLanguage":"uk"}`; `ui.inspect` reports the saved choice in its UI state.
|
||||
Invalid choices leave the current language unchanged. Menus, command labels and prompt text
|
||||
returned by the control channel retain their English source text. See [localization.md](localization.md).
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Localization parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Czech catalog added: reference/literal coverage, Czech plurals and menu headings) · **Target:** Autodesk AutoCAD 2027 for Mac (26.0)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (complete current Korean catalog; locale/preferences/live-switch support and Hangul font coverage) · **Target:** Autodesk AutoCAD 2027 for Mac (26.0)
|
||||
|
||||
How far CADCraft's interface and drawing text are from AutoCAD's languages. Summarised in
|
||||
[ROADMAP.md](../ROADMAP.md#languages); work items in [gaps.md](gaps.md#localization).
|
||||
|
|
@ -62,16 +62,16 @@ plus native review by a human; extending the catalog to the remaining English-on
|
|||
| Indonesian | id | 0 (0%) | 0% | Latin: yes | no | none | 3–5 h |
|
||||
| Japanese | ja | 0 (0%) | 0% | CJK glyphs render; IME pending; no vertical text | no | none | 4–6 h |
|
||||
| German | de | 989 catalog rows (≈ 100% of the catalog; ≈ 80% of all UI strings, estimated: engine diagnostics and uncatalogued text stay English) | dialogs and tooltips yes; command reference searchable in German | Latin: yes (bundled egui fonts cover ä ö ü Ä Ö Ü ß; long strings will need layout checks) | no native review | partial | 1–2 h |
|
||||
| Korean | ko | 0 (0%) | 0% | Hangul renders; macOS Korean IME fixes pending (#118, #119) | no | none | 4–6 h |
|
||||
| Korean | ko | 1,235 rows (1,229 ordinary, 4 menu, 2 count); full existing catalog union and marked UI coverage | dialogs, tooltips, Options and translated command-reference labels | UI requires catalog-covering installed or optional craft-fonts Hang/Kore face; English fallback without one; IME fixes still pending | original AI-assisted translation; independent native proofreading pending | complete current catalog; English-only interface gaps remain | native review + platform layout/IME validation |
|
||||
| Vietnamese | vi | 0 (0%) | 0% | Latin with stacked diacritics: renders per code point, untested | no | none | 3–5 h |
|
||||
| Ukrainian | uk | 989 catalog rows (≈ 100% of the catalog; ≈ 80% of all UI strings, estimated: engine diagnostics and uncatalogued text stay English) | dialogs and tooltips yes; command reference searchable in Ukrainian | Cyrillic: yes (bundled egui fonts) | translated by a contributor; no second review | partial | 2–4 h |
|
||||
| Czech | cs | 1,202 catalog rows; reference catalog and marked UI literals covered | dialogs and tooltips yes; contextual menu headings; command reference searchable in Czech | Latin: yes (bundled egui proportional and monospace fonts; Czech letters and quotes tested) | AI-assisted by @Losminek; independent review pass; not yet proofread by a native speaker | complete current catalog; English-only interface gaps remain | native review + shared English-only UI work |
|
||||
| Italian | it | 1,227 catalog rows; reference catalog and marked UI literals covered | dialogs and tooltips yes; contextual menu headings (Visualizza / Vista, Edita / Modifica); command reference searchable in Italian | Latin: yes (bundled egui fonts; accented letters tested) | AI-assisted by Emanuele Bottelli (Claude), written from the English strings with Italian desktop-CAD vocabulary; no native proofreading pass yet | complete current catalog; English-only interface gaps remain | native review + shared English-only UI work |
|
||||
|
||||
Other languages shipped: Ukrainian, Brazilian Portuguese, German, Czech, Russian, French and Italian. Czech and
|
||||
Other languages shipped: Ukrainian, Brazilian Portuguese, German, Czech, Russian, French, Italian and Korean. Czech and
|
||||
Brazilian Portuguese cover the reference catalog and every marked UI literal; engine diagnostics and uncatalogued text
|
||||
remain English. AutoCAD for Mac ships 7
|
||||
non-English languages (zh-Hans, es, fr, ja, de, ko, it); of those, CADCraft now ships German,
|
||||
non-English languages (zh-Hans, es, fr, ja, de, ko, it); of those, CADCraft now ships German and Korean,
|
||||
and additionally Ukrainian, which AutoCAD for Mac doesn't ship.
|
||||
|
||||
**Total to put all twelve at `full`:** about 55–95 Opus 5.5 hours (catalog extension 4–6 h,
|
||||
|
|
@ -82,6 +82,7 @@ each language, which needs humans. Translations parallelise fully once the catal
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-13 | minor | Korean added: full 1,235-row existing catalog union, single count form, locale/persistence/live-switch tests, and validated Hangul UI fonts; native review and platform verification pending |
|
||||
| 2026-10-12 | minor | Italian added: 1,227 rows, one/other plurals, two `menu` headings, locale/preference/plural/font tests; AI-assisted, native proofreading pending |
|
||||
| 2026-10-12 | minor | Brazilian Portuguese catalog completed by @RicSchonfelder: 238 rows added (1,231 total), now covering the reference catalog and every marked UI literal, gaps 0; bundled-font glyph test for the catalog's accented letters |
|
||||
| 2026-10-11 | minor | Czech added: 1,202 rows, one/few/other plurals, locale/preferences/menu/font/render tests; independent review pass; native proofreading pending |
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Interface languages
|
||||
|
||||
Choose **Edit → Interface Language** and select **Auto (system language)**, **English**,
|
||||
**Українська**, **Português (Brasil)**, **Deutsch**, **Español**, **Čeština** (Czech), **Русский**, **Français**, **简体中文**, or **Italiano**.
|
||||
**Українська**, **Português (Brasil)**, **Deutsch**, **Español**, **Čeština** (Czech), **Русский**, **Français**, **简体中文**, **Italiano**, or **한국어** (Korean).
|
||||
The interface updates without restarting, including the native macOS menus.
|
||||
The choice is saved with the other interface preferences (`cadcraft.prefs`, beside the theme):
|
||||
eframe storage on the desktop, browser local storage on the web. Installations without a saved
|
||||
|
|
@ -10,7 +10,7 @@ choice use Auto.
|
|||
Auto uses the first supported language in the operating system's preferred locales, or the
|
||||
browser's `navigator.languages` on the web. BCP 47 and POSIX tags select their primary language:
|
||||
`uk-UA` → `uk`, `de_AT.UTF-8` → `de`, `es-MX` → `es`, `pt_BR.UTF-8` → `pt-BR`,
|
||||
`cs-CZ` and `cs_CZ.UTF-8` → `cs`, `it-IT` and `it_CH.UTF-8` → `it`. Unsupported languages fall back to English.
|
||||
`cs-CZ` and `cs_CZ.UTF-8` → `cs`, `it-IT` and `it_CH.UTF-8` → `it`, `ko-KR` and `ko_KR.UTF-8` → `ko`. Unsupported languages fall back to English.
|
||||
Set `CADCRAFT_LOCALE=cs-CZ` to override the desktop's locale for a launch.
|
||||
An explicit saved language choice takes precedence over that override.
|
||||
|
||||
|
|
@ -21,7 +21,7 @@ Automation can select the language through the existing control channel:
|
|||
```
|
||||
|
||||
The `ui.set` method also accepts `{"interfaceLanguage":"cs"}`. Supported values are `auto`,
|
||||
`en`, `uk`, `pt-BR`, `de`, `es`, `cs`, `ru`, `fr`, `zh`, and `it`; invalid values are rejected without changing the current
|
||||
`en`, `uk`, `pt-BR`, `de`, `es`, `cs`, `ru`, `fr`, `zh`, `it`, and `ko`; invalid values are rejected without changing the current
|
||||
selection. Resetting palettes keeps the language choice.
|
||||
|
||||
## Translation coverage
|
||||
|
|
@ -38,6 +38,13 @@ labels: **Zobrazení / Pohled**, **Kreslení / Kreslit**, **Kótování / Kóta*
|
|||
The translation is AI-assisted work by @Losminek, reviewed against Czech desktop and CAD UI
|
||||
conventions and an independent review pass; it has not yet been proofread by a native speaker.
|
||||
|
||||
Korean has **1,235 rows**: 1,229 ordinary labels, four contextual menu headings, and
|
||||
two single-form count templates. It covers the union of the existing catalogs, including
|
||||
the Ukrainian reference, the current Czech catalog, Options, prompts, dialogs, and tooltips.
|
||||
The translations are original, AI-assisted Korean CAD terminology (for example, 도면층,
|
||||
치수, 모깎기, 모따기, 간격띄우기 and 객체 스냅). Independent native proofreading and
|
||||
platform-specific layout/IME validation remain follow-up work.
|
||||
|
||||
Brazilian Portuguese has 1,231 catalog rows and also covers the reference catalog and every marked UI
|
||||
literal: commands, menus, tool groups and tooltips, object snaps, drafting prompts, dialogs and
|
||||
Preferences; its gaps are now zero. Its plural rule is `one|other` (0 and 1 are `one`), as CLDR defines
|
||||
|
|
@ -56,6 +63,20 @@ The bundled egui fonts cover Ukrainian (`Ґґ Єє Іі Її`), German (`ÄÖÜ
|
|||
(`ÁČĎÉĚÍŇÓŘŠŤÚŮÝŽ áčďéěíňóřšťúůýž`, including typographic quotes `„…“`).
|
||||
The translations add no font assets and work on the web without system fonts.
|
||||
|
||||
Korean needs a font that covers **every Hangul character in its catalog** and the language
|
||||
picker label. The CJK font loaded in every language (Noto Sans CJK, Microsoft YaHei, PingFang)
|
||||
usually covers Hangul already, and then nothing more is loaded. Otherwise, only when Korean is
|
||||
chosen, desktop builds look for a `Hang`/`Kore` face from the optional `CRAFT_FONTS_DIR` input,
|
||||
then installed fonts: Malgun Gothic/Gulim on Windows, Apple SD Gothic Neo/AppleGothic on macOS,
|
||||
and Nanum Gothic or Noto CJK on Linux/FreeBSD. Collection faces are checked individually, and
|
||||
file reads/face scans are bounded. No system fonts or new font assets are redistributed.
|
||||
|
||||
Web builds cannot read installed fonts and embed no Korean face (to stay within hosting size
|
||||
limits), so the web interface stays English when Korean is chosen. Without a font covering the
|
||||
catalog, the saved `ko` preference is kept but the interface falls back to English; the language
|
||||
command reports that a font is needed, and the picker uses the readable label **Korean** wherever
|
||||
Hangul can't be shown. This change does not fix platform IME issues or localize engine output.
|
||||
|
||||
## Adding translations
|
||||
|
||||
The implementation follows the display-time catalog pattern used by
|
||||
|
|
@ -70,6 +91,7 @@ The implementation follows the display-time catalog pattern used by
|
|||
- `@plural` entries hold the language's forms for integer counts: Ukrainian `one|few|many`,
|
||||
German and Spanish `one|other`, Brazilian Portuguese `one|other` (0 and 1 are `one`),
|
||||
Czech `one|few|other` (1; 2–4; everything else, including 0, 11, 21 and 22).
|
||||
Korean has one form (`other`) for all integer counts.
|
||||
The API takes `u64`, so fractional counts are not accepted.
|
||||
- Register a new catalog in `i18n::LANGUAGES`, add its preference variant and code/name,
|
||||
and set its integer plural rule and form count on the `Language` row.
|
||||
|
|
|
|||
Loading…
Reference in New Issue