Merge PR #1976: i18n: add Indonesian, Vietnamese, Hindi and Korean interfaces

Conflict resolutions:
- crates/ui-egui/src/system_fonts.rs: Hangul fallback kept from #2872 (U+D7A3 probe, its candidate list); #1976's duplicate probe, hangul_fallback and hangul_candidates dropped; #1976's hangul_candidates_are_font_files_without_adobe_designs test kept (checks #2872's list).
- crates/ui-egui/src/theme.rs: SYSTEM_HANGUL_FALLBACK and its fallback entry kept once, at #2872's position and doc wording.
- crates/ui-egui/tests/fonts.rs: #2872's system_fallback_covers_hangul kept, #1976's duplicate dropped.
- ATTRIBUTION.md: asset count 279 (both sides' rows).
This commit is contained in:
aiongg
2026-10-22 01:50:27 +00:00
13 changed files with 8485 additions and 15 deletions
+4 -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 (276)
## In this repository (279)
| Asset | Title | Author | Licence | Source | Used for |
|---|---|---|---|---|---|
@@ -28,6 +28,9 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `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 |
| `crates/ui-egui/src/i18n/tr.tsv` | Turkish 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 | Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/lt.tsv` | Lithuanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed | Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/hi.tsv` | Hindi 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 | Hindi UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/id.tsv` | Indonesian 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 | Indonesian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/ko.tsv` | Korean 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 | Korean UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `assets/icons/accessibility.svg` | Lucide icon "accessibility" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/accessibility.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/align-left.svg` | Lucide icon "align-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/align-center.svg` | Lucide icon "align-center" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-center.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
+31
View File
@@ -230,6 +230,37 @@ kind = "translation"
usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "6ed886d00ad8cd39d8bf254f4733c8b8c90d5bf2c6868ff9c4ca42765a148786"
[[asset]]
path = "crates/ui-egui/src/i18n/hi.tsv"
title = "Hindi interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Hindi UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "72951e5f6ba5f1808c991db5c6ba32f7b2d210b8cc9f20da7634532dc5294e27"
[[asset]]
path = "crates/ui-egui/src/i18n/id.tsv"
title = "Indonesian interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Indonesian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "fa4e8dbd5c81e71000507fcde6bb7f4d702171e162b005c9c4b5e7be1f66b20a"
[[asset]]
path = "crates/ui-egui/src/i18n/ko.tsv"
title = "Korean interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Korean UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "408e777db94b4d5807dba807f0ed82aee4fee6f99166c8fc2dc1b7dea0027f85"
[[asset]]
path = "assets/icons/accessibility.svg"
+61 -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 static DATE_LANGUAGES: [DateLanguage; 23] = [
pub static DATE_LANGUAGES: [DateLanguage; 26] = [
DateLanguage {
code: "en",
name: "English",
@@ -360,6 +360,17 @@ pub static DATE_LANGUAGES: [DateLanguage; 23] = [
days: ["sekmadienis", "pirmadienis", "antradienis", "trečiadienis", "ketvirtadienis", "penktadienis", "šeštadienis"],
days_short: ["sk", "pr", "an", "tr", "kt", "pn", "št"],
},
// Indonesian. Month and weekday names are the same with and after a day number, so
// `months_with_day` is `None`.
DateLanguage {
code: "id",
name: "Bahasa Indonesia",
months: ["Januari", "Februari", "Maret", "April", "Mei", "Juni", "Juli", "Agustus", "September", "Oktober", "November", "Desember"],
months_with_day: None,
months_short: ["Jan", "Feb", "Mar", "Apr", "Mei", "Jun", "Jul", "Agu", "Sep", "Okt", "Nov", "Des"],
days: ["Minggu", "Senin", "Selasa", "Rabu", "Kamis", "Jumat", "Sabtu"],
days_short: ["Min", "Sen", "Sel", "Rab", "Kam", "Jum", "Sab"],
},
// Vietnamese: numeric month forms (`tháng 1` with a day, `Tháng 1` standing alone).
// Fill & Sign still refuses any format whose final text fails the existing WinAnsi check
// (for example weekday names); `dd.mm.yyyy` remains writable.
@@ -398,6 +409,26 @@ pub static DATE_LANGUAGES: [DateLanguage; 23] = [
days: ["Chủ Nhật", "Thứ Hai", "Thứ Ba", "Thứ Tư", "Thứ Năm", "Thứ Sáu", "Thứ Bảy"],
days_short: ["CN", "T2", "T3", "T4", "T5", "T6", "T7"],
},
// Hindi (Devanagari). Month and weekday names are the same with and after a day number.
DateLanguage {
code: "hi",
name: "हिन्दी",
months: ["जनवरी", "फ़रवरी", "मार्च", "अप्रैल", "मई", "जून", "जुलाई", "अगस्त", "सितंबर", "अक्तूबर", "नवंबर", "दिसंबर"],
months_with_day: None,
months_short: ["जन॰", "फ़र॰", "मार्च", "अप्रैल", "मई", "जून", "जुला॰", "अग॰", "सित॰", "अक्तू॰", "नव॰", "दिस॰"],
days: ["रविवार", "सोमवार", "मंगलवार", "बुधवार", "गुरुवार", "शुक्रवार", "शनिवार"],
days_short: ["रवि", "सोम", "मंगल", "बुध", "गुरु", "शुक्र", "शनि"],
},
// Korean: months are numbered (`3월`) and weekdays end in 요일; no names change after a day.
DateLanguage {
code: "ko",
name: "한국어",
months: ["1월", "2월", "3월", "4월", "5월", "6월", "7월", "8월", "9월", "10월", "11월", "12월"],
months_with_day: None,
months_short: ["1월", "2월", "3월", "4월", "5월", "6월", "7월", "8월", "9월", "10월", "11월", "12월"],
days: ["일요일", "월요일", "화요일", "수요일", "목요일", "금요일", "토요일"],
days_short: ["일", "월", "화", "수", "목", "금", "토"],
},
];
/// The date language for `code` (any case), or `None` when there's none.
@@ -718,6 +749,35 @@ mod tests {
assert_eq!(in_lang("dddd, mmmm", "cs"), "sobota, březen");
}
/// Indonesian month and weekday names are the same with and after a day number. (Vietnamese
/// has its own test upstream: its numeric month forms are writable, weekday names are not.)
#[test]
fn indonesian_month_and_weekday_names() {
assert_eq!(in_lang("d mmmm yyyy", "id"), "7 Maret 2026");
assert_eq!(in_lang("mmmm yyyy", "id"), "Maret 2026");
assert_eq!(in_lang("dddd, d mmmm yyyy", "id"), "Sabtu, 7 Maret 2026");
// Indonesian month names are Latin, so they reach a PDF.
let mut s = Session::new().with_clock(|| 1_700_000_000);
s.set_date_language(Some("id")).unwrap();
assert_eq!(s.today_text_for_pdf(Some("d mmmm yyyy"), None).as_deref(), Ok("14 November 2023"));
}
/// Hindi and Korean names are the same with and after a day number. Neither is Western
/// European, so the date-text path refuses them with what to use instead.
#[test]
fn hindi_and_korean_month_and_weekday_names() {
assert_eq!(in_lang("d mmmm yyyy", "hi"), "7 मार्च 2026");
assert_eq!(in_lang("dddd, mmmm", "hi"), "शनिवार, मार्च");
assert_eq!(in_lang("ddd, d mmm", "hi"), "शनि, 7 मार्च");
assert_eq!(in_lang("yyyy\\년 mmmm d\\일 dddd", "ko"), "2026년 3월 7일 토요일");
assert_eq!(in_lang("ddd", "ko"), "토");
let mut s = Session::new().with_clock(|| 1_700_000_000);
for code in ["hi", "ko"] {
s.set_date_language(Some(code)).unwrap();
assert!(s.today_text_for_pdf(Some("d mmmm yyyy"), None).unwrap_err().contains("dd.mm.yyyy"), "{code}");
}
}
#[test]
fn the_session_writes_names_in_its_date_language() {
let mut s = Session::new().with_clock(|| 1_700_000_000);
+10 -4
View File
@@ -1,8 +1,8 @@
//! Embeds the fonts of the optional craft-fonts build input (`CRAFT_FONTS_DIR`) as `CRAFT_FONTS`.
//! The recipe is craft-fonts' `docs/integration.md`; unset, `CRAFT_FONTS` is empty. It only reads
//! the local checkout (no network). On wasm32 it embeds only the UI faces (BIZ UDPGothic Regular
//! and any `Arab`, `Telu`, `Hebr` or `Thai` face), to keep the web build within hosting limits (Cloudflare Pages:
//! 25 MiB per file).
//! and any `Arab`, `Telu`, `Hebr`, `Thai` or `Deva` face), to keep the web build within hosting
//! limits (Cloudflare Pages: 25 MiB per file).
use std::fmt::Write as _;
use std::path::PathBuf;
@@ -55,8 +55,14 @@ fn craft_fonts(dir: &std::path::Path) -> Result<String, String> {
let telugu = scripts.split(',').any(|s| s.trim() == "Telu");
// And the Hebrew ones (small), for Hebrew text and Edit text on the web.
let hebrew = scripts.split(',').any(|s| s.trim() == "Hebr");
let web_face =
(*family == "BIZ UDPGothic" && *style == "Regular") || arabic || telugu || hebrew || scripts.split(',').any(|s| s.trim() == "Thai");
// Devanagari too, for the Hindi interface (one small face).
let devanagari = scripts.split(',').any(|s| s.trim() == "Deva");
let web_face = (*family == "BIZ UDPGothic" && *style == "Regular")
|| arabic
|| telugu
|| hebrew
|| scripts.split(',').any(|s| s.trim() == "Thai")
|| devanagari;
if wasm && !web_face {
continue;
}
+14
View File
@@ -75,6 +75,20 @@ pub fn document_thai_font() -> Option<&'static CraftFont> {
ui_thai_fonts().into_iter().next()
}
/// The `Deva` craft-fonts faces for Devanagari interface text (the Hindi catalog, file names,
/// document titles), in manifest order. Empty when built without craft-fonts or when its revision
/// has no Devanagari face; a desktop build then falls back to one font already installed on the
/// machine (`SYSTEM_DEVANAGARI_FALLBACK`), and the web build shows replacement boxes.
pub fn ui_devanagari_fonts() -> Vec<&'static CraftFont> {
CRAFT_FONTS.iter().filter(|f| f.covers("Deva")).collect()
}
/// The face for Devanagari text written into PDFs: the first `Deva` face. `None` when built
/// without craft-fonts or when its revision has no Devanagari face.
pub fn document_devanagari_font() -> Option<&'static CraftFont> {
ui_devanagari_fonts().into_iter().next()
}
/// The `Hebr` craft-fonts faces for Hebrew interface text (file names, document titles, text
/// being edited), in manifest order. Empty when built without craft-fonts or when it has no
/// Hebrew face.
+3 -3
View File
@@ -18,9 +18,9 @@ pub mod source_shaping;
mod std14;
mod std14_metrics;
pub use craft::{
CRAFT_FONTS, CraftFont, SHIPPORI_MINCHO, document_arabic_font, document_hebrew_font, document_japanese_font, document_japanese_font_for_style,
document_japanese_fonts_for_style, document_thai_font, ui_arabic_fonts, ui_chinese_fonts, ui_cjk_fonts, ui_hebrew_fonts, ui_japanese_fonts,
ui_telugu_fonts, ui_thai_fonts,
CRAFT_FONTS, CraftFont, SHIPPORI_MINCHO, document_arabic_font, document_devanagari_font, document_hebrew_font, document_japanese_font,
document_japanese_font_for_style, document_japanese_fonts_for_style, document_thai_font, ui_arabic_fonts, ui_chinese_fonts, ui_cjk_fonts,
ui_devanagari_fonts, ui_hebrew_fonts, ui_japanese_fonts, ui_telugu_fonts, ui_thai_fonts,
};
pub use script::{
GlyphError, GlyphOutline, INTER_REGULAR, MAX_SIGNATURE_CHARS, ScriptOutline, japanese_glyph, japanese_glyph_from, script_missing, script_outline,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+276 -1
View File
@@ -147,7 +147,7 @@ fn plural_sl(n: u64) -> usize {
}
/// The registry. English first: it is the fallback and the source language.
pub static LANGUAGES: [LangInfo; 23] = [
pub static LANGUAGES: [LangInfo; 26] = [
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`).
@@ -207,9 +207,19 @@ pub static LANGUAGES: [LangInfo; 23] = [
LangInfo { code: "tr", name: "Türkçe", source: include_str!("tr.tsv"), plural: plural_none, rtl: false, catalog: OnceLock::new() },
// Lithuanian; every `lt-*` locale (`lt-LT`, `lt_LT.UTF-8`) resolves here. Three plural forms (one, few, other).
LangInfo { code: "lt", name: "Lietuvių", source: include_str!("lt.tsv"), plural: plural_lithuanian, rtl: false, catalog: OnceLock::new() },
// Indonesian; every `id-*` locale (`id-ID`, `id_ID.UTF-8`) resolves here. CLDR `id` counts have
// one form ("other"), so `@plural` entries list a single form.
LangInfo { code: "id", name: "Bahasa Indonesia", source: include_str!("id.tsv"), plural: plural_none, rtl: false, catalog: OnceLock::new() },
// Vietnamese; every `vi-*` locale (`vi-VN`, `vi_VN.UTF-8`) resolves here. CLDR `vi` counts have
// one form ("other"), so `@plural` entries list a single form.
LangInfo { code: "vi", name: "Tiếng Việt", source: include_str!("vi.tsv"), plural: plural_none, rtl: false, catalog: OnceLock::new() },
// Hindi; every `hi-*` locale (`hi-IN`, `hi_IN.UTF-8`) resolves here. CLDR `hi` has "one"
// (0 and 1) and "other", but the catalog's nouns are the same in both ("{n} पृष्ठ"), so
// `@plural` entries list a single form.
LangInfo { code: "hi", name: "हिन्दी", source: include_str!("hi.tsv"), plural: plural_none, rtl: false, catalog: OnceLock::new() },
// Korean; every `ko-*` locale (`ko-KR`, `ko_KR.UTF-8`) resolves here. CLDR `ko` counts have
// one form ("other"), so `@plural` entries list a single form.
LangInfo { code: "ko", name: "한국어", source: include_str!("ko.tsv"), plural: plural_none, rtl: false, catalog: OnceLock::new() },
];
impl LangInfo {
@@ -2732,6 +2742,271 @@ mod tests {
assert!(missing.is_empty(), "untranslated Slovene UI literals: {missing:#?}");
}
#[test]
fn indonesian_and_vietnamese_are_registered_and_persist() {
let id = Lang::from_code("id").expect("id registered");
let vi = Lang::from_code("vi").expect("vi registered");
assert_eq!(id.name(), "Bahasa Indonesia");
assert_eq!(vi.name(), "Tiếng Việt");
assert_eq!(normalize_pref("ID"), Some("id"));
assert_eq!(normalize_pref("Vi"), Some("vi"));
for tag in ["id", "id-ID", "id_ID.UTF-8", "ID-id"] {
assert_eq!(lang_from_tag(tag), Some(id), "{tag}");
}
for tag in ["vi", "vi-VN", "vi_VN.UTF-8", "VI-vn"] {
assert_eq!(lang_from_tag(tag), Some(vi), "{tag}");
}
assert_eq!(first_supported("id-ID\r\nen-US"), Some(id));
assert_eq!(first_supported("vi-VN\r\nen-US"), Some(vi));
assert_eq!(tr(id, "Save"), "Simpan");
assert_eq!(tr(id, "Save as…"), "Simpan sebagai…");
assert_eq!(tr(id, "Bookmarks"), "Bookmark");
assert_eq!(tr(id, "Fill & Sign"), "Isi & Tanda Tangani");
assert_eq!(tr(vi, "Save"), "Lưu");
assert_eq!(tr(vi, "Save as…"), "Lưu thành…");
assert_eq!(tr(vi, "Bookmarks"), "Dấu trang");
assert_eq!(tr(vi, "Fill & Sign"), "Điền & Ký");
assert_eq!(tr(vi, "Interface language"), "Ngôn ngữ giao diện");
assert_eq!(tr(id, "Interface language"), "Bahasa antarmuka");
// One CLDR plural form (CLDR `id`, `vi`): the message reads the same for every count.
assert_eq!((0..=3).map(|n| (id.0.plural)(n)).collect::<Vec<_>>(), [0, 0, 0, 0]);
assert_eq!((0..=3).map(|n| (vi.0.plural)(n)).collect::<Vec<_>>(), [0, 0, 0, 0]);
for n in [0, 1, 2, 5] {
assert_eq!(trn(id, n, "{n} page", "{n} pages"), format!("{n} halaman"));
assert_eq!(trn(vi, n, "{n} page", "{n} pages"), format!("{n} trang"));
assert_eq!(trn(id, n, "{n} field", "{n} fields"), format!("{n} kolom"));
assert_eq!(trn(vi, n, "{n} field", "{n} fields"), format!("{n} trường"));
}
let mut app = crate::PdfCraftApp::default();
app.set_option("language", "ID").unwrap();
assert_eq!(app.language, "id");
let mut restored = crate::PdfCraftApp::default();
restored.restore(&app.persist());
assert_eq!(restored.language, "id");
let mut app = crate::PdfCraftApp::default();
app.set_option("language", "vi").unwrap();
assert_eq!(app.language, "vi");
let mut restored = crate::PdfCraftApp::default();
restored.restore(&app.persist());
assert_eq!(restored.language, "vi");
assert!(restored.set_option("language", "xx").is_err());
assert_eq!(restored.language, "vi");
}
/// Indonesian and Vietnamese translate every registered command and every All tools group,
/// section and item.
#[test]
fn indonesian_and_vietnamese_cover_commands_and_catalogue() {
for lang in [Lang::from_code("id").expect("id registered"), Lang::from_code("vi").expect("vi registered")] {
for command in pdfcraft_engine::commands::COMMANDS {
assert!(has(lang, command.label), "{} missing command: {}", lang.code(), command.label);
if let Some(menu) = command.menu {
assert!(has(lang, menu), "{} missing menu: {menu}", lang.code());
}
}
for group in pdfcraft_engine::catalog::TOOL_GROUPS {
assert!(has(lang, group.label), "{} missing group: {}", lang.code(), group.label);
for section in group.sections {
assert!(has(lang, section.title), "{} missing section: {}", lang.code(), section.title);
for item in section.items {
assert!(has(lang, item.label), "{} missing item: {}", lang.code(), item.label);
}
}
}
}
}
/// Every `tl!("…")` literal in the UI source is translated in Indonesian and Vietnamese, so a
/// new label cannot silently fall back to English in either.
#[test]
fn indonesian_and_vietnamese_cover_ui_literals() {
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");
let mut rest = source.split("#[cfg(test)]\nmod ").next().unwrap_or_default();
while let Some((_, after)) = rest.split_once("tl!(\"") {
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);
if tail.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());
for lang in [Lang::from_code("id").expect("id registered"), Lang::from_code("vi").expect("vi registered")] {
let missing: Vec<_> = literals.iter().filter(|label| !has(lang, label)).collect();
assert!(missing.is_empty(), "untranslated {} UI literals: {missing:#?}", lang.code());
}
}
#[test]
fn hindi_and_korean_are_registered_and_persist() {
let hi = Lang::from_code("hi").expect("hi registered");
let ko = Lang::from_code("ko").expect("ko registered");
assert_eq!(hi.name(), "हिन्दी");
assert_eq!(ko.name(), "한국어");
assert_eq!(normalize_pref("HI"), Some("hi"));
assert_eq!(normalize_pref("Ko"), Some("ko"));
for tag in ["hi", "hi-IN", "hi_IN.UTF-8", "HI-in"] {
assert_eq!(lang_from_tag(tag), Some(hi), "{tag}");
}
for tag in ["ko", "ko-KR", "ko_KR.UTF-8", "KO-kr"] {
assert_eq!(lang_from_tag(tag), Some(ko), "{tag}");
}
assert_eq!(first_supported("hi-IN\r\nen-US"), Some(hi));
assert_eq!(first_supported("ko-KR\r\nen-US"), Some(ko));
assert_eq!(tr(hi, "Save"), "सहेजें");
assert_eq!(tr(hi, "Save as…"), "इस रूप में सहेजें…");
assert_eq!(tr(hi, "Bookmarks"), "बुकमार्क");
assert_eq!(tr(hi, "Home"), "होम");
assert_eq!(tr(hi, "Interface language"), "इंटरफ़ेस भाषा");
assert_eq!(tr(ko, "Save"), "저장");
assert_eq!(tr(ko, "Save as…"), "다른 이름으로 저장…");
assert_eq!(tr(ko, "Bookmarks"), "책갈피");
assert_eq!(tr(ko, "Home"), "홈");
assert_eq!(tr(ko, "Interface language"), "인터페이스 언어");
// One plural form per catalog: the message reads the same for every count.
assert_eq!((0..=3).map(|n| (hi.0.plural)(n)).collect::<Vec<_>>(), [0, 0, 0, 0]);
assert_eq!((0..=3).map(|n| (ko.0.plural)(n)).collect::<Vec<_>>(), [0, 0, 0, 0]);
for n in [0, 1, 2, 5] {
assert_eq!(trn(hi, n, "{n} page", "{n} pages"), format!("{n} पृष्ठ"));
assert_eq!(trn(ko, n, "{n} page", "{n} pages"), format!("{n}페이지"));
assert_eq!(trn(ko, n, "{n} field", "{n} fields"), format!("필드 {n}개"));
}
// The broken-line regression: a `\n` escape stays one catalog line.
assert_eq!(tr(ko, "This page couldn't be displayed.\n{e}"), "이 페이지를 표시할 수 없습니다.\n{e}");
for code in ["hi", "ko"] {
let mut app = crate::PdfCraftApp::default();
app.set_option("language", &code.to_uppercase()).unwrap();
assert_eq!(app.language, code);
let mut restored = crate::PdfCraftApp::default();
restored.restore(&app.persist());
assert_eq!(restored.language, code);
assert!(restored.set_option("language", "xx").is_err());
assert_eq!(restored.language, code);
}
}
/// Redaction first marks a region; applying it irreversibly removes marked content.
/// Korean must not present either stage as censorship ("검열") or imply that
/// clearing un-applied marks deletes PDF content.
#[test]
fn korean_redaction_labels_distinguish_marks_and_permanent_removal() {
let ko = Lang::from_code("ko").expect("ko registered");
for english in [
"Redact",
"Redact a PDF",
"Redact text and images",
"Redact pages…",
"Mark for redaction",
"Apply redactions",
"Apply redactions…",
"Clear redaction marks",
"Redact all",
] {
assert!(!tr(ko, english).contains("검열"), "Korean label misstates redaction: {english}");
}
assert_eq!(tr(ko, "Mark for redaction"), "제거할 영역 표시");
assert_eq!(tr(ko, "Apply redactions"), "표시된 정보 영구 제거");
assert_eq!(tr(ko, "Clear redaction marks"), "제거 대상 표시 지우기");
let warning = concat!(
"You are about to apply 1 redaction mark. ",
"Text, images and drawings under the marks, and comments ",
"and form fields that overlap them, are removed permanently."
);
assert!(tr(ko, warning).contains("영구적으로 제거됩니다"));
}
/// Hindi and Korean translate every registered command and every All tools group, section
/// and item.
#[test]
fn hindi_and_korean_cover_commands_and_catalogue() {
for lang in [Lang::from_code("hi").expect("hi registered"), Lang::from_code("ko").expect("ko registered")] {
for command in pdfcraft_engine::commands::COMMANDS {
assert!(has(lang, command.label), "{} missing command: {}", lang.code(), command.label);
if let Some(menu) = command.menu {
assert!(has(lang, menu), "{} missing menu: {menu}", lang.code());
}
}
for group in pdfcraft_engine::catalog::TOOL_GROUPS {
assert!(has(lang, group.label), "{} missing group: {}", lang.code(), group.label);
for section in group.sections {
assert!(has(lang, section.title), "{} missing section: {}", lang.code(), section.title);
for item in section.items {
assert!(has(lang, item.label), "{} missing item: {}", lang.code(), item.label);
}
}
}
}
}
/// Every `tl!("…")` literal in the UI source is translated in Hindi and Korean, so a new
/// label cannot silently fall back to English in either.
#[test]
fn hindi_and_korean_cover_ui_literals() {
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");
let mut rest = source.split("#[cfg(test)]\nmod ").next().unwrap_or_default();
while let Some((_, after)) = rest.split_once("tl!(\"") {
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);
if tail.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());
for lang in [Lang::from_code("hi").expect("hi registered"), Lang::from_code("ko").expect("ko registered")] {
let missing: Vec<_> = literals.iter().filter(|label| !has(lang, label)).collect();
assert!(missing.is_empty(), "untranslated {} UI literals: {missing:#?}", lang.code());
}
}
#[test]
fn bulgarian_is_registered() {
let bg = Lang::from_code("bg").expect("bg registered");
+15
View File
@@ -440,6 +440,7 @@ mod tests {
assert!(script_fallback(&candidates, 'A').is_some());
assert!(script_fallback(&candidates, TELUGU_PROBE).is_none());
assert!(script_fallback(&candidates, DEVANAGARI_PROBE).is_none());
assert!(script_fallback(&candidates, HANGUL_PROBE).is_none());
assert!(script_fallback(&candidates, SINHALA_PROBE).is_none());
assert!(script_fallback(&candidates, TAMIL_PROBE).is_none());
}
@@ -453,6 +454,20 @@ mod tests {
}
}
#[test]
fn hangul_candidates_are_font_files_without_adobe_designs() {
let list = hangul_candidates();
assert!(!list.is_empty());
for path in list {
assert!(path.extension().is_some_and(|e| e == "ttf" || e == "ttc"), "{path:?}");
let name = path.to_string_lossy().to_lowercase();
assert!(!["notosanscjk", "notoserifcjk", "sourcehan", "source-han"].iter().any(|b| name.contains(b)), "{name}");
}
// Inter has no Hangul.
let inter = include_bytes!("../../../assets/fonts/Inter-Regular.ttf");
assert_eq!(face_with(inter, HANGUL_PROBE), None);
}
#[test]
fn han_candidates_skip_adobe_designs() {
for need in [Need::Hans, Need::Hant] {
+3 -2
View File
@@ -385,13 +385,14 @@ fn font_definitions_for_platform(prefer_hans: bool, platform: FontPolicyPlatform
}
}
// Arabic-script faces (file names, document titles), Telugu faces (the Telugu interface,
// file names), Hebrew faces (file names, text being edited) and Thai faces after the CJK ones; the
// ranges don't overlap.
// file names), Hebrew faces (file names, text being edited), Thai faces and Devanagari faces
// (the Hindi interface) after the CJK ones; the ranges don't overlap.
for face in pdfcraft_fonts::ui_arabic_fonts()
.into_iter()
.chain(pdfcraft_fonts::ui_telugu_fonts())
.chain(pdfcraft_fonts::ui_hebrew_fonts())
.chain(pdfcraft_fonts::ui_thai_fonts())
.chain(pdfcraft_fonts::ui_devanagari_fonts())
{
let name = face.name();
if !fonts.font_data.contains_key(&name) {
+33 -3
View File
@@ -8,6 +8,7 @@ const JAPANESE: &str = "日本語の文字";
const CHINESE: &str = "简体中文欢迎";
const ARABIC: &str = "واحد اثنين";
const TELUGU: &str = "తెలుగు ఫైల్";
const DEVANAGARI: &str = "हिंदी पाठ सम्पादन";
fn families() -> Vec<FontId> {
vec![FontId::proportional(13.0), FontId::monospace(13.0), theme::medium(13.0), theme::semibold(17.0)]
@@ -163,7 +164,31 @@ fn system_fallback_covers_devanagari() {
assert_eq!(stack.iter().filter(|n| *n == theme::SYSTEM_DEVANAGARI_FALLBACK).count(), 1, "{family:?}");
}
let mut fonts = Fonts::new(TextOptions::default(), defs);
const DEVANAGARI: &str = "हिंदी पाठ सम्पादन";
for id in families() {
assert!(fonts.has_glyphs(&id, DEVANAGARI), "{id:?} lacks {DEVANAGARI}");
}
assert!(layout_widths(&mut fonts, DEVANAGARI).iter().all(|w| w.is_finite() && *w > 0.0));
}
/// Built with craft-fonts, Hindi text renders with real glyphs (no tofu) in every family: a
/// `Deva` face is a fallback after the app's own fonts. Without craft-fonts it is the installed
/// Devanagari fallback's job (see `system_fallback_covers_devanagari`), and the web build has
/// neither.
#[test]
fn devanagari_ui_text_uses_craft_fonts() {
if pdfcraft_fonts::ui_devanagari_fonts().is_empty() {
eprintln!("skipping devanagari_ui_text_uses_craft_fonts: built without craft-fonts (set CRAFT_FONTS_DIR to run it)");
return;
}
let defs = theme::font_definitions();
for family in [FontFamily::Proportional, FontFamily::Monospace] {
let stack = &defs.families[&family];
let first =
stack.iter().position(|n| pdfcraft_fonts::ui_devanagari_fonts().iter().any(|f| f.name() == *n)).expect("a Deva face is a fallback");
let own = stack.iter().position(|n| n == "Inter" || n == "JetBrainsMono").expect("the app's own font");
assert!(own < first, "{family:?}: {stack:?}");
}
let mut fonts = Fonts::new(TextOptions::default(), defs);
for id in families() {
assert!(fonts.has_glyphs(&id, DEVANAGARI), "{id:?} lacks {DEVANAGARI}");
}
@@ -290,7 +315,7 @@ fn ui_fonts_work_without_craft_fonts() {
theme::install_fonts(&ctx);
}
/// Latin- and Cyrillic-script catalogs (Czech, Brazilian Portuguese, German, Spanish, French, Russian, Bulgarian, Hungarian, Ukrainian, Italian, Slovene) are drawn entirely by the app's own faces
/// Latin- and Cyrillic-script catalogs (Czech, Brazilian Portuguese, German, Spanish, French, Russian, Bulgarian, Kazakh, Hungarian, Ukrainian, Italian, Slovene, Lithuanian, Greek, Turkish, Indonesian, Vietnamese) are drawn entirely by the app's own faces
/// (Inter, JetBrains Mono with Inter fallback): no letter falls through to egui's defaults or a CJK fallback. (egui's
/// `has_glyphs` can't answer this: with only the primary face it is also the replacement face.)
#[test]
@@ -317,9 +342,14 @@ fn primary_ui_fonts_cover_latin_and_cyrillic_catalogs() {
("tr", include_str!("../src/i18n/tr.tsv")),
("sl", include_str!("../src/i18n/sl.tsv")),
("lt", include_str!("../src/i18n/lt.tsv")),
("id", include_str!("../src/i18n/id.tsv")),
("vi", include_str!("../src/i18n/vi.tsv")),
] {
let mut text = String::from("áčďéěíňóřšťúůýžÁČĎÉĚÍŇÓŘŠŤÚŮÝŽãõçâêôàÃÕÇÂÊÔÀñÑüÜäÄöÖőűß„“¿¡«»…ґҐєЄіІїЇәғқңөұүһӘҒҚҢӨҰҮҺ");
// Vietnamese stacks its diacritics: the precomposed letters and the base letters they
// combine with, including `đ` and the tone marks `ạ ả ấ ầ ẩ ẫ ậ ắ ằ ẳ ẵ ặ ế ề ể ễ ệ`.
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()));