M14: Kazakh interface language (#955)

* M14: Kazakh interface language

A complete clean-room Kazakh (kk) translation of the interface: 2105
catalogue rows covering every English key (commands, tool catalogue,
UI literals), with the plural forms and the DATE_LANGUAGES entry
(months, months_short, weekdays) the other languages have.

- Register 'kk' (Қазақша) in LANGUAGES; every kk-* locale resolves to
  it, and it appears in Preferences and the date-language picker.
- Three coverage tests cloned from the Ukrainian battery: every
  registered command and All tools label, every tl! literal in the UI
  sources, and no untranslated English left (format-only strings excepted).
- tests/fonts.rs covers the catalogue's Kazakh Cyrillic (әғқңөұүһі)
  against Inter and JetBrains Mono, which both carry the letters.
- ATTRIBUTION.toml gains the kk.tsv entry (contributor-original,
  MIT OR Apache-2.0).

cargo test -p pdfcraft-ui-egui (lib 171 + fonts 7), clippy -D warnings,
cargo fmt --check and cargo xtask assets all pass.

* Tests: give the Kazakh literal check its own doc comment and restore the Ukrainian one

* Kazakh: rows for the labels that landed alongside this catalog; refresh hashes

---------

Co-authored-by: pechnikov2026-star <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-11 16:22:16 -04:00
1 parent 58ae136267
commit d1980ec99f
6 files changed
+2384 -4

No files matched your search

+2 -1
View File
@@ -4,7 +4,7 @@
Every asset PdfCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with SHA-256 hashes, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE).
## In this repository (267)
## In this repository (268)
| Asset | Title | Author | Licence | Source | Used for |
|---|---|---|---|---|---|
@@ -22,6 +22,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `crates/ui-egui/src/i18n/ru.tsv` | Russian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/hu.tsv` | Hungarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/bg.tsv` | Bulgarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/kk.tsv` | Kazakh interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of the English UI labels into Kazakh, no proprietary localisation resources | Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/ja.tsv` | Japanese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/ar.tsv` | Arabic interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
+11
View File
@@ -164,6 +164,17 @@ kind = "translation"
usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "ca305b188abc8d9c261901dde10f7cd8518520983fdf88d2358374b352ad3d5a"
[[asset]]
path = "crates/ui-egui/src/i18n/kk.tsv"
title = "Kazakh interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of the English UI labels into Kazakh, no proprietary localisation resources"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "77afe7d7fa215c99e8e627a758178d79c51e08e33564e00b97b8840aeb91a9dd"
[[asset]]
path = "crates/ui-egui/src/i18n/ja.tsv"
title = "Japanese interface translation catalog"
+10 -1
View File
@@ -56,7 +56,7 @@ const AR_DAYS: [&str; 7] = ["الأحد", "الاثنين", "الثلاثاء",
/// The languages month and weekday names can be written in (Preferences ▸ Date format ▸ Language):
/// the interface languages, in the same order and with the same names.
pub const DATE_LANGUAGES: [DateLanguage; 19] = [
pub const DATE_LANGUAGES: [DateLanguage; 20] = [
DateLanguage {
code: "en",
name: "English",
@@ -160,6 +160,15 @@ pub const DATE_LANGUAGES: [DateLanguage; 19] = [
days: ["воскресенье", "понедельник", "вторник", "среда", "четверг", "пятница", "суббота"],
days_short: ["вс", "пн", "вт", "ср", "чт", "пт", "сб"],
},
DateLanguage {
code: "kk",
name: "Қазақша",
months: ["қаңтар", "ақпан", "наурыз", "сәуір", "мамыр", "маусым", "шілде", "тамыз", "қыркүйек", "қазан", "қараша", "желтоқсан"],
months_with_day: None,
months_short: ["қаң", "ақп", "нау", "сәу", "мам", "мау", "шіл", "там", "қыр", "қаз", "қар", "жел"],
days: ["жексенбі", "дүйсенбі", "сейсенбі", "сәрсенбі", "бейсенбі", "жұма", "сенбі"],
days_short: ["жк", "дс", "сс", "ср", "бс", "жм", "сб"],
},
DateLanguage {
code: "bg",
name: "Български",
File diff suppressed because it is too large. Load diff
+109 -1
View File
@@ -136,7 +136,7 @@ fn plural_sl(n: u64) -> usize {
}
/// The registry. English first: it is the fallback and the source language.
pub static LANGUAGES: [LangInfo; 19] = [
pub static LANGUAGES: [LangInfo; 20] = [
LangInfo { code: "en", name: "English", source: "", plural: plural_one_other, rtl: false, catalog: OnceLock::new() },
LangInfo { code: "ja", name: "日本語", source: include_str!("ja.tsv"), plural: plural_none, rtl: false, catalog: OnceLock::new() },
// Simplified Chinese; `zh`, `zh-CN`, `zh-SG` and `zh-Hans-*` locales resolve here (see `candidates`).
@@ -166,6 +166,8 @@ pub static LANGUAGES: [LangInfo; 19] = [
LangInfo { code: "fr", name: "Français", source: include_str!("fr.tsv"), plural: plural_fr, rtl: false, catalog: OnceLock::new() },
// Russian; every `ru-*` locale (`ru-RU`, `ru-BY`, `ru-KZ` ...) resolves here.
LangInfo { code: "ru", name: "Русский", source: include_str!("ru.tsv"), plural: plural_russian, rtl: false, catalog: OnceLock::new() },
// Kazakh; every `kk-*` locale (`kk-KZ`, `kk-KZ.UTF-8`) resolves here.
LangInfo { code: "kk", name: "Қазақша", source: include_str!("kk.tsv"), plural: plural_one_other, rtl: false, catalog: OnceLock::new() },
// Bulgarian; every `bg-*` locale (`bg-BG`) resolves here.
LangInfo {
code: "bg", name: "Български", source: include_str!("bg.tsv"), plural: plural_one_other, rtl: false, catalog: OnceLock::new()
@@ -2377,6 +2379,112 @@ mod tests {
}
}
/// Kazakh translates every registered command and every All tools label.
#[test]
fn kazakh_covers_commands_and_catalogue() {
let kk = Lang::from_code("kk").expect("kk registered");
for command in pdfcraft_engine::commands::COMMANDS {
assert!(has(kk, command.label), "missing command: {}", command.label);
if let Some(menu) = command.menu {
assert!(has(kk, menu), "missing menu: {menu}");
}
}
for group in pdfcraft_engine::catalog::TOOL_GROUPS {
assert!(has(kk, group.label), "missing group: {}", group.label);
for section in group.sections {
assert!(has(kk, section.title), "missing section: {}", section.title);
for item in section.items {
assert!(has(kk, item.label), "missing item: {}", item.label);
}
}
}
}
/// Preserve only product/format names, abbreviations and templates with no English words.
#[test]
fn kazakh_catalog_does_not_leave_english_ui_text() {
let kk = Lang::from_code("kk").expect("kk registered");
let (entries, errors) = parse_entries(kk.0.source, kk.0.plural_forms());
assert!(errors.is_empty(), "{errors:?}");
let keep = [
"OK",
"PDF/A…",
"Microsoft Word (.docx)",
"PostScript / EPS",
// Key-cap names, printed in English on Kazakh keyboards.
"Home / End",
"JavaScript",
"ZIP",
"{n} {kind}",
"{rule} - {status}",
"{field}: {e}",
"`{command}` {when}",
"{0}: {1}",
"{month} {y}",
"+LOC",
"−LOC",
"ΔLOC",
"+Bin",
"−Bin",
];
let unchanged: Vec<_> = entries
.iter()
.filter(|e| {
let key_cap = e.context == "key" && KEY_CAP_NAMES.contains(&e.source.as_str());
e.source == e.translation && !keep.contains(&e.source.as_str()) && !key_cap
})
.collect();
assert!(unchanged.is_empty(), "untranslated Kazakh catalog entries: {unchanged:#?}");
}
/// Kazakh translates every tl!("literal") in the UI source, including direct lookups and
/// multiline literals, following the complete-catalog check for Simplified Chinese.
#[test]
fn kazakh_covers_ui_literals() {
let kk = Lang::from_code("kk").expect("kk registered");
let mut stack = vec![std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src")];
let mut literals = std::collections::BTreeSet::new();
while let Some(dir) = stack.pop() {
for entry in std::fs::read_dir(dir).expect("UI source directory") {
let path = entry.expect("UI source entry").path();
if path.is_dir() {
if path.file_name().is_some_and(|name| name != "i18n") {
stack.push(path);
}
} else if path.extension().is_some_and(|ext| ext == "rs") {
let source = std::fs::read_to_string(path).expect("UI source file").replace("\r\n", "\n").replace("crate::i18n::t(", "tl!(");
let mut rest = source.split("#[cfg(test)]\nmod ").next().unwrap_or_default();
while let Some((_, after)) = rest.split_once("tl!(") {
let after = after.trim_start();
let Some(after) = after.strip_prefix('"') else {
rest = after;
continue;
};
let mut escaped = false;
let end = after
.char_indices()
.find_map(|(i, c)| {
if c == '"' && !escaped {
return Some(i);
}
escaped = c == '\\' && !escaped;
None
})
.expect("closed tl! literal");
let (raw, tail) = after.split_at(end);
let closing = tail.strip_prefix('"').expect("closing quote").trim_start();
let closing = closing.strip_prefix(',').unwrap_or(closing).trim_start();
if closing.starts_with(')') {
let label: String = serde_json::from_str(&format!("\"{raw}\"")).expect("UI literal escapes");
literals.insert(label);
}
rest = tail.strip_prefix('"').expect("closing quote");
}
}
}
}
assert!(literals.len() > 900, "source scan found only {} literals", literals.len());
let missing: Vec<_> = literals.iter().filter(|label| !has(kk, label)).collect();
assert!(missing.is_empty(), "untranslated Kazakh UI literals: {missing:#?}");
}
/// Check direct lookups and multiline literals as well as ordinary tl!("literal") calls,
/// following the complete-catalog check for Simplified Chinese.
#[test]
+2 -1
View File
@@ -233,13 +233,14 @@ fn primary_ui_fonts_cover_latin_and_cyrillic_catalogs() {
("es", include_str!("../src/i18n/es.tsv")),
("fr", include_str!("../src/i18n/fr.tsv")),
("ru", include_str!("../src/i18n/ru.tsv")),
("kk", include_str!("../src/i18n/kk.tsv")),
("bg", include_str!("../src/i18n/bg.tsv")),
("hu", include_str!("../src/i18n/hu.tsv")),
("uk", include_str!("../src/i18n/uk.tsv")),
("it", include_str!("../src/i18n/it.tsv")),
("sl", include_str!("../src/i18n/sl.tsv")),
] {
let mut text = String::from("áčďéěíňóřšťúůýžÁČĎÉĚÍŇÓŘŠŤÚŮÝŽãõçâêôàÃÕÇÂÊÔÀñÑüÜäÄöÖőűß„“¿¡«»…ґҐєЄіІїЇ");
let mut text = String::from("áčďéěíňóřšťúůýžÁČĎÉĚÍŇÓŘŠŤÚŮÝŽãõçâêôàÃÕÇÂÊÔÀñÑüÜäÄöÖőűß„“¿¡«»…ґҐєЄіІїЇәғқңөұүһӘҒҚҢӨҰҮҺ");
for line in catalog.lines().filter(|l| !l.starts_with('#')) {
if let Some(translation) = line.split('\t').nth(2) {
text.extend(translation.chars().filter(|c| !c.is_whitespace()));