mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-28 03:58:45 -06:00
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:
+4
-1
@@ -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 |
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()));
|
||||
|
||||
Reference in New Issue
Block a user