From 12bfdb07989bdfd11b637336ccd33418e3a7639d Mon Sep 17 00:00:00 2001 From: neko233-com <242357072+neko233-com@users.noreply.github.com> Date: Sat, 10 Oct 2026 10:20:59 +0800 Subject: [PATCH] i18n: add Chinese, Korean and Russian interface languages --- Cargo.lock | 1 + apps/gridcraft-web/src/web.rs | 2 +- apps/gridcraft/src/main.rs | 2 +- crates/ui-egui/Cargo.toml | 7 +- crates/ui-egui/src/i18n.rs | 94 +++++++--- crates/ui-egui/src/i18n/ko.rs | 268 ++++++++++++++++++++++++++++ crates/ui-egui/src/i18n/ru.rs | 268 ++++++++++++++++++++++++++++ crates/ui-egui/src/i18n/zh.rs | 268 ++++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 23 ++- crates/ui-egui/src/ribbon.rs | 6 +- crates/ui-egui/src/theme.rs | 129 +++++++++---- crates/ui-egui/tests/interaction.rs | 19 +- 12 files changed, 1005 insertions(+), 82 deletions(-) create mode 100644 crates/ui-egui/src/i18n/ko.rs create mode 100644 crates/ui-egui/src/i18n/ru.rs create mode 100644 crates/ui-egui/src/i18n/zh.rs diff --git a/Cargo.lock b/Cargo.lock index fd6140b..fea43b6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1530,6 +1530,7 @@ dependencies = [ "serde_json", "sys-locale", "toml", + "web-sys", ] [[package]] diff --git a/apps/gridcraft-web/src/web.rs b/apps/gridcraft-web/src/web.rs index c06d890..d046ce4 100644 --- a/apps/gridcraft-web/src/web.rs +++ b/apps/gridcraft-web/src/web.rs @@ -38,7 +38,7 @@ pub fn start() { let _ = session.execute("file.new", serde_json::json!({"sample": "sales"})); } let app = SheetApp::new(session, services(inbox.clone(), cc.egui_ctx.clone())); - SheetApp::setup_context(&cc.egui_ctx, false); + SheetApp::setup_context_for_language(&cc.egui_ctx, false, app.ui.language); Ok(Box::new(WebShell { app, inbox })) }), ) diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs index 6acaf51..a1f2041 100644 --- a/apps/gridcraft/src/main.rs +++ b/apps/gridcraft/src/main.rs @@ -191,7 +191,7 @@ fn main() -> eframe::Result<()> { "GridCraft", options, Box::new(move |cc| { - SheetApp::setup_context(&cc.egui_ctx, app.ui.dark); + SheetApp::setup_context_for_language(&cc.egui_ctx, app.ui.dark, app.ui.language); if let Some(port) = control_port { match control_server::start(port, cc.egui_ctx.clone()) { Ok(rx) => app.control_rx = Some(rx), diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index ee77578..a6a2da2 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -21,10 +21,13 @@ image = { workspace = true } egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -# The desktop's preferred language, for the first-run interface language. The web build has no -# system locale, so the crate is native-only (see i18n::Language::system). +# The desktop's preferred language for the first-run interface language. sys-locale = { workspace = true } +[target.'cfg(target_arch = "wasm32")'.dependencies] +# Read the browser's preferred language for the first-run web interface language. +web-sys = { version = "0.3", features = ["Window", "Navigator"] } + [build-dependencies] serde_json = { workspace = true } toml = "1" diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 48e809d..9d86a5a 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -4,43 +4,63 @@ //! The interface language is a preference ([`crate::UiState::language`]): on first run it defaults //! to the system's language ([`Language::system`]), and the View tab switches it by hand. //! -//! No Japanese font is bundled and none is installed here: the CJK faces [`crate::theme`] appends -//! to every family already cover kana and kanji, so Japanese interface text renders without help. +//! CJK fonts stay system-provided: [`crate::theme`] appends available CJK faces to every family so +//! Japanese, Chinese and Korean labels and workbook text can render without bundling font files. use serde::{Deserialize, Serialize}; +mod ko; +mod ru; +mod zh; + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Language { #[default] En, + Zh, Ja, + Ko, + Ru, } impl Language { - pub const ALL: [Self; 2] = [Self::En, Self::Ja]; + pub const ALL: [Self; 5] = [Self::En, Self::Zh, Self::Ja, Self::Ko, Self::Ru]; /// The language's own name, shown in the switcher. pub fn name(self) -> &'static str { match self { Self::En => "English", + Self::Zh => "简体中文", Self::Ja => "日本語", + Self::Ko => "한국어", + Self::Ru => "Русский", + } + } + + pub const fn code(self) -> &'static str { + match self { + Self::En => "en", + Self::Zh => "zh", + Self::Ja => "ja", + Self::Ko => "ko", + Self::Ru => "ru", } } pub fn parse(code: &str) -> Option { - match code { - "en" => Some(Self::En), - "ja" => Some(Self::Ja), - _ => None, - } + Self::from_tag(code) } - /// A language tag (`ja`, `ja-JP`, `en_US`) reduced to a supported language. Native only: the web - /// build has no system locale to read and tests exercise it on the host. - #[cfg(not(target_arch = "wasm32"))] fn from_tag(tag: &str) -> Option { - Self::parse(&tag.split(['-', '_']).next().unwrap_or("").to_ascii_lowercase()) + match tag.trim().split(['-', '_']).next()?.to_ascii_lowercase().as_str() { + "en" => Some(Self::En), + "zh" => Some(Self::Zh), + "ja" => Some(Self::Ja), + "ko" => Some(Self::Ko), + "ru" => Some(Self::Ru), + _ => None, + } } /// The system's preferred interface language, best effort; English when it is unknown or @@ -52,17 +72,21 @@ impl Language { } #[cfg(target_arch = "wasm32")] { - Self::En + web_sys::window().and_then(|window| window.navigator().language()).and_then(|tag| Self::from_tag(&tag)).unwrap_or(Self::En) } } pub fn tr(self, text: &str) -> &str { - if self == Self::Ja - && let Some((_, japanese)) = JAPANESE.iter().find(|(english, _)| *english == text) - { - return japanese; + if self == Self::En { + return text; + } + match self { + Self::En => text, + Self::Ja => JAPANESE.iter().find(|(english, _)| *english == text).map(|(_, translated)| *translated).unwrap_or(text), + Self::Zh => zh::translate(text).unwrap_or(text), + Self::Ko => ko::translate(text).unwrap_or(text), + Self::Ru => ru::translate(text).unwrap_or(text), } - text } } @@ -344,6 +368,8 @@ const JAPANESE: &[(&str, &str)] = &[ ("Lookup &\nReference", "検索/行列"), ("Math &\nTrig", "数学/三角"), ("More\nFunctions", "その他の関数"), + ("Screen", "画面"), + ("Print", "印刷"), ]; #[cfg(test)] @@ -357,8 +383,19 @@ mod tests { assert!(JAPANESE.iter().take(i).all(|(other, _)| en != other)); assert_eq!(Language::En.tr(en), *en); } + for (english, _) in JAPANESE { + assert!(zh::translate(english).is_some_and(|text| !text.is_empty()), "missing Simplified Chinese: {english}"); + assert!(ko::translate(english).is_some_and(|text| !text.is_empty()), "missing Korean: {english}"); + assert!(ru::translate(english).is_some_and(|text| !text.is_empty()), "missing Russian: {english}"); + } assert_eq!(Language::Ja.tr("Data"), "データ"); + assert_eq!(Language::Zh.tr("Data"), "数据"); + assert_eq!(Language::Ko.tr("Data"), "데이터"); + assert_eq!(Language::Ru.tr("Data"), "Данные"); assert_eq!(Language::Ja.tr("Sheet1!A1"), "Sheet1!A1"); + assert_eq!(Language::Zh.tr("Sheet1!A1"), "Sheet1!A1"); + assert_eq!(Language::Ko.tr("Sheet1!A1"), "Sheet1!A1"); + assert_eq!(Language::Ru.tr("Sheet1!A1"), "Sheet1!A1"); assert_eq!(Language::parse("xx"), None); } @@ -366,17 +403,26 @@ mod tests { fn tags_reduce_to_a_supported_language() { assert_eq!(Language::from_tag("ja-JP"), Some(Language::Ja)); assert_eq!(Language::from_tag("en_US"), Some(Language::En)); + assert_eq!(Language::from_tag("zh-Hans-CN"), Some(Language::Zh)); + assert_eq!(Language::from_tag("ko-KR"), Some(Language::Ko)); + assert_eq!(Language::from_tag("RU_ru"), Some(Language::Ru)); assert_eq!(Language::from_tag("de-DE"), None); } + #[test] + fn an_unset_language_preference_uses_the_system_default() { + let restored: crate::UiState = serde_json::from_str("{}").unwrap(); + assert_eq!(restored.language, Language::system()); + } + #[test] fn language_commands_switch_and_persist() { let mut app = crate::SheetApp::new(gridcraft_engine::Session::default(), Default::default()); - app.run("app.language.japanese", serde_json::json!({})).unwrap(); - assert_eq!(app.ui.language, Language::Ja); - let restored: crate::UiState = serde_json::from_str(&serde_json::to_string(&app.ui).unwrap()).unwrap(); - assert_eq!(restored.language, Language::Ja); - app.run("app.language.english", serde_json::json!({})).unwrap(); - assert_eq!(app.ui.language, Language::En); + for language in Language::ALL { + app.run("app.language.set", serde_json::json!({"language": language.code()})).unwrap(); + assert_eq!(app.ui.language, language); + let restored: crate::UiState = serde_json::from_str(&serde_json::to_string(&app.ui).unwrap()).unwrap(); + assert_eq!(restored.language, language); + } } } diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs new file mode 100644 index 0000000..ea7dc8c --- /dev/null +++ b/crates/ui-egui/src/i18n/ko.rs @@ -0,0 +1,268 @@ +//! Korean translations for the interface strings in i18n.rs. + +pub(super) fn translate(text: &str) -> Option<&'static str> { + Some(match text { + "Paste" => "붙여넣기", + "Paste Values" => "값 붙여넣기", + "Paste Formulas" => "수식 붙여넣기", + "Paste Formatting" => "서식 붙여넣기", + "Transpose" => "행/열 바꾸기", + "Paste Link" => "연결하여 붙여넣기", + "Paste Special…" => "선택하여 붙여넣기…", + "Cut (⌘X)" => "잘라내기 (⌘X)", + "Copy (⌘C)" => "복사 (⌘C)", + "Format Painter (double-click to keep it on)" => "서식 복사 (두 번 클릭하여 유지)", + "Bold (⌘B)" => "굵게 (⌘B)", + "Italic (⌘I)" => "기울임꼴 (⌘I)", + "Underline (⌘U)" => "밑줄 (⌘U)", + "Double Underline" => "이중 밑줄", + "Strikethrough" => "취소선", + "Underline" => "밑줄", + "Increase Font Size" => "글꼴 크기 늘리기", + "Decrease Font Size" => "글꼴 크기 줄이기", + "Wrap Text" => "텍스트 줄 바꿈", + "Merge & Center" => "병합하고 가운데 맞춤", + "Merge Cells" => "셀 병합", + "Merge Across" => "행 방향 병합", + "Unmerge Cells" => "셀 병합 취소", + "Fill Color" => "채우기 색", + "Font Color" => "글꼴 색", + "Borders" => "테두리", + "Top\nAlign" => "위쪽\n맞춤", + "Middle\nAlign" => "가운데\n맞춤", + "Bottom\nAlign" => "아래쪽\n맞춤", + "Left" => "왼쪽", + "Right" => "오른쪽", + "Center" => "가운데", + "Horizontal" => "가로", + "Vertical Text" => "세로 텍스트", + "Decrease Indent" => "들여쓰기 줄이기", + "Increase Indent" => "들여쓰기 늘리기", + "Orientation" => "방향", + "Angle Counterclockwise" => "시계 반대 방향 각도", + "Angle Clockwise" => "시계 방향 각도", + "Rotate Text Up" => "텍스트 위쪽으로 회전", + "Rotate Text Down" => "텍스트 아래쪽으로 회전", + "Accounting Number Format" => "회계 숫자 서식", + "Percent Style" => "백분율 스타일", + "Comma Style" => "쉼표 스타일", + "Increase Decimal" => "소수 자릿수 늘리기", + "Decrease Decimal" => "소수 자릿수 줄이기", + "More Accounting Formats…" => "회계 형식 더 보기…", + "Conditional\nFormatting" => "조건부\n서식", + "Conditional Formatting" => "조건부 서식", + "Format\nas Table" => "표 서식\n지정", + "Cell\nStyles" => "셀\n스타일", + "Format Cells…" => "셀 서식…", + "Format Cell Alignment…" => "셀 맞춤 서식…", + "Format\nPane" => "서식\n창", + "Format" => "서식", + "Insert" => "삽입", + "Insert Cells…" => "셀 삽입…", + "Delete" => "삭제", + "Delete Cells…" => "셀 삭제…", + "Insert Sheet Rows" => "시트 행 삽입", + "Insert Sheet Columns" => "시트 열 삽입", + "Delete Sheet Rows" => "시트 행 삭제", + "Delete Sheet Columns" => "시트 열 삭제", + "Row Height…" => "행 높이…", + "AutoFit Row Height" => "행 높이 자동 맞춤", + "Column Width…" => "열 너비…", + "AutoFit Column Width" => "열 너비 자동 맞춤", + "Default Width…" => "기본 너비…", + "Hide Rows" => "행 숨기기", + "Unhide Rows" => "행 숨기기 취소", + "Hide Columns" => "열 숨기기", + "Unhide Columns" => "열 숨기기 취소", + "Lock Cell" => "셀 잠금", + "Clear" => "지우기", + "Clear All" => "모두 지우기", + "Clear Formats" => "서식 지우기", + "Clear Contents" => "내용 지우기", + "Clear Comments and Notes" => "댓글 및 메모 지우기", + "Clear Hyperlinks" => "하이퍼링크 지우기", + "AutoSum" => "자동 합계", + "AutoSum (⌘⇧T)" => "자동 합계 (⌘⇧T)", + "Flash\nFill" => "빠른\n채우기", + "Flash Fill" => "빠른 채우기", + "Fill" => "채우기", + "Clear Print Area" => "인쇄 영역 지우기", + "Find &\nSelect" => "찾기 및\n선택", + "Find…" => "찾기…", + "Replace…" => "바꾸기…", + "Go To…" => "이동…", + "Go To Special…" => "선택하여 이동…", + "PivotTable" => "피벗 테이블", + "Recommended\nCharts" => "추천\n차트", + "Table" => "표", + "Pictures" => "그림", + "Shapes" => "도형", + "Icons" => "아이콘", + "Text\nBox" => "텍스트\n상자", + "Comment" => "댓글", + "New\nComment" => "새 댓글", + "New Note" => "새 메모", + "Show/Hide Note" => "메모 표시/숨기기", + "Header &\nFooter" => "머리글 및\n바닥글", + "Text to\nColumns" => "텍스트\n나누기", + "Link" => "링크", + "Symbol" => "기호", + "Sparklines" => "스파크라인", + "Header & Footer" => "머리글 및 바닥글", + "Draw with ink" => "잉크로 그리기", + "Eraser" => "지우개", + "Ink to\nShape" => "잉크를\n도형으로", + "Select\nObjects" => "개체\n선택", + "Themes" => "테마", + "Margins" => "여백", + "Size" => "용지 크기", + "Print\nArea" => "인쇄\n영역", + "Set Print Area" => "인쇄 영역 설정", + "Breaks" => "나누기", + "Insert Page Break" => "페이지 나누기 삽입", + "Remove Page Break" => "페이지 나누기 제거", + "Reset All Page Breaks" => "모든 페이지 나누기 다시 설정", + "Print\nTitles" => "인쇄\n제목", + "Narrow" => "좁게", + "Wide" => "넓게", + "Normal" => "보통", + "Portrait" => "세로", + "Landscape" => "가로", + "Custom Margins…" => "사용자 지정 여백…", + "Insert\nFunction" => "함수\n삽입", + "Name\nManager" => "이름\n관리자", + "Define Name" => "이름 정의", + "Use in Formula" => "수식에서 사용", + "Create from Selection" => "선택 영역에서 만들기", + "Trace Precedents" => "선행 셀 추적", + "Trace Dependents" => "종속 셀 추적", + "Remove Arrows" => "화살표 제거", + "Show Formulas" => "수식 표시", + "Error Checking" => "오류 검사", + "Evaluate Formula" => "수식 계산", + "Watch Window" => "감시 창", + "Calculation\nOptions" => "계산\n옵션", + "Calculate Now" => "지금 계산", + "Calculate Sheet" => "시트 계산", + "Automatic" => "자동", + "Automatic Except for Data Tables" => "데이터 표를 제외한 자동", + "Manual" => "수동", + "Sort &\nFilter" => "정렬 및\n필터", + "Sort" => "정렬", + "Sort A to Z" => "오름차순 정렬", + "Sort Z to A" => "내림차순 정렬", + "Custom Sort…" => "사용자 지정 정렬…", + "Filter" => "필터", + "Reapply" => "다시 적용", + "Get Data\n(Text/CSV)" => "데이터 가져오기\n(텍스트/CSV)", + "Remove\nDuplicates" => "중복\n항목 제거", + "Data\nValidation" => "데이터\n유효성 검사", + "Data Validation" => "데이터 유효성 검사", + "Data Validation…" => "데이터 유효성 검사…", + "Circle Invalid Data" => "잘못된 데이터 표시", + "Clear Validation" => "유효성 검사 지우기", + "Group" => "그룹", + "Ungroup" => "그룹 해제", + "Subtotal" => "부분합", + "What-If\nAnalysis" => "가상 분석", + "Data Table…" => "데이터 표…", + "Scenario Manager…" => "시나리오 관리자…", + "Goal Seek…" => "목표값 찾기…", + "Spelling" => "맞춤법 검사", + "Check\nAccessibility" => "접근성\n검사", + "Threaded Comments" => "스레드 댓글", + "Show\nComments" => "댓글\n표시", + "Protect\nWorkbook" => "통합 문서\n보호", + "Unprotect\nSheet" => "시트 보호\n해제", + "Protect Sheet…" => "시트 보호…", + "Comments" => "댓글", + "Notes" => "메모", + "Page Break\nPreview" => "페이지 나누기\n미리 보기", + "Page\nLayout" => "페이지\n레이아웃", + "Formula Bar" => "수식 입력줄", + "Gridlines" => "눈금선", + "Headings" => "머리글", + "Zoom" => "확대/축소", + "100%" => "100%", + "Zoom to\nSelection" => "선택 영역에\n맞춤", + "Freeze\nPanes" => "틀\n고정", + "Freeze Panes" => "틀 고정", + "Freeze Top Row" => "첫 행 고정", + "Freeze First Column" => "첫 열 고정", + "Unfreeze Panes" => "틀 고정 취소", + "Dark Mode" => "다크 모드", + "Interface language" => "인터페이스 언어", + "Command\nPalette" => "명령\n팔레트", + "Agent\nControl" => "에이전트\n제어", + "Action\nJournal" => "작업\n기록", + "About\nGridCraft" => "GridCraft\n정보", + "Share" => "공유", + "Add Chart\nElement" => "차트\n요소 추가", + "Change\nChart Type" => "차트 종류\n변경", + "Switch\nRow/Column" => "행/열\n전환", + "Data Labels: None" => "데이터 레이블: 없음", + "Data Labels: Show" => "데이터 레이블: 표시", + "Legend: None" => "범례: 없음", + "Legend: Bottom" => "범례: 아래쪽", + "Legend: Top" => "범례: 위쪽", + "Legend: Right" => "범례: 오른쪽", + "Gridlines: None" => "눈금선: 없음", + "Gridlines: Show" => "눈금선: 표시", + "Chart\nTitle" => "차트\n제목", + "Delete\nChart" => "차트\n삭제", + "Convert\nto Range" => "범위로\n변환", + "Table\nStyles" => "표\n스타일", + "Top 10 Items" => "상위 10개 항목", + "Top 10%" => "상위 10%", + "Bottom 10 Items" => "하위 10개 항목", + "Bottom 10%" => "하위 10%", + "Above Average" => "평균 초과", + "Below Average" => "평균 미만", + "Clear Rules from Entire Sheet" => "시트 전체에서 규칙 지우기", + "Clear Rules from Selected Cells" => "선택한 셀에서 규칙 지우기", + "Constants" => "상수", + "Data Bars" => "데이터 막대", + "Color Scales" => "색조", + "Icon Sets" => "아이콘 집합", + "Home" => "홈", + "Draw" => "그리기", + "Page Layout" => "페이지 레이아웃", + "Formulas" => "수식", + "Data" => "데이터", + "Review" => "검토", + "View" => "보기", + "Automate" => "자동화", + "Table Design" => "표 디자인", + "Chart Design" => "차트 디자인", + "Ready" => "준비", + "Enter" => "입력", + "Edit" => "편집", + "Point" => "가리키기", + "Calculate" => "계산", + "Select destination and press Enter or choose Paste" => "대상을 선택하고 Enter 키를 누르거나 [붙여넣기]를 선택하세요", + "General" => "일반", + "Number" => "숫자", + "Currency" => "통화", + "Accounting" => "회계", + "Date" => "날짜", + "Time" => "시간", + "Percentage" => "백분율", + "Fraction" => "분수", + "Scientific" => "과학", + "Text" => "텍스트", + "Special" => "특수", + "Custom" => "사용자 지정", + "Short Date" => "간단한 날짜", + "Long Date" => "자세한 날짜", + "More Number Formats…" => "숫자 서식 더 보기…", + "Financial" => "재무", + "Logical" => "논리", + "Date &\nTime" => "날짜 및\n시간", + "Lookup &\nReference" => "조회 및\n참조", + "Math &\nTrig" => "수학 및\n삼각", + "More\nFunctions" => "기타\n함수", + "Screen" => "화면", + "Print" => "인쇄", + _ => return None, + }) +} diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs new file mode 100644 index 0000000..e55bcf5 --- /dev/null +++ b/crates/ui-egui/src/i18n/ru.rs @@ -0,0 +1,268 @@ +//! Russian translations for the interface strings in i18n.rs. + +pub(super) fn translate(text: &str) -> Option<&'static str> { + Some(match text { + "Paste" => "Вставить", + "Paste Values" => "Вставить значения", + "Paste Formulas" => "Вставить формулы", + "Paste Formatting" => "Вставить форматирование", + "Transpose" => "Транспонировать", + "Paste Link" => "Вставить связь", + "Paste Special…" => "Специальная вставка…", + "Cut (⌘X)" => "Вырезать (⌘X)", + "Copy (⌘C)" => "Копировать (⌘C)", + "Format Painter (double-click to keep it on)" => "Формат по образцу (дважды щёлкните, чтобы закрепить)", + "Bold (⌘B)" => "Полужирный (⌘B)", + "Italic (⌘I)" => "Курсив (⌘I)", + "Underline (⌘U)" => "Подчёркивание (⌘U)", + "Double Underline" => "Двойное подчёркивание", + "Strikethrough" => "Зачёркивание", + "Underline" => "Подчёркивание", + "Increase Font Size" => "Увеличить размер шрифта", + "Decrease Font Size" => "Уменьшить размер шрифта", + "Wrap Text" => "Переносить текст", + "Merge & Center" => "Объединить и поместить в центре", + "Merge Cells" => "Объединить ячейки", + "Merge Across" => "Объединить по строкам", + "Unmerge Cells" => "Отменить объединение ячеек", + "Fill Color" => "Цвет заливки", + "Font Color" => "Цвет шрифта", + "Borders" => "Границы", + "Top\nAlign" => "По верхнему\nкраю", + "Middle\nAlign" => "По центру\nвертикали", + "Bottom\nAlign" => "По нижнему\nкраю", + "Left" => "Слева", + "Right" => "Справа", + "Center" => "По центру", + "Horizontal" => "По горизонтали", + "Vertical Text" => "Вертикальный текст", + "Decrease Indent" => "Уменьшить отступ", + "Increase Indent" => "Увеличить отступ", + "Orientation" => "Ориентация", + "Angle Counterclockwise" => "Повернуть против часовой стрелки", + "Angle Clockwise" => "Повернуть по часовой стрелке", + "Rotate Text Up" => "Повернуть текст вверх", + "Rotate Text Down" => "Повернуть текст вниз", + "Accounting Number Format" => "Финансовый числовой формат", + "Percent Style" => "Процентный формат", + "Comma Style" => "Разделитель разрядов", + "Increase Decimal" => "Увеличить разрядность", + "Decrease Decimal" => "Уменьшить разрядность", + "More Accounting Formats…" => "Другие финансовые форматы…", + "Conditional\nFormatting" => "Условное\nформатирование", + "Conditional Formatting" => "Условное форматирование", + "Format\nas Table" => "Форматировать\nкак таблицу", + "Cell\nStyles" => "Стили\nячеек", + "Format Cells…" => "Формат ячеек…", + "Format Cell Alignment…" => "Выравнивание ячеек…", + "Format\nPane" => "Область\nформатирования", + "Format" => "Формат", + "Insert" => "Вставить", + "Insert Cells…" => "Вставить ячейки…", + "Delete" => "Удалить", + "Delete Cells…" => "Удалить ячейки…", + "Insert Sheet Rows" => "Вставить строки листа", + "Insert Sheet Columns" => "Вставить столбцы листа", + "Delete Sheet Rows" => "Удалить строки листа", + "Delete Sheet Columns" => "Удалить столбцы листа", + "Row Height…" => "Высота строки…", + "AutoFit Row Height" => "Автоподбор высоты строки", + "Column Width…" => "Ширина столбца…", + "AutoFit Column Width" => "Автоподбор ширины столбца", + "Default Width…" => "Ширина по умолчанию…", + "Hide Rows" => "Скрыть строки", + "Unhide Rows" => "Показать строки", + "Hide Columns" => "Скрыть столбцы", + "Unhide Columns" => "Показать столбцы", + "Lock Cell" => "Заблокировать ячейку", + "Clear" => "Очистить", + "Clear All" => "Очистить всё", + "Clear Formats" => "Очистить форматы", + "Clear Contents" => "Очистить содержимое", + "Clear Comments and Notes" => "Удалить комментарии и заметки", + "Clear Hyperlinks" => "Удалить гиперссылки", + "AutoSum" => "Автосумма", + "AutoSum (⌘⇧T)" => "Автосумма (⌘⇧T)", + "Flash\nFill" => "Мгновенное\nзаполнение", + "Flash Fill" => "Мгновенное заполнение", + "Fill" => "Заполнить", + "Clear Print Area" => "Очистить область печати", + "Find &\nSelect" => "Найти и\nвыделить", + "Find…" => "Найти…", + "Replace…" => "Заменить…", + "Go To…" => "Перейти…", + "Go To Special…" => "Выделить группу ячеек…", + "PivotTable" => "Сводная таблица", + "Recommended\nCharts" => "Рекомендуемые\nдиаграммы", + "Table" => "Таблица", + "Pictures" => "Изображения", + "Shapes" => "Фигуры", + "Icons" => "Значки", + "Text\nBox" => "Текстовое\nполе", + "Comment" => "Комментарий", + "New\nComment" => "Новый\nкомментарий", + "New Note" => "Новая заметка", + "Show/Hide Note" => "Показать/скрыть заметку", + "Header &\nFooter" => "Верхний и\nнижний колонтитулы", + "Text to\nColumns" => "Текст\nпо столбцам", + "Link" => "Ссылка", + "Symbol" => "Символ", + "Sparklines" => "Спарклайны", + "Header & Footer" => "Верхний и нижний колонтитулы", + "Draw with ink" => "Рисование от руки", + "Eraser" => "Ластик", + "Ink to\nShape" => "Преобразовать рукописный\nввод в фигуру", + "Select\nObjects" => "Выбрать\nобъекты", + "Themes" => "Темы", + "Margins" => "Поля", + "Size" => "Размер", + "Print\nArea" => "Область\nпечати", + "Set Print Area" => "Задать область печати", + "Breaks" => "Разрывы", + "Insert Page Break" => "Вставить разрыв страницы", + "Remove Page Break" => "Удалить разрыв страницы", + "Reset All Page Breaks" => "Сбросить все разрывы страниц", + "Print\nTitles" => "Печатать\nзаголовки", + "Narrow" => "Узкие", + "Wide" => "Широкие", + "Normal" => "Обычные", + "Portrait" => "Книжная", + "Landscape" => "Альбомная", + "Custom Margins…" => "Настраиваемые поля…", + "Insert\nFunction" => "Вставить\nфункцию", + "Name\nManager" => "Диспетчер\nимён", + "Define Name" => "Присвоить имя", + "Use in Formula" => "Использовать в формуле", + "Create from Selection" => "Создать из выделенного", + "Trace Precedents" => "Влияющие ячейки", + "Trace Dependents" => "Зависимые ячейки", + "Remove Arrows" => "Убрать стрелки", + "Show Formulas" => "Показать формулы", + "Error Checking" => "Проверка ошибок", + "Evaluate Formula" => "Вычислить формулу", + "Watch Window" => "Окно контрольных значений", + "Calculation\nOptions" => "Параметры\nвычислений", + "Calculate Now" => "Вычислить сейчас", + "Calculate Sheet" => "Вычислить лист", + "Automatic" => "Автоматически", + "Automatic Except for Data Tables" => "Автоматически, кроме таблиц данных", + "Manual" => "Вручную", + "Sort &\nFilter" => "Сортировка и\nфильтр", + "Sort" => "Сортировка", + "Sort A to Z" => "Сортировка от А до Я", + "Sort Z to A" => "Сортировка от Я до А", + "Custom Sort…" => "Настраиваемая сортировка…", + "Filter" => "Фильтр", + "Reapply" => "Повторить", + "Get Data\n(Text/CSV)" => "Получить данные\n(текст/CSV)", + "Remove\nDuplicates" => "Удалить\nдубликаты", + "Data\nValidation" => "Проверка\nданных", + "Data Validation" => "Проверка данных", + "Data Validation…" => "Проверка данных…", + "Circle Invalid Data" => "Обвести неверные данные", + "Clear Validation" => "Очистить проверку данных", + "Group" => "Группировать", + "Ungroup" => "Разгруппировать", + "Subtotal" => "Промежуточный итог", + "What-If\nAnalysis" => "Анализ «что\nесли»", + "Data Table…" => "Таблица данных…", + "Scenario Manager…" => "Диспетчер сценариев…", + "Goal Seek…" => "Подбор параметра…", + "Spelling" => "Орфография", + "Check\nAccessibility" => "Проверка\nдоступности", + "Threaded Comments" => "Комментарии с ветками", + "Show\nComments" => "Показать\nкомментарии", + "Protect\nWorkbook" => "Защитить\nкнигу", + "Unprotect\nSheet" => "Снять защиту\nлиста", + "Protect Sheet…" => "Защитить лист…", + "Comments" => "Комментарии", + "Notes" => "Заметки", + "Page Break\nPreview" => "Предварительный просмотр\nразрывов страниц", + "Page\nLayout" => "Разметка\nстраницы", + "Formula Bar" => "Строка формул", + "Gridlines" => "Сетка", + "Headings" => "Заголовки", + "Zoom" => "Масштаб", + "100%" => "100%", + "Zoom to\nSelection" => "Масштаб по\nвыделению", + "Freeze\nPanes" => "Закрепить\nобласти", + "Freeze Panes" => "Закрепить области", + "Freeze Top Row" => "Закрепить верхнюю строку", + "Freeze First Column" => "Закрепить первый столбец", + "Unfreeze Panes" => "Снять закрепление областей", + "Dark Mode" => "Тёмный режим", + "Interface language" => "Язык интерфейса", + "Command\nPalette" => "Палитра\nкоманд", + "Agent\nControl" => "Управление\nагентом", + "Action\nJournal" => "Журнал\nдействий", + "About\nGridCraft" => "О\nGridCraft", + "Share" => "Поделиться", + "Add Chart\nElement" => "Добавить элемент\nдиаграммы", + "Change\nChart Type" => "Изменить тип\nдиаграммы", + "Switch\nRow/Column" => "Поменять\nстроки/столбцы", + "Data Labels: None" => "Подписи данных: нет", + "Data Labels: Show" => "Подписи данных: показать", + "Legend: None" => "Легенда: нет", + "Legend: Bottom" => "Легенда: снизу", + "Legend: Top" => "Легенда: сверху", + "Legend: Right" => "Легенда: справа", + "Gridlines: None" => "Линии сетки: нет", + "Gridlines: Show" => "Линии сетки: показать", + "Chart\nTitle" => "Название\nдиаграммы", + "Delete\nChart" => "Удалить\nдиаграмму", + "Convert\nto Range" => "Преобразовать\nв диапазон", + "Table\nStyles" => "Стили\nтаблиц", + "Top 10 Items" => "Первые 10 элементов", + "Top 10%" => "Верхние 10%", + "Bottom 10 Items" => "Последние 10 элементов", + "Bottom 10%" => "Нижние 10%", + "Above Average" => "Выше среднего", + "Below Average" => "Ниже среднего", + "Clear Rules from Entire Sheet" => "Очистить правила на всём листе", + "Clear Rules from Selected Cells" => "Очистить правила для выделенных ячеек", + "Constants" => "Константы", + "Data Bars" => "Гистограммы", + "Color Scales" => "Цветовые шкалы", + "Icon Sets" => "Наборы значков", + "Home" => "Главная", + "Draw" => "Рисование", + "Page Layout" => "Разметка страницы", + "Formulas" => "Формулы", + "Data" => "Данные", + "Review" => "Рецензирование", + "View" => "Вид", + "Automate" => "Автоматизация", + "Table Design" => "Конструктор таблиц", + "Chart Design" => "Конструктор диаграмм", + "Ready" => "Готово", + "Enter" => "Ввод", + "Edit" => "Изменение", + "Point" => "Указание", + "Calculate" => "Вычисление", + "Select destination and press Enter or choose Paste" => "Выберите место вставки и нажмите Enter или выберите «Вставить»", + "General" => "Общий", + "Number" => "Числовой", + "Currency" => "Денежный", + "Accounting" => "Финансовый", + "Date" => "Дата", + "Time" => "Время", + "Percentage" => "Процентный", + "Fraction" => "Дробный", + "Scientific" => "Экспоненциальный", + "Text" => "Текстовый", + "Special" => "Дополнительный", + "Custom" => "Пользовательский", + "Short Date" => "Краткая дата", + "Long Date" => "Длинная дата", + "More Number Formats…" => "Другие числовые форматы…", + "Financial" => "Финансовые", + "Logical" => "Логические", + "Date &\nTime" => "Дата и\nвремя", + "Lookup &\nReference" => "Поиск и\nссылки", + "Math &\nTrig" => "Математические и\nтригонометрические", + "More\nFunctions" => "Другие\nфункции", + "Screen" => "На экране", + "Print" => "Печать", + _ => return None, + }) +} diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs new file mode 100644 index 0000000..9b2c66f --- /dev/null +++ b/crates/ui-egui/src/i18n/zh.rs @@ -0,0 +1,268 @@ +//! Simplified Chinese translations for the interface strings in i18n.rs. + +pub(super) fn translate(text: &str) -> Option<&'static str> { + Some(match text { + "Paste" => "粘贴", + "Paste Values" => "粘贴值", + "Paste Formulas" => "粘贴公式", + "Paste Formatting" => "粘贴格式", + "Transpose" => "转置", + "Paste Link" => "粘贴链接", + "Paste Special…" => "选择性粘贴…", + "Cut (⌘X)" => "剪切 (⌘X)", + "Copy (⌘C)" => "复制 (⌘C)", + "Format Painter (double-click to keep it on)" => "格式刷(双击可持续使用)", + "Bold (⌘B)" => "粗体 (⌘B)", + "Italic (⌘I)" => "斜体 (⌘I)", + "Underline (⌘U)" => "下划线 (⌘U)", + "Double Underline" => "双下划线", + "Strikethrough" => "删除线", + "Underline" => "下划线", + "Increase Font Size" => "增大字号", + "Decrease Font Size" => "减小字号", + "Wrap Text" => "自动换行", + "Merge & Center" => "合并后居中", + "Merge Cells" => "合并单元格", + "Merge Across" => "跨行合并", + "Unmerge Cells" => "取消合并单元格", + "Fill Color" => "填充颜色", + "Font Color" => "字体颜色", + "Borders" => "边框", + "Top\nAlign" => "顶端对齐", + "Middle\nAlign" => "垂直居中", + "Bottom\nAlign" => "底端对齐", + "Left" => "左对齐", + "Right" => "右对齐", + "Center" => "居中", + "Horizontal" => "水平", + "Vertical Text" => "竖排文字", + "Decrease Indent" => "减少缩进", + "Increase Indent" => "增加缩进", + "Orientation" => "文字方向", + "Angle Counterclockwise" => "逆时针旋转", + "Angle Clockwise" => "顺时针旋转", + "Rotate Text Up" => "文字向上旋转", + "Rotate Text Down" => "文字向下旋转", + "Accounting Number Format" => "会计数字格式", + "Percent Style" => "百分比样式", + "Comma Style" => "千位分隔样式", + "Increase Decimal" => "增加小数位数", + "Decrease Decimal" => "减少小数位数", + "More Accounting Formats…" => "更多会计格式…", + "Conditional\nFormatting" => "条件\n格式", + "Conditional Formatting" => "条件格式", + "Format\nas Table" => "套用表格\n格式", + "Cell\nStyles" => "单元格\n样式", + "Format Cells…" => "设置单元格格式…", + "Format Cell Alignment…" => "设置单元格对齐方式…", + "Format\nPane" => "格式\n窗格", + "Format" => "格式", + "Insert" => "插入", + "Insert Cells…" => "插入单元格…", + "Delete" => "删除", + "Delete Cells…" => "删除单元格…", + "Insert Sheet Rows" => "插入工作表行", + "Insert Sheet Columns" => "插入工作表列", + "Delete Sheet Rows" => "删除工作表行", + "Delete Sheet Columns" => "删除工作表列", + "Row Height…" => "行高…", + "AutoFit Row Height" => "自动调整行高", + "Column Width…" => "列宽…", + "AutoFit Column Width" => "自动调整列宽", + "Default Width…" => "默认列宽…", + "Hide Rows" => "隐藏行", + "Unhide Rows" => "取消隐藏行", + "Hide Columns" => "隐藏列", + "Unhide Columns" => "取消隐藏列", + "Lock Cell" => "锁定单元格", + "Clear" => "清除", + "Clear All" => "全部清除", + "Clear Formats" => "清除格式", + "Clear Contents" => "清除内容", + "Clear Comments and Notes" => "清除批注和备注", + "Clear Hyperlinks" => "清除超链接", + "AutoSum" => "自动求和", + "AutoSum (⌘⇧T)" => "自动求和 (⌘⇧T)", + "Flash\nFill" => "快速\n填充", + "Flash Fill" => "快速填充", + "Fill" => "填充", + "Clear Print Area" => "清除打印区域", + "Find &\nSelect" => "查找与\n选择", + "Find…" => "查找…", + "Replace…" => "替换…", + "Go To…" => "定位…", + "Go To Special…" => "定位条件…", + "PivotTable" => "数据透视表", + "Recommended\nCharts" => "推荐\n图表", + "Table" => "表格", + "Pictures" => "图片", + "Shapes" => "形状", + "Icons" => "图标", + "Text\nBox" => "文本\n框", + "Comment" => "批注", + "New\nComment" => "新建\n批注", + "New Note" => "新建备注", + "Show/Hide Note" => "显示/隐藏备注", + "Header &\nFooter" => "页眉和\n页脚", + "Text to\nColumns" => "分列", + "Link" => "链接", + "Symbol" => "符号", + "Sparklines" => "迷你图", + "Header & Footer" => "页眉和页脚", + "Draw with ink" => "墨迹绘制", + "Eraser" => "橡皮擦", + "Ink to\nShape" => "墨迹转\n形状", + "Select\nObjects" => "选择\n对象", + "Themes" => "主题", + "Margins" => "页边距", + "Size" => "纸张大小", + "Print\nArea" => "打印\n区域", + "Set Print Area" => "设置打印区域", + "Breaks" => "分页符", + "Insert Page Break" => "插入分页符", + "Remove Page Break" => "删除分页符", + "Reset All Page Breaks" => "重置所有分页符", + "Print\nTitles" => "打印\n标题", + "Narrow" => "窄", + "Wide" => "宽", + "Normal" => "普通", + "Portrait" => "纵向", + "Landscape" => "横向", + "Custom Margins…" => "自定义页边距…", + "Insert\nFunction" => "插入\n函数", + "Name\nManager" => "名称\n管理器", + "Define Name" => "定义名称", + "Use in Formula" => "在公式中使用", + "Create from Selection" => "根据所选内容创建", + "Trace Precedents" => "追踪引用单元格", + "Trace Dependents" => "追踪从属单元格", + "Remove Arrows" => "删除箭头", + "Show Formulas" => "显示公式", + "Error Checking" => "错误检查", + "Evaluate Formula" => "公式求值", + "Watch Window" => "监视窗口", + "Calculation\nOptions" => "计算\n选项", + "Calculate Now" => "立即计算", + "Calculate Sheet" => "计算工作表", + "Automatic" => "自动", + "Automatic Except for Data Tables" => "除数据表以外自动", + "Manual" => "手动", + "Sort &\nFilter" => "排序和\n筛选", + "Sort" => "排序", + "Sort A to Z" => "升序排序", + "Sort Z to A" => "降序排序", + "Custom Sort…" => "自定义排序…", + "Filter" => "筛选", + "Reapply" => "重新应用", + "Get Data\n(Text/CSV)" => "获取数据\n(文本/CSV)", + "Remove\nDuplicates" => "删除\n重复项", + "Data\nValidation" => "数据\n验证", + "Data Validation" => "数据验证", + "Data Validation…" => "数据验证…", + "Circle Invalid Data" => "圈出无效数据", + "Clear Validation" => "清除验证", + "Group" => "组合", + "Ungroup" => "取消组合", + "Subtotal" => "分类汇总", + "What-If\nAnalysis" => "假设分析", + "Data Table…" => "数据表…", + "Scenario Manager…" => "方案管理器…", + "Goal Seek…" => "单变量求解…", + "Spelling" => "拼写检查", + "Check\nAccessibility" => "检查\n辅助功能", + "Threaded Comments" => "线程式批注", + "Show\nComments" => "显示\n批注", + "Protect\nWorkbook" => "保护\n工作簿", + "Unprotect\nSheet" => "撤销\n工作表保护", + "Protect Sheet…" => "保护工作表…", + "Comments" => "批注", + "Notes" => "备注", + "Page Break\nPreview" => "分页符\n预览", + "Page\nLayout" => "页面\n布局", + "Formula Bar" => "编辑栏", + "Gridlines" => "网格线", + "Headings" => "标题", + "Zoom" => "缩放", + "100%" => "100%", + "Zoom to\nSelection" => "缩放到\n所选内容", + "Freeze\nPanes" => "冻结\n窗格", + "Freeze Panes" => "冻结窗格", + "Freeze Top Row" => "冻结首行", + "Freeze First Column" => "冻结首列", + "Unfreeze Panes" => "取消冻结窗格", + "Dark Mode" => "深色模式", + "Interface language" => "界面语言", + "Command\nPalette" => "命令\n面板", + "Agent\nControl" => "智能体\n控制", + "Action\nJournal" => "操作\n日志", + "About\nGridCraft" => "关于\nGridCraft", + "Share" => "分享", + "Add Chart\nElement" => "添加图表\n元素", + "Change\nChart Type" => "更改\n图表类型", + "Switch\nRow/Column" => "切换\n行/列", + "Data Labels: None" => "数据标签:无", + "Data Labels: Show" => "数据标签:显示", + "Legend: None" => "图例:无", + "Legend: Bottom" => "图例:底部", + "Legend: Top" => "图例:顶部", + "Legend: Right" => "图例:右侧", + "Gridlines: None" => "网格线:无", + "Gridlines: Show" => "网格线:显示", + "Chart\nTitle" => "图表\n标题", + "Delete\nChart" => "删除\n图表", + "Convert\nto Range" => "转换\n为区域", + "Table\nStyles" => "表格\n样式", + "Top 10 Items" => "前 10 项", + "Top 10%" => "前 10%", + "Bottom 10 Items" => "后 10 项", + "Bottom 10%" => "后 10%", + "Above Average" => "高于平均值", + "Below Average" => "低于平均值", + "Clear Rules from Entire Sheet" => "清除整张工作表的规则", + "Clear Rules from Selected Cells" => "清除所选单元格的规则", + "Constants" => "常量", + "Data Bars" => "数据条", + "Color Scales" => "色阶", + "Icon Sets" => "图标集", + "Home" => "开始", + "Draw" => "绘图", + "Page Layout" => "页面布局", + "Formulas" => "公式", + "Data" => "数据", + "Review" => "审阅", + "View" => "视图", + "Automate" => "自动化", + "Table Design" => "表格设计", + "Chart Design" => "图表设计", + "Ready" => "就绪", + "Enter" => "输入", + "Edit" => "编辑", + "Point" => "指向", + "Calculate" => "计算", + "Select destination and press Enter or choose Paste" => "选择目标位置并按 Enter,或选择“粘贴”", + "General" => "常规", + "Number" => "数值", + "Currency" => "货币", + "Accounting" => "会计专用", + "Date" => "日期", + "Time" => "时间", + "Percentage" => "百分比", + "Fraction" => "分数", + "Scientific" => "科学记数法", + "Text" => "文本", + "Special" => "特殊", + "Custom" => "自定义", + "Short Date" => "短日期", + "Long Date" => "长日期", + "More Number Formats…" => "更多数字格式…", + "Financial" => "财务", + "Logical" => "逻辑", + "Date &\nTime" => "日期和\n时间", + "Lookup &\nReference" => "查找和\n引用", + "Math &\nTrig" => "数学和\n三角函数", + "More\nFunctions" => "更多\n函数", + "Screen" => "屏幕", + "Print" => "打印", + _ => return None, + }) +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4e7a383..ff1a734 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -109,6 +109,7 @@ pub struct SheetApp { pub perf: Perf, pub fonts_ready: bool, fonts_set: bool, + fonts_language: Option, pub name_box: Option, pub(crate) shots: control::Shots, /// Chart selected on the sheet (id). @@ -138,6 +139,7 @@ impl SheetApp { perf: Perf::default(), fonts_ready: false, fonts_set: false, + fonts_language: None, name_box: None, shots: control::Shots::default(), selected_chart: None, @@ -145,9 +147,14 @@ impl SheetApp { } } - /// One-time context setup: fonts and visuals. + /// One-time context setup: fonts and visuals, using the system's preferred language. pub fn setup_context(ctx: &egui::Context, dark: bool) { - ctx.set_fonts(theme::font_definitions()); + Self::setup_context_for_language(ctx, dark, i18n::Language::system()); + } + + /// Sets up fonts and visuals for a specific persisted interface language. + pub fn setup_context_for_language(ctx: &egui::Context, dark: bool, language: i18n::Language) { + ctx.set_fonts(theme::font_definitions_for_language(language)); theme::apply(ctx, dark); } @@ -199,7 +206,10 @@ impl SheetApp { self.ui.language = l; Ok(json!({"language": l})) } - None => Err(format!("unknown language {:?} (use \"en\" or \"ja\")", p.get("language").and_then(Json::as_str).unwrap_or(""))), + None => Err(format!( + "unknown language {:?} (use \"en\", \"zh\", \"ja\", \"ko\" or \"ru\")", + p.get("language").and_then(Json::as_str).unwrap_or("") + )), } } "app.language.english" => { @@ -375,13 +385,18 @@ impl SheetApp { /// Per-frame logic (control channel, screenshots). Call before `ui`. pub fn logic(&mut self, ctx: &egui::Context) { + if self.fonts_language.is_some_and(|language| language != self.ui.language) { + self.fonts_ready = false; + self.fonts_set = false; + } if !self.fonts_ready { // New fonts apply from the next frame on: paint nothing until then. if self.fonts_set { self.fonts_ready = true; } else { - SheetApp::setup_context(ctx, self.ui.dark); + SheetApp::setup_context_for_language(ctx, self.ui.dark, self.ui.language); self.fonts_set = true; + self.fonts_language = Some(self.ui.language); ctx.request_repaint(); } } diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 52bc34b..e436485 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1268,7 +1268,8 @@ fn page_layout(app: &mut SheetApp, ui: &mut Ui) { .unwrap_or((true, true, false, false)); // These two checkboxes mean the screen view vs. the printed page, so they do not share the // "View" tab label; pick per language rather than through the shared table. - let (screen, print) = if app.ui.language == crate::i18n::Language::Ja { ("画面", "印刷") } else { ("View", "Print") }; + let screen = app.ui.language.tr("Screen"); + let print = app.ui.language.tr("Print"); ui.vertical(|ui| { ui.label(egui::RichText::new(app.ui.language.tr("Gridlines")).strong().small()); let mut v = sh.0; @@ -1588,8 +1589,7 @@ fn view(app: &mut SheetApp, ui: &mut Ui) { |ui| { for l in crate::i18n::Language::ALL { if ui.selectable_label(l == lang, l.name()).clicked() { - let code = if l == crate::i18n::Language::Ja { "app.language.japanese" } else { "app.language.english" }; - act(app, code, json!({})); + act(app, "app.language.set", json!({"language": l.code()})); } } }, diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index e14b9f8..e69ca52 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -289,12 +289,18 @@ fn candidates(role: &str) -> Vec<(String, u32)> { /// Builds the font set from system fonts (with egui's defaults as fallback). pub fn font_definitions() -> FontDefinitions { + font_definitions_for_language(crate::i18n::Language::system()) +} + +/// Builds the font set with CJK glyph shapes ordered for the selected interface language. +pub fn font_definitions_for_language(language: crate::i18n::Language) -> FontDefinitions { let mut fonts = FontDefinitions::default(); let base_prop: Vec = fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default(); let base_mono: Vec = fonts.families.get(&FontFamily::Monospace).cloned().unwrap_or_default(); // CJK has to sit at the end of each chain: a Latin font owns Latin, and epaint picks the first - // face that has the glyph, so the CJK faces only ever catch Han/Kana/Hangul. - let cjk = load_cjk(&mut fonts); + // face that has the glyph, so the CJK faces only ever catch Han/Kana/Hangul. Keep both + // simplified-Chinese and Japanese Han faces: either font may lack a less common glyph. + let cjk = load_cjk(&mut fonts, language); for role in [UI, UI_BOLD, CELL, CELL_BOLD, CELL_ITALIC, CELL_BOLD_ITALIC, SERIF, MONO] { let paths = candidates(role); let refs: Vec<(&str, u32)> = paths.iter().map(|(p, i)| (p.as_str(), *i)).collect(); @@ -324,40 +330,64 @@ pub fn font_definitions() -> FontDefinitions { /// family can carry them as fallback. egui's bundled fonts have no CJK coverage, so without this a /// Japanese or Chinese workbook renders as tofu boxes even though the text was read correctly. #[cfg(not(target_arch = "wasm32"))] -fn load_cjk(fonts: &mut FontDefinitions) -> Vec { - let mut keys = Vec::new(); - for (key, cands) in [("sys-cjk-han", cjk_han()), ("sys-cjk-hangul", cjk_hangul())] { - let refs: Vec<(&str, u32)> = cands.iter().map(|(p, i)| (p.as_str(), *i)).collect(); - if let Some(fd) = try_load(&refs) { - fonts.font_data.insert(key.to_string(), Arc::new(fd)); - keys.push(key.to_string()); - } - } - keys +fn load_cjk(fonts: &mut FontDefinitions, language: crate::i18n::Language) -> Vec { + let han_ja = load_cjk_face(fonts, "sys-cjk-han-ja", cjk_han_japanese()); + let han_zh = load_cjk_face(fonts, "sys-cjk-han-zh", cjk_han_simplified()); + let hangul = load_cjk_face(fonts, "sys-cjk-hangul", cjk_hangul()); + let order = match language { + crate::i18n::Language::Ja => [han_ja, han_zh, hangul], + crate::i18n::Language::Ko => [hangul, han_zh, han_ja], + _ => [han_zh, han_ja, hangul], + }; + order.into_iter().flatten().collect() } #[cfg(target_arch = "wasm32")] -fn load_cjk(_fonts: &mut FontDefinitions) -> Vec { +fn load_cjk(_fonts: &mut FontDefinitions, _language: crate::i18n::Language) -> Vec { Vec::new() } -/// Han + Kana faces, in fallback order. A Japanese face comes first (kana and JIS kanji render -/// with Japanese glyph shapes); a Chinese face follows to widen coverage. Every CJK face carries -/// kana, so any of them renders Japanese; the order only decides kanji glyph style. #[cfg(not(target_arch = "wasm32"))] -fn cjk_han() -> Vec<(String, u32)> { - let mac = "/System/Library/Fonts"; +fn load_cjk_face(fonts: &mut FontDefinitions, key: &str, candidates: Vec<(String, u32)>) -> Option { + let refs: Vec<(&str, u32)> = candidates.iter().map(|(path, index)| (path.as_str(), *index)).collect(); + let fd = try_load(&refs)?; + fonts.font_data.insert(key.to_string(), Arc::new(fd)); + Some(key.to_string()) +} + +/// Japanese Han/Kana faces. A Japanese face goes first when the interface is Japanese so kanji +/// use Japanese glyph shapes; Chinese remains later in the chain for glyphs it does not contain. +#[cfg(not(target_arch = "wasm32"))] +fn cjk_han_japanese() -> Vec<(String, u32)> { let win = std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into()); - let lin = ["/usr/share/fonts/opentype/noto", "/usr/share/fonts/truetype/noto", "/usr/share/fonts/truetype", "/usr/share/fonts"]; let mut v: Vec<(String, u32)> = Vec::new(); v.push((format!("{win}\\YuGothR.ttc"), 0)); // Yu Gothic (Japanese) v.push((format!("{win}\\msgothic.ttc"), 0)); // MS Gothic (Japanese) - v.push((format!("{win}\\msyh.ttc"), 0)); // Microsoft YaHei (Chinese) - v.push((format!("{mac}/Hiragino Sans GB.ttc"), 0)); - v.push((format!("{mac}/PingFang.ttc"), 0)); + v.push(("/System/Library/Fonts/Hiragino Sans W3.ttc".into(), 0)); + v.push(("/System/Library/Fonts/ヒラギノ角ゴシック W3.ttc".into(), 0)); + let lin = ["/usr/share/fonts/opentype/noto", "/usr/share/fonts/truetype/noto", "/usr/share/fonts/truetype", "/usr/share/fonts"]; for d in lin { - v.push((format!("{d}/NotoSansCJK-Regular.ttc"), 0)); v.push((format!("{d}/NotoSansCJKjp-Regular.otf"), 0)); + v.push((format!("{d}/NotoSansCJK-Regular.ttc"), 0)); + } + v +} + +/// Simplified Chinese Han faces. These are loaded separately from Japanese fonts so a missing +/// glyph in the first face can continue through the family chain instead of becoming tofu. +#[cfg(not(target_arch = "wasm32"))] +fn cjk_han_simplified() -> Vec<(String, u32)> { + let win = std::env::var("WINDIR").map(|w| format!("{w}\\Fonts")).unwrap_or_else(|_| "C:\\Windows\\Fonts".into()); + let mut v: Vec<(String, u32)> = Vec::new(); + v.push((format!("{win}\\msyh.ttc"), 0)); // Microsoft YaHei + v.push((format!("{win}\\simsun.ttc"), 0)); // SimSun + v.push((format!("{win}\\simhei.ttf"), 0)); // SimHei + v.push(("/System/Library/Fonts/PingFang.ttc".into(), 0)); + v.push(("/System/Library/Fonts/Hiragino Sans GB.ttc".into(), 0)); + let lin = ["/usr/share/fonts/opentype/noto", "/usr/share/fonts/truetype/noto", "/usr/share/fonts/truetype", "/usr/share/fonts"]; + for d in lin { + v.push((format!("{d}/NotoSansCJKsc-Regular.otf"), 0)); + v.push((format!("{d}/NotoSansCJK-Regular.ttc"), 2)); // Simplified Chinese face in the Noto collection v.push((format!("{d}/wqy-microhei.ttc"), 0)); } v @@ -374,7 +404,7 @@ fn cjk_hangul() -> Vec<(String, u32)> { v.push((format!("{mac}/AppleSDGothicNeo.ttc"), 0)); for d in lin { v.push((format!("{d}/NotoSansCJKkr-Regular.otf"), 0)); - v.push((format!("{d}/NotoSansCJK-Regular.ttc"), 0)); + v.push((format!("{d}/NotoSansCJK-Regular.ttc"), 1)); // Korean face in the Noto collection } v } @@ -423,32 +453,55 @@ mod tests { fn cjk_fallback_is_appended_to_every_family() { // Structural: whatever loaded, the CJK keys must sit at the end of each chain (after the // Latin/default fonts) so Latin still wins for Latin text. - let fonts = font_definitions(); - let cjk_keys: Vec = - ["sys-cjk-han", "sys-cjk-hangul"].into_iter().filter(|k| fonts.font_data.contains_key(*k)).map(str::to_string).collect(); + let language = crate::i18n::Language::Zh; + let fonts = font_definitions_for_language(language); + let cjk_keys: Vec = ["sys-cjk-han-zh", "sys-cjk-han-ja", "sys-cjk-hangul"] + .into_iter() + .filter(|key| fonts.font_data.contains_key(*key)) + .map(str::to_string) + .collect(); if cjk_keys.is_empty() { return; // no CJK face on this machine (minimal Linux) } for role in [UI, UI_BOLD, CELL, CELL_BOLD, CELL_ITALIC, CELL_BOLD_ITALIC, SERIF, MONO] { let chain = fonts.families.get(&FontFamily::Name(role.into())).map(Vec::as_slice).unwrap_or_default(); assert!(chain.len() >= cjk_keys.len(), "{role} chain too short for its CJK fallback"); - let tail = &chain[chain.len() - cjk_keys.len()..]; - assert_eq!(tail, cjk_keys.as_slice(), "{role} must end its chain with the CJK faces"); + let first_cjk = chain.iter().position(|key| key.starts_with("sys-cjk-")).unwrap_or(chain.len()); + let tail = &chain[first_cjk..]; + assert_eq!(tail, cjk_keys.as_slice(), "{role} must end its chain with CJK faces in the language order"); } } #[test] - fn cjk_fallback_covers_japanese() { - // Real render check: the proportional family (what cells and the UI use) must resolve - // Japanese kana/kanji to a glyph, not the tofu replacement char. - if cjk_han().iter().all(|(p, _)| std::fs::read(p).is_err()) { - return; // no CJK face on this machine (minimal Linux); nothing to assert + fn system_fallback_covers_supported_scripts() { + let cases = [ + (crate::i18n::Language::Zh, cjk_han_simplified(), &["页面布局", "自动化", "粘贴格式"][..]), + (crate::i18n::Language::Ja, cjk_han_japanese(), &["カテゴリ", "貼り付け"][..]), + (crate::i18n::Language::Ko, cjk_hangul(), &["한국어"][..]), + ]; + for (language, paths, samples) in cases { + if paths.iter().all(|(path, _)| std::fs::read(path).is_err()) { + continue; // this machine has no system face for this script + } + let ctx = egui::Context::default(); + ctx.set_fonts(font_definitions_for_language(language)); + let mut out = ctx.run_ui(egui::RawInput::default(), |_ui| {}); + out.textures_delta.clear(); // nothing consumes the atlas in a headless test + for sample in samples { + let covered = ctx.fonts_mut(|f| f.has_glyphs(&FontId::proportional(14.0), sample)); + assert!(covered, "{sample:?} must render with a real glyph, not tofu"); + } + if language == crate::i18n::Language::Zh { + let cell_font = FontId::new(14.0, FontFamily::Name(CELL.into())); + let covered = ctx.fonts_mut(|f| f.has_glyphs(&cell_font, "客户端配置 中文字体测试")); + assert!(covered, "Chinese workbook text must render with a real glyph, not tofu"); + } } let ctx = egui::Context::default(); - ctx.set_fonts(font_definitions()); + ctx.set_fonts(font_definitions_for_language(crate::i18n::Language::Ru)); let mut out = ctx.run_ui(egui::RawInput::default(), |_ui| {}); - out.textures_delta.clear(); // nothing consumes the atlas in a headless test - let covered = ctx.fonts_mut(|f| f.has_glyphs(&FontId::proportional(14.0), "カテゴリ")); - assert!(covered, "Japanese text must render with a real glyph, not tofu"); + out.textures_delta.clear(); + let cyrillic = ctx.fonts_mut(|f| f.has_glyphs(&ui_font(14.0), "Русский")); + assert!(cyrillic, "Russian interface text must render with a real glyph, not tofu"); } } diff --git a/crates/ui-egui/tests/interaction.rs b/crates/ui-egui/tests/interaction.rs index 63cdc81..f3b5d31 100644 --- a/crates/ui-egui/tests/interaction.rs +++ b/crates/ui-egui/tests/interaction.rs @@ -102,15 +102,16 @@ fn renders_every_ribbon_tab_without_panicking() { fn language_switch_translates_the_ribbon_and_persists() { use gridcraft_ui_egui::i18n::Language; let mut h = harness(blank()); - // English by default on a machine whose system language is not Japanese. - assert_ne!(h.state().ui.language, Language::Ja); - h.state_mut().run("app.language.japanese", json!({})).unwrap(); - h.run_steps(2); // the ribbon renders the Japanese tabs without panicking - assert_eq!(h.state().ui.language, Language::Ja); - // The preference round-trips through the saved UiState. - let restored: gridcraft_ui_egui::UiState = serde_json::from_str(&serde_json::to_string(&h.state().ui).unwrap()).unwrap(); - assert_eq!(restored.language, Language::Ja); - h.state_mut().run("app.language.english", json!({})).unwrap(); + assert_eq!(h.state().ui.language, Language::system()); + for (language, expected) in [(Language::Zh, "数据"), (Language::Ja, "データ"), (Language::Ko, "데이터"), (Language::Ru, "Данные")] { + h.state_mut().run("app.language.set", json!({"language": language.code()})).unwrap(); + h.run_steps(2); // every locale renders the ribbon without panicking + assert_eq!(h.state().ui.language, language); + assert_eq!(language.tr("Data"), expected); + let restored: gridcraft_ui_egui::UiState = serde_json::from_str(&serde_json::to_string(&h.state().ui).unwrap()).unwrap(); + assert_eq!(restored.language, language); + } + h.state_mut().run("app.language.set", json!({"language": "en"})).unwrap(); assert_eq!(h.state().ui.language, Language::En); }