Merge pull request #104 from neko233-com/feature/i18n-zh-ko-ru

i18n: add Simplified Chinese, Korean and Russian UI
This commit is contained in:
Brandon Thomas
2026-10-10 13:25:34 -04:00
13 changed files with 1067 additions and 106 deletions
Generated
+1
View File
@@ -1545,6 +1545,7 @@ dependencies = [
"serde_json",
"sys-locale",
"toml",
"web-sys",
]
[[package]]
+11 -2
View File
@@ -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 }))
}),
)
+1 -1
View File
@@ -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),
+5 -2
View File
@@ -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"
+1 -1
View File
@@ -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", ""),
] {
+69 -29
View File
@@ -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<Self> {
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> {
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));
}
+273
View File
@@ -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,
})
}
+273
View File
@@ -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,
})
}
+273
View File
@@ -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,
})
}
+18 -4
View File
@@ -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<theme::HanOrder>,
effective_dark: bool,
pub name_box: Option<String>,
/// 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();
}
}
+4 -3
View File
@@ -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;
+129 -57
View File
@@ -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<String> = fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default();
let base_mono: Vec<String> = 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<String> {
fn load_fallback(fonts: &mut FontDefinitions, han: HanOrder) -> Vec<String> {
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<String> = 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<String> {
}
#[cfg(target_arch = "wasm32")]
fn load_fallback(_fonts: &mut FontDefinitions) -> Vec<String> {
fn load_fallback(_fonts: &mut FontDefinitions, _han: HanOrder) -> Vec<String> {
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<String> = 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<String> =
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::<Vec<_>>();
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<FontData>> = 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<FontData>> =
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::<Vec<_>>()
};
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::<Vec<_>>()
};
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");
}
}
}
+9 -7
View File
@@ -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);
}