diff --git a/Cargo.lock b/Cargo.lock index ade23a0a7..6ff01cc9c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1545,6 +1545,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 e5136c60c..48d308e48 100644 --- a/apps/gridcraft-web/src/web.rs +++ b/apps/gridcraft-web/src/web.rs @@ -37,8 +37,17 @@ pub fn start() { } else if q.contains("sample") { 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); + let mut app = SheetApp::new(session, services(inbox.clone(), cc.egui_ctx.clone())); + // Follow the browser's language, except a CJK one: the web build has no system + // CJK fonts (none are bundled), so that interface would render as tofu. + let browser = gridcraft_ui_egui::i18n::Language::system(); + if !matches!( + browser, + gridcraft_ui_egui::i18n::Language::Zh | gridcraft_ui_egui::i18n::Language::Ja | gridcraft_ui_egui::i18n::Language::Ko + ) { + app.ui.language = browser; + } + 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 6f9174119..70c83684c 100644 --- a/apps/gridcraft/src/main.rs +++ b/apps/gridcraft/src/main.rs @@ -262,7 +262,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 ee77578f5..a6a2da2e3 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/control.rs b/crates/ui-egui/src/control.rs index b1733fd11..360d596e4 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -163,7 +163,7 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) - ("view.theme", "Display Theme", ""), ("view.zoom100", "100%", ""), ("ui.dialog", "Open Dialog", ""), - ("app.language.set", "Interface Language", "{language: \"en\"|\"ja\"} (alias: code)"), + ("app.language.set", "Interface Language", "{language: \"en\"|\"zh\"|\"ja\"|\"ko\"|\"ru\"} (alias: code)"), ("app.language.english", "Interface Language: English", ""), ("app.language.japanese", "Interface Language: Japanese", ""), ] { diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 18fda086c..2bd840c1c 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -4,51 +4,64 @@ //! 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 => "Русский", } } /// The language's code, as saved in `ui.json` and taken by `app.language.set`. - pub fn code(self) -> &'static str { + 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 @@ -60,7 +73,7 @@ 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) } } @@ -69,12 +82,14 @@ impl Language { /// A label that needs two translations (Home's text "Orientation" vs Page Layout's print /// "Orientation") is keyed `context|label`: English shows only the part after the `|`. pub fn tr(self, text: &str) -> &str { - if self == Self::Ja - && let Some((_, japanese)) = JAPANESE.iter().find(|(english, _)| *english == text) - { - return japanese; - } - text.rsplit_once('|').map_or(text, |(_, label)| label) + let translated = match self { + Self::En => None, + Self::Ja => JAPANESE.iter().find(|(english, _)| *english == text).map(|(_, translated)| *translated), + Self::Zh => zh::translate(text), + Self::Ko => ko::translate(text), + Self::Ru => ru::translate(text), + }; + translated.unwrap_or_else(|| text.rsplit_once('|').map_or(text, |(_, label)| label)) } } @@ -384,6 +399,8 @@ const JAPANESE: &[(&str, &str)] = &[ ("Lookup &\nReference", "検索/行列"), ("Math &\nTrig", "数学/三角"), ("More\nFunctions", "その他の関数"), + ("Sheet Options|View", "画面"), + ("Print", "印刷"), ]; #[cfg(test)] @@ -397,8 +414,19 @@ mod tests { assert!(JAPANESE.iter().take(i).all(|(other, _)| en != other)); assert_eq!(Language::En.tr(en), en.rsplit_once('|').map_or(*en, |(_, label)| label)); } + 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"); // A `context|label` key shows only its label in English and splits the translation. assert_eq!(Language::En.tr("Page Layout|Orientation"), "Orientation"); assert_eq!(Language::Ja.tr("Page Layout|Orientation"), "印刷の向き"); @@ -425,23 +453,35 @@ 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_is_english_until_the_app_applies_the_system_one() { + // Deserializing never reads the host locale (tests stay deterministic); the desktop app + // applies `Language::system()` itself when `ui.json` names no usable language. + let restored: crate::UiState = serde_json::from_str("{}").unwrap(); + assert_eq!(restored.language, Language::En); + assert_eq!(saved_language(&serde_json::json!({"language": "zh"})), Some(Language::Zh)); + } + #[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); + 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); + } + app.run("app.language.set", serde_json::json!({"code": "ja"})).unwrap(); + assert_eq!(app.ui.language, Language::Ja, "`code` is an alias for `language`"); + assert!(app.run("app.language.set", serde_json::json!({})).is_err(), "no language is an error, not a dialog"); app.run("app.language.english", serde_json::json!({})).unwrap(); assert_eq!(app.ui.language, Language::En); - app.run("app.language.set", serde_json::json!({"language": "ja"})).unwrap(); - assert_eq!(app.ui.language, Language::Ja); - app.run("app.language.set", serde_json::json!({"code": "en"})).unwrap(); - assert_eq!(app.ui.language, Language::En); - assert!(app.run("app.language.set", serde_json::json!({})).is_err(), "no language is an error, not a dialog"); for l in Language::ALL { assert_eq!(Language::parse(l.code()), Some(l)); } diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs new file mode 100644 index 000000000..c37ae3cab --- /dev/null +++ b/crates/ui-egui/src/i18n/ko.rs @@ -0,0 +1,273 @@ +//! 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함수", + "Page Layout|Orientation" => "용지 방향", + "Display theme" => "표시 테마", + "System" => "시스템", + "Light" => "밝게", + "Dark" => "어둡게", + "Sheet Options|View" => "화면", + "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 000000000..8d5485658 --- /dev/null +++ b/crates/ui-egui/src/i18n/ru.rs @@ -0,0 +1,273 @@ +//! 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функции", + "Page Layout|Orientation" => "Ориентация", + "Display theme" => "Тема оформления", + "System" => "Системная", + "Light" => "Светлая", + "Dark" => "Тёмная", + "Sheet Options|View" => "На экране", + "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 000000000..9f04ada73 --- /dev/null +++ b/crates/ui-egui/src/i18n/zh.rs @@ -0,0 +1,273 @@ +//! 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函数", + "Page Layout|Orientation" => "纸张方向", + "Display theme" => "显示主题", + "System" => "系统", + "Light" => "浅色", + "Dark" => "深色", + "Sheet Options|View" => "屏幕", + "Print" => "打印", + _ => return None, + }) +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4422f1ff2..4588e6c58 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -144,6 +144,9 @@ pub struct SheetApp { pub perf: Perf, pub fonts_ready: bool, fonts_set: bool, + /// Han face order the installed fonts were built with; a language switch rebuilds the fonts + /// only when it changes this (see `theme::HanOrder`). + fonts_han: Option, effective_dark: bool, pub name_box: Option, /// Transient ribbon keyboard navigation; never saved with UI preferences. @@ -177,6 +180,7 @@ impl SheetApp { perf: Perf::default(), fonts_ready: false, fonts_set: false, + fonts_han: None, effective_dark: false, name_box: None, keytips: keytips::KeyTips::default(), @@ -186,9 +190,14 @@ impl SheetApp { } } - /// One-time context setup: fonts and visuals. + /// One-time context setup: fonts and visuals (default Han order; never reads the host locale). pub fn setup_context(ctx: &egui::Context, dark: bool) { - ctx.set_fonts(theme::font_definitions()); + Self::setup_context_for_language(ctx, dark, i18n::Language::En); + } + + /// 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); } @@ -294,7 +303,7 @@ impl SheetApp { self.ui.language = l; Ok(json!({"language": l})) } - None => Err(format!("unknown language {code:?} (use \"en\" or \"ja\")")), + None => Err(format!("unknown language {code:?} (use \"en\", \"zh\", \"ja\", \"ko\" or \"ru\")")), } } "app.language.english" => { @@ -478,13 +487,18 @@ impl SheetApp { /// Per-frame logic (control channel, screenshots). Call before `ui`. pub fn logic(&mut self, ctx: &egui::Context) { + if self.fonts_han.is_some_and(|han| han != theme::HanOrder::of(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_han = Some(theme::HanOrder::of(self.ui.language)); ctx.request_repaint(); } } diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 5c0c8f581..28d025c11 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1358,9 +1358,10 @@ fn page_layout(app: &mut SheetApp, ui: &mut Ui) { .active() .and_then(|d| d.wb.active().map(|s| (s.show_gridlines, s.show_headings, s.print.gridlines, s.print.headings))) .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") }; + // These two checkboxes mean the screen view vs. the printed page. English shows "View" (as in + // Excel), but the key is distinct from the "View" tab so languages can word them apart. + let screen = app.ui.language.tr("Sheet Options|View"); + 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; diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index b10842757..968d95245 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -287,14 +287,37 @@ fn candidates(role: &str) -> Vec<(String, u32)> { v } -/// Builds the font set from system fonts (with egui's defaults as fallback). +/// Builds the font set from system fonts (with egui's defaults as fallback), with the default +/// (non-Japanese) Han order. Never reads the host locale, so tests and snapshots are stable. pub fn font_definitions() -> FontDefinitions { + font_definitions_for_language(crate::i18n::Language::En) +} + +/// Which Han face leads the fallback chain. Both Han faces cover kana and most kanji, so this only +/// decides glyph shapes (and which face catches a glyph the other lacks); switching between two +/// languages with the same order needs no font rebuild. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum HanOrder { + /// Japanese glyph shapes first (the interface is Japanese). + Japanese, + /// Simplified Chinese first: the widest Han coverage, for every other interface language. + Chinese, +} + +impl HanOrder { + pub fn of(language: crate::i18n::Language) -> Self { + if language == crate::i18n::Language::Ja { Self::Japanese } else { Self::Chinese } + } +} + +/// Builds the font set with the Han fallback faces ordered for the 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(); // The non-Latin fallback faces have to sit at the end of each chain: a Latin font owns Latin, // and epaint picks the first face that has the glyph, so they only catch their own scripts. - let fallback = load_fallback(&mut fonts); + let fallback = load_fallback(&mut fonts, HanOrder::of(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(); @@ -326,13 +349,13 @@ pub fn font_definitions() -> FontDefinitions { /// was read correctly. With a face installed those scripts get real glyphs instead of tofu; nothing /// is bundled, and a machine without the face keeps the old behaviour. #[cfg(not(target_arch = "wasm32"))] -fn load_fallback(fonts: &mut FontDefinitions) -> Vec { +fn load_fallback(fonts: &mut FontDefinitions, han: HanOrder) -> Vec { let mut keys = Vec::new(); // Files already loaded as a fallback face. A file serves at most one key: the region-merged // Noto Sans CJK .ttc found for Han also carries Hangul (and Tahoma carries both Thai and // Hebrew), so loading it again for a later script would only duplicate tens of MB in memory. let mut loaded: Vec = Vec::new(); - for (key, cands) in fallback_faces() { + for (key, cands) in fallback_faces(han) { for (path, index) in &cands { if loaded.contains(path) { break; // a face loaded for an earlier script already covers this one @@ -349,16 +372,23 @@ fn load_fallback(fonts: &mut FontDefinitions) -> Vec { } #[cfg(target_arch = "wasm32")] -fn load_fallback(_fonts: &mut FontDefinitions) -> Vec { +fn load_fallback(_fonts: &mut FontDefinitions, _han: HanOrder) -> Vec { Vec::new() } /// Every fallback face we look for, in chain order: `(key, candidate files)`. One file per script /// family is enough — epaint picks the first face with the glyph, and a script's glyphs live in one -/// file. Order only matters between faces that both cover a glyph (e.g. Japanese vs Chinese kanji). +/// file. Order only matters between faces that both cover a glyph (Japanese vs Chinese kanji), so +/// the interface language reorders the two Han entries and nothing else. #[cfg(not(target_arch = "wasm32"))] -fn fallback_faces() -> Vec<(&'static str, Vec<(String, u32)>)> { - vec![("sys-cjk-han", cjk_han()), ("sys-cjk-hangul", cjk_hangul()), ("sys-thai", thai()), ("sys-hebrew", hebrew())] +fn fallback_faces(han: HanOrder) -> Vec<(&'static str, Vec<(String, u32)>)> { + let ja = ("sys-cjk-han-ja", cjk_han_japanese()); + let zh = ("sys-cjk-han-zh", cjk_han_simplified()); + let (first, second) = match han { + HanOrder::Japanese => (ja, zh), + HanOrder::Chinese => (zh, ja), + }; + vec![first, second, ("sys-cjk-hangul", cjk_hangul()), ("sys-thai", thai()), ("sys-hebrew", hebrew())] } /// Linux directories that hold CJK fonts across distributions: Debian/Ubuntu (`opentype/noto`, @@ -378,28 +408,41 @@ const LINUX_CJK_DIRS: [&str; 10] = [ "/usr/local/share/fonts/noto-cjk", ]; -/// 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. +/// Japanese Han + Kana faces (Japanese kanji glyph shapes). /// /// The region-merged `NotoSansCJK-Regular.ttc` holds one face per locale (JP 0, KR 1, SC 2, TC 3, -/// HK 4) with the same glyph coverage; index 0 picks the Japanese glyph shapes. +/// HK 4) with the same glyph coverage, so the index only picks glyph shapes. Whichever Han list +/// comes first loads it; [`load_fallback`] does not load the same file again for the other. #[cfg(not(target_arch = "wasm32"))] -fn cjk_han() -> Vec<(String, u32)> { +fn cjk_han_japanese() -> Vec<(String, u32)> { let mac = "/System/Library/Fonts"; 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}\\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!("{win}\\simsun.ttc"), 0)); // SimSun (Chinese, older installs) - v.push((format!("{mac}/ヒラギノ角ゴシック W3.ttc"), 0)); // Hiragino Sans (Japanese) + v.push((format!("{win}\\YuGothR.ttc"), 0)); // Yu Gothic + v.push((format!("{win}\\msgothic.ttc"), 0)); // MS Gothic + v.push((format!("{mac}/ヒラギノ角ゴシック W3.ttc"), 0)); // Hiragino Sans + for d in LINUX_CJK_DIRS { + v.push((format!("{d}/NotoSansCJKjp-Regular.otf"), 0)); + v.push((format!("{d}/NotoSansCJK-Regular.ttc"), 0)); // JP face + } + v +} + +/// Simplified Chinese Han faces. Loaded apart from the Japanese ones so a glyph one family lacks +/// (simplified-only hanzi in a Japanese font) still falls through to a real glyph. +#[cfg(not(target_arch = "wasm32"))] +fn cjk_han_simplified() -> Vec<(String, u32)> { + let mac = "/System/Library/Fonts"; + 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((format!("{mac}/Hiragino Sans GB.ttc"), 0)); // ships on every recent macOS v.push((format!("{mac}/PingFang.ttc"), 0)); // older macOS only; now a downloadable asset for d in LINUX_CJK_DIRS { - v.push((format!("{d}/NotoSansCJK-Regular.ttc"), 0)); - v.push((format!("{d}/NotoSansCJKjp-Regular.otf"), 0)); v.push((format!("{d}/NotoSansCJKsc-Regular.otf"), 0)); + v.push((format!("{d}/NotoSansCJK-Regular.ttc"), 2)); // SC face } for d in LINUX_CJK_DIRS { v.push((format!("{d}/wqy/wqy-microhei.ttc"), 0)); @@ -515,33 +558,52 @@ pub fn color32(rgb: [u8; 3]) -> Color32 { #[cfg(all(test, not(target_arch = "wasm32")))] mod tests { use super::*; + use crate::i18n::Language; #[test] fn fallback_faces_are_appended_to_every_family() { // Structural: whatever loaded, the non-Latin fallback 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 fb_keys: Vec = fallback_faces().into_iter().map(|(k, _)| k.to_string()).filter(|k| fonts.font_data.contains_key(k)).collect(); - if fb_keys.is_empty() { - return; // no fallback face on this machine (minimal Linux) + // (after the Latin/default fonts) so Latin still wins for Latin text, in the language's order. + for han in [HanOrder::Chinese, HanOrder::Japanese] { + let language = if han == HanOrder::Japanese { Language::Ja } else { Language::Zh }; + let fonts = font_definitions_for_language(language); + let fb_keys: Vec = + fallback_faces(han).into_iter().map(|(k, _)| k.to_string()).filter(|k| fonts.font_data.contains_key(k)).collect(); + if fb_keys.is_empty() { + return; // no fallback 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() >= fb_keys.len(), "{role} chain too short for its fallback"); + let tail = &chain[chain.len() - fb_keys.len()..]; + assert_eq!(tail, fb_keys.as_slice(), "{role} must end its chain with the fallback faces"); + } } - 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() >= fb_keys.len(), "{role} chain too short for its fallback"); - let tail = &chain[chain.len() - fb_keys.len()..]; - assert_eq!(tail, fb_keys.as_slice(), "{role} must end its chain with the fallback faces"); + } + + #[test] + fn language_reorders_only_the_han_faces() { + let keys = |han| fallback_faces(han).into_iter().map(|(k, _)| k).collect::>(); + assert_eq!(keys(HanOrder::Japanese), ["sys-cjk-han-ja", "sys-cjk-han-zh", "sys-cjk-hangul", "sys-thai", "sys-hebrew"]); + assert_eq!(keys(HanOrder::Chinese), ["sys-cjk-han-zh", "sys-cjk-han-ja", "sys-cjk-hangul", "sys-thai", "sys-hebrew"]); + assert_eq!(HanOrder::of(Language::Ja), HanOrder::Japanese); + for l in [Language::En, Language::Zh, Language::Ko, Language::Ru] { + assert_eq!(HanOrder::of(l), HanOrder::Chinese, "{l:?} shares the default order (no font rebuild)"); } } #[test] fn fallback_faces_load_each_file_once() { - // The region-merged Noto Sans CJK .ttc serves Han and Hangul; it must be loaded once, not - // once per script (tens of MB each). - let fonts = font_definitions(); - let present: Vec<&Arc> = fallback_faces().into_iter().filter_map(|(k, _)| fonts.font_data.get(k)).collect(); - for (i, a) in present.iter().enumerate() { - for b in present.iter().skip(i + 1) { - assert!(a.font != b.font, "the same font file is loaded for two fallback scripts"); + // The region-merged Noto Sans CJK .ttc serves Japanese, Chinese and Hangul; it must be loaded + // once, not once per script (tens of MB each). + for language in [Language::En, Language::Ja] { + let fonts = font_definitions_for_language(language); + let present: Vec<&Arc> = + fallback_faces(HanOrder::of(language)).into_iter().filter_map(|(k, _)| fonts.font_data.get(k)).collect(); + for (i, a) in present.iter().enumerate() { + for b in present.iter().skip(i + 1) { + assert!(a.font != b.font, "the same font file is loaded for two fallback scripts"); + } } } } @@ -551,31 +613,41 @@ mod tests { // Real render check, per script: the proportional family (what cells and the UI use) must // resolve a sample string to a real glyph, not the tofu replacement char. Skips a script // whose face is absent (a minimal machine) and fails if a face is present but not wired in. - let cases: [(&str, Vec<(String, u32)>, &str); 4] = [ - ("sys-cjk-han", cjk_han(), "カテゴリ"), + let cases: [(&str, Vec<(String, u32)>, &str); 6] = [ + ("sys-cjk-han-ja", cjk_han_japanese(), "カテゴリ"), + ("sys-cjk-han-zh", cjk_han_simplified(), "页面布局 客户端配置"), ("sys-cjk-hangul", cjk_hangul(), "한글"), ("sys-thai", thai(), "ทดสอบ"), ("sys-hebrew", hebrew(), "עברית"), + ("ui", candidates(UI), "Русский"), ]; - let ctx = egui::Context::default(); - ctx.set_fonts(font_definitions()); - let mut out = ctx.run_ui(egui::RawInput::default(), |_ui| {}); - out.textures_delta.clear(); // nothing consumes the atlas in a headless test - // `has_glyphs` is a false negative when the face that owns the script is also the face that - // draws the replacement glyph (DejaVu Sans as the UI font owns Hebrew), so compare the laid - // out glyphs with the tofu glyph itself instead. - let font = FontId::proportional(14.0); - let glyphs = |text: &str| { - let galley = ctx.fonts_mut(|f| f.layout_no_wrap(text.to_owned(), font.clone(), Color32::BLACK)); - galley.rows.iter().flat_map(|r| r.glyphs.iter().map(|g| g.uv_rect)).collect::>() - }; - let tofu = glyphs("\u{25FB}"); - for (key, cands, sample) in cases { - if !cands.iter().any(|(p, _)| std::path::Path::new(p).exists()) { - continue; // no face for this script on this machine; nothing to assert + for language in [Language::En, Language::Ja] { + 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 + // `has_glyphs` is a false negative when the face that owns the script is also the face + // that draws the replacement glyph (DejaVu Sans as the UI font owns Hebrew), so compare + // the laid out glyphs with the tofu glyph itself instead. + for family in [FontFamily::Proportional, FontFamily::Name(CELL.into())] { + let font = FontId::new(14.0, family); + let glyphs = |text: &str| { + let galley = ctx.fonts_mut(|f| f.layout_no_wrap(text.to_owned(), font.clone(), Color32::BLACK)); + galley.rows.iter().flat_map(|r| r.glyphs.iter().filter(|g| g.chr != ' ').map(|g| g.uv_rect)).collect::>() + }; + let tofu = glyphs("\u{25FB}"); + for (key, cands, sample) in &cases { + if !cands.iter().any(|(p, _)| std::path::Path::new(p).exists()) { + continue; // no face for this script on this machine; nothing to assert + } + let drawn = glyphs(sample); + assert!( + !drawn.is_empty() && drawn.iter().all(|g| !tofu.contains(g)), + "{key} is present but {sample:?} still renders as tofu ({language:?}, {:?})", + font.family + ); + } } - let drawn = glyphs(sample); - assert!(!drawn.is_empty() && drawn.iter().all(|g| !tofu.contains(g)), "{key} is present but {sample:?} still renders as tofu"); } } } diff --git a/crates/ui-egui/tests/interaction.rs b/crates/ui-egui/tests/interaction.rs index 5baf10ba6..f08e34bd0 100644 --- a/crates/ui-egui/tests/interaction.rs +++ b/crates/ui-egui/tests/interaction.rs @@ -199,13 +199,15 @@ fn language_switch_translates_the_ribbon_and_persists() { for id in ["app.language.set", "app.language.english", "app.language.japanese"] { assert!(ids.contains(&id), "engine.commands lists {id}"); } - 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(); + 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); }