mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-11 13:24:35 +00:00
* G14: Vietnamese catalog — menus, file and shell chrome Add the Vietnamese (vi) TSV catalog header and translations for menus, File/Edit/Help chrome, clipboard and common shell labels. PhotoCraft and VectorCraft catalogs used only as translation memory when English source and context matched. ATTRIBUTION records the asset hash. Co-authored-by: Cursor <cursoragent@cursor.com> * G14: Vietnamese catalog — forms, signatures and pages Translate form/Fill & Sign/certificate wording and page organize/rotate/ extract/split labels. Keep product and format names unchanged. Co-authored-by: Cursor <cursoragent@cursor.com> * G14: Vietnamese catalog — comments, view, a11y and contexts Translate comments/markup, view/zoom/preferences tools, accessibility and preflight sentences, plus contextual rows (certificate, calendar, keys, scroll speed, signature pad, action wizard, @plural one-form). Co-authored-by: Cursor <cursoragent@cursor.com> * G14: Vietnamese catalog — remaining dialogs and notices Complete the Vietnamese catalog to every (context, English) key in the bundled union (2,580 entries). No unapproved English UI fallbacks remain outside the documented VI_KEEP_AS_IS list. Co-authored-by: Cursor <cursoragent@cursor.com> * G14: restore complete Vietnamese catalog key order Replace the domain-appended catalog with the verified 2,580-entry union in stable key order so coverage matches every bundled reference key. Co-authored-by: Cursor <cursoragent@cursor.com> * G14: register Vietnamese interface and date language Add `vi` / Tiếng Việt to LANGUAGES and DATE_LANGUAGES with plural_none and rtl false. Locale tags vi, vi-VN and vi_VN.UTF-8 resolve. Calendar uses Tháng Một / tháng Một; Fill & Sign refuses non-WinAnsi month names. Coverage tests require full command, literal, key and credits coverage. Co-authored-by: Cursor <cursoragent@cursor.com> * G14: document Vietnamese localization Record Vietnamese at 100% catalog coverage in localization-parity and ROADMAP, list Tiếng Việt in Preferences/docs, and note the G14 catalog in the progress log. Engine/OS details, document contents, filenames, CLI/MCP and external help remain out of scope by design. Co-authored-by: Cursor <cursoragent@cursor.com> * G14: use ASCII > in Vietnamese preference paths Inter lacks U+25B8 (▸). Match other Latin catalogs and write preference paths as "Tùy chọn > …" so the glyph coverage test stays green. Co-authored-by: Cursor <cursoragent@cursor.com> * G14: finalize Vietnamese terminology and current coverage Disambiguate theme Appearance (Giao diện) from object look (Diện mạo), normalize form Field to Trường, make redaction apply wording permanent, use numeric calendar months, close the GoToR/digital-ID key gap, and pin regression assertions. Refresh the vi.tsv attribution hash. Co-authored-by: Cursor <cursoragent@cursor.com> * G14: align localization docs with Vietnamese coverage Recompute the twelve-language mean (≈59% → ≈68%), document 23 interface languages, and remove stale claims that Vietnamese is missing. Co-authored-by: Cursor <cursoragent@cursor.com> * G14: cover Wayland title-bar strings in Vietnamese Translate Minimize/Maximize/Restore and the system title-bar preference added on upstream main, and refresh the catalog attribution hash and counts. Co-authored-by: Cursor <cursoragent@cursor.com> * G14: correct current union key count in localization docs Co-authored-by: Cursor <cursoragent@cursor.com> * G14: prepare Vietnamese localization for review Trim the vi.tsv header to maintainer-oriented notes, record native-speaker community review in attribution and localization docs, and refresh the weighted readiness arithmetic (localization ≈68% → ≈50%). Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: DUY Tran Nhat (SAE-KIDS) <duytn@sanancorp.com> Co-authored-by: Cursor <cursoragent@cursor.com>
758 lines
35 KiB
Rust
758 lines
35 KiB
Rust
//! The date format Fill & Sign stamps (Preferences ▸ Date format), kept on the [`Session`] so
|
||
//! the app and the automation tools write the same text. Acrobat's Fill & Sign has no such
|
||
//! setting (only Acrobat Sign's account settings do); this one is per user and takes any
|
||
//! pattern in Acrobat's date letters, not just a fixed list, with month and weekday names in
|
||
//! any of [`DATE_LANGUAGES`].
|
||
//!
|
||
//! Patterns use `util.printd`'s date letters: `yyyy` `yy`, `mmmm` `mmm` `mm` `m`, `dddd` `ddd`
|
||
//! `dd` `d`. `\` shows the next character as is (`d \de mmmm` → `8 de October`).
|
||
|
||
use crate::Session;
|
||
|
||
/// The format until the user picks one (Acrobat's: US month/day/year).
|
||
pub const DEFAULT_DATE_FORMAT: &str = "m/d/yyyy";
|
||
|
||
/// Ready-made formats for Preferences ▸ Date format. Any other valid pattern works too.
|
||
pub const DATE_FORMATS: [&str; 12] = [
|
||
"m/d/yyyy",
|
||
"mm/dd/yyyy",
|
||
"d/m/yyyy",
|
||
"dd/mm/yyyy",
|
||
"dd.mm.yyyy",
|
||
"yyyy-mm-dd",
|
||
"yyyy.mm.dd.",
|
||
"d mmm yyyy",
|
||
"d mmmm yyyy",
|
||
"mmm d, yyyy",
|
||
"mmmm d, yyyy",
|
||
"dddd, mmmm d, yyyy",
|
||
];
|
||
|
||
/// The longest pattern kept (settings and tool arguments are untrusted).
|
||
pub const MAX_DATE_FORMAT_CHARS: usize = 64;
|
||
|
||
/// Month and weekday names in one language. Weekdays start on Sunday.
|
||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||
pub struct DateLanguage {
|
||
/// The interface-language code where there is one (`en`, `cs`, `pt-br`, `zh-hans`, …).
|
||
pub code: &'static str,
|
||
/// Its name in itself, for pickers.
|
||
pub name: &'static str,
|
||
pub months: [&'static str; 12],
|
||
/// The month after a day number (`mmmm` when the pattern also shows `d` or `dd`), where
|
||
/// the language changes it: Czech `8. října`, Russian `8 октября`.
|
||
pub months_with_day: Option<[&'static str; 12]>,
|
||
pub months_short: [&'static str; 12],
|
||
pub days: [&'static str; 7],
|
||
pub days_short: [&'static str; 7],
|
||
}
|
||
|
||
const CJK_MONTHS: [&str; 12] = ["1月", "2月", "3月", "4月", "5月", "6月", "7月", "8月", "9月", "10月", "11月", "12月"];
|
||
const ZH_MONTHS: [&str; 12] = ["一月", "二月", "三月", "四月", "五月", "六月", "七月", "八月", "九月", "十月", "十一月", "十二月"];
|
||
const ZH_DAYS: [&str; 7] = ["星期日", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六"];
|
||
|
||
const AR_MONTHS: [&str; 12] = ["يناير", "فبراير", "مارس", "أبريل", "مايو", "يونيو", "يوليو", "أغسطس", "سبتمبر", "أكتوبر", "نوفمبر", "ديسمبر"];
|
||
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] = [
|
||
DateLanguage {
|
||
code: "en",
|
||
name: "English",
|
||
months: ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"],
|
||
months_with_day: None,
|
||
months_short: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"],
|
||
days: ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"],
|
||
days_short: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"],
|
||
},
|
||
DateLanguage {
|
||
code: "ja",
|
||
name: "日本語",
|
||
months: CJK_MONTHS,
|
||
months_with_day: None,
|
||
months_short: CJK_MONTHS,
|
||
days: ["日曜日", "月曜日", "火曜日", "水曜日", "木曜日", "金曜日", "土曜日"],
|
||
days_short: ["日", "月", "火", "水", "木", "金", "土"],
|
||
},
|
||
DateLanguage {
|
||
code: "zh-hans",
|
||
name: "简体中文",
|
||
months: ZH_MONTHS,
|
||
months_with_day: None,
|
||
months_short: CJK_MONTHS,
|
||
days: ZH_DAYS,
|
||
days_short: ["周日", "周一", "周二", "周三", "周四", "周五", "周六"],
|
||
},
|
||
DateLanguage {
|
||
code: "zh-hant",
|
||
name: "繁體中文",
|
||
months: ZH_MONTHS,
|
||
months_with_day: None,
|
||
months_short: CJK_MONTHS,
|
||
days: ZH_DAYS,
|
||
days_short: ["週日", "週一", "週二", "週三", "週四", "週五", "週六"],
|
||
},
|
||
DateLanguage {
|
||
code: "cs",
|
||
name: "Čeština",
|
||
months: ["leden", "únor", "březen", "duben", "květen", "červen", "červenec", "srpen", "září", "říjen", "listopad", "prosinec"],
|
||
months_with_day: Some([
|
||
"ledna",
|
||
"února",
|
||
"března",
|
||
"dubna",
|
||
"května",
|
||
"června",
|
||
"července",
|
||
"srpna",
|
||
"září",
|
||
"října",
|
||
"listopadu",
|
||
"prosince",
|
||
]),
|
||
months_short: ["led", "úno", "bře", "dub", "kvě", "čvn", "čvc", "srp", "zář", "říj", "lis", "pro"],
|
||
days: ["neděle", "pondělí", "úterý", "středa", "čtvrtek", "pátek", "sobota"],
|
||
days_short: ["ne", "po", "út", "st", "čt", "pá", "so"],
|
||
},
|
||
DateLanguage {
|
||
code: "pt-br",
|
||
name: "Português (Brasil)",
|
||
months: ["janeiro", "fevereiro", "março", "abril", "maio", "junho", "julho", "agosto", "setembro", "outubro", "novembro", "dezembro"],
|
||
months_with_day: None,
|
||
months_short: ["jan", "fev", "mar", "abr", "mai", "jun", "jul", "ago", "set", "out", "nov", "dez"],
|
||
days: ["domingo", "segunda-feira", "terça-feira", "quarta-feira", "quinta-feira", "sexta-feira", "sábado"],
|
||
days_short: ["dom", "seg", "ter", "qua", "qui", "sex", "sáb"],
|
||
},
|
||
DateLanguage {
|
||
code: "de",
|
||
name: "Deutsch",
|
||
months: ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"],
|
||
months_with_day: None,
|
||
months_short: ["Jan.", "Feb.", "März", "Apr.", "Mai", "Juni", "Juli", "Aug.", "Sept.", "Okt.", "Nov.", "Dez."],
|
||
days: ["Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag"],
|
||
days_short: ["So.", "Mo.", "Di.", "Mi.", "Do.", "Fr.", "Sa."],
|
||
},
|
||
DateLanguage {
|
||
code: "es",
|
||
name: "Español",
|
||
months: ["enero", "febrero", "marzo", "abril", "mayo", "junio", "julio", "agosto", "septiembre", "octubre", "noviembre", "diciembre"],
|
||
months_with_day: None,
|
||
months_short: ["ene", "feb", "mar", "abr", "may", "jun", "jul", "ago", "sept", "oct", "nov", "dic"],
|
||
days: ["domingo", "lunes", "martes", "miércoles", "jueves", "viernes", "sábado"],
|
||
days_short: ["dom", "lun", "mar", "mié", "jue", "vie", "sáb"],
|
||
},
|
||
DateLanguage {
|
||
code: "fr",
|
||
name: "Français",
|
||
months: ["janvier", "février", "mars", "avril", "mai", "juin", "juillet", "août", "septembre", "octobre", "novembre", "décembre"],
|
||
months_with_day: None,
|
||
months_short: ["janv.", "févr.", "mars", "avr.", "mai", "juin", "juil.", "août", "sept.", "oct.", "nov.", "déc."],
|
||
days: ["dimanche", "lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi"],
|
||
days_short: ["dim.", "lun.", "mar.", "mer.", "jeu.", "ven.", "sam."],
|
||
},
|
||
DateLanguage {
|
||
code: "ru",
|
||
name: "Русский",
|
||
months: ["январь", "февраль", "март", "апрель", "май", "июнь", "июль", "август", "сентябрь", "октябрь", "ноябрь", "декабрь"],
|
||
months_with_day: Some(["января", "февраля", "марта", "апреля", "мая", "июня", "июля", "августа", "сентября", "октября", "ноября", "декабря"]),
|
||
months_short: ["янв.", "февр.", "март", "апр.", "май", "июнь", "июль", "авг.", "сент.", "окт.", "нояб.", "дек."],
|
||
days: ["воскресенье", "понедельник", "вторник", "среда", "четверг", "пятница", "суббота"],
|
||
days_short: ["вс", "пн", "вт", "ср", "чт", "пт", "сб"],
|
||
},
|
||
DateLanguage {
|
||
code: "kk",
|
||
name: "Қазақша",
|
||
months: ["қаңтар", "ақпан", "наурыз", "сәуір", "мамыр", "маусым", "шілде", "тамыз", "қыркүйек", "қазан", "қараша", "желтоқсан"],
|
||
months_with_day: None,
|
||
months_short: ["қаң", "ақп", "нау", "сәу", "мам", "мау", "шіл", "там", "қыр", "қаз", "қар", "жел"],
|
||
days: ["жексенбі", "дүйсенбі", "сейсенбі", "сәрсенбі", "бейсенбі", "жұма", "сенбі"],
|
||
days_short: ["жк", "дс", "сс", "ср", "бс", "жм", "сб"],
|
||
},
|
||
DateLanguage {
|
||
code: "bg",
|
||
name: "Български",
|
||
months: ["януари", "февруари", "март", "април", "май", "юни", "юли", "август", "септември", "октомври", "ноември", "декември"],
|
||
months_with_day: None,
|
||
months_short: ["яну", "фев", "март", "апр", "май", "юни", "юли", "авг", "сеп", "окт", "ное", "дек"],
|
||
days: ["неделя", "понеделник", "вторник", "сряда", "четвъртък", "петък", "събота"],
|
||
days_short: ["нд", "пн", "вт", "ср", "чт", "пт", "сб"],
|
||
},
|
||
DateLanguage {
|
||
code: "te",
|
||
name: "తెలుగు",
|
||
months: ["జనవరి", "ఫిబ్రవరి", "మార్చి", "ఏప్రిల్", "మే", "జూన్", "జులై", "ఆగస్టు", "సెప్టెంబర్", "అక్టోబర్", "నవంబర్", "డిసెంబర్"],
|
||
months_with_day: None,
|
||
months_short: ["జన", "ఫిబ్ర", "మార్చి", "ఏప్రి", "మే", "జూన్", "జులై", "ఆగ", "సెప్టెం", "అక్టో", "నవం", "డిసెం"],
|
||
days: ["ఆదివారం", "సోమవారం", "మంగళవారం", "బుధవారం", "గురువారం", "శుక్రవారం", "శనివారం"],
|
||
days_short: ["ఆది", "సోమ", "మంగళ", "బుధ", "గురు", "శుక్ర", "శని"],
|
||
},
|
||
DateLanguage {
|
||
code: "hu",
|
||
name: "Magyar",
|
||
months: [
|
||
"január",
|
||
"február",
|
||
"március",
|
||
"április",
|
||
"május",
|
||
"június",
|
||
"július",
|
||
"augusztus",
|
||
"szeptember",
|
||
"október",
|
||
"november",
|
||
"december",
|
||
],
|
||
months_with_day: None,
|
||
months_short: ["jan.", "febr.", "márc.", "ápr.", "máj.", "jún.", "júl.", "aug.", "szept.", "okt.", "nov.", "dec."],
|
||
days: ["vasárnap", "hétfő", "kedd", "szerda", "csütörtök", "péntek", "szombat"],
|
||
days_short: ["V", "H", "K", "Sze", "Cs", "P", "Szo"],
|
||
},
|
||
DateLanguage {
|
||
code: "uk",
|
||
name: "Українська",
|
||
months: ["січень", "лютий", "березень", "квітень", "травень", "червень", "липень", "серпень", "вересень", "жовтень", "листопад", "грудень"],
|
||
months_with_day: Some([
|
||
"січня",
|
||
"лютого",
|
||
"березня",
|
||
"квітня",
|
||
"травня",
|
||
"червня",
|
||
"липня",
|
||
"серпня",
|
||
"вересня",
|
||
"жовтня",
|
||
"листопада",
|
||
"грудня",
|
||
]),
|
||
months_short: ["січ.", "лют.", "бер.", "квіт.", "трав.", "черв.", "лип.", "серп.", "вер.", "жовт.", "лист.", "груд."],
|
||
days: ["неділя", "понеділок", "вівторок", "середа", "четвер", "пʼятниця", "субота"],
|
||
days_short: ["нд", "пн", "вт", "ср", "чт", "пт", "сб"],
|
||
},
|
||
DateLanguage {
|
||
code: "it",
|
||
name: "Italiano",
|
||
months: ["gennaio", "febbraio", "marzo", "aprile", "maggio", "giugno", "luglio", "agosto", "settembre", "ottobre", "novembre", "dicembre"],
|
||
months_with_day: None,
|
||
months_short: ["gen", "feb", "mar", "apr", "mag", "giu", "lug", "ago", "set", "ott", "nov", "dic"],
|
||
days: ["domenica", "lunedì", "martedì", "mercoledì", "giovedì", "venerdì", "sabato"],
|
||
days_short: ["dom", "lun", "mar", "mer", "gio", "ven", "sab"],
|
||
},
|
||
// Arabic has no shorter written forms of its month and weekday names.
|
||
DateLanguage {
|
||
code: "ar",
|
||
name: "العربية",
|
||
months: AR_MONTHS,
|
||
months_with_day: None,
|
||
months_short: AR_MONTHS,
|
||
days: AR_DAYS,
|
||
days_short: AR_DAYS,
|
||
},
|
||
DateLanguage {
|
||
code: "pl",
|
||
name: "Polski",
|
||
months: ["styczeń", "luty", "marzec", "kwiecień", "maj", "czerwiec", "lipiec", "sierpień", "wrzesień", "październik", "listopad", "grudzień"],
|
||
months_with_day: Some([
|
||
"stycznia",
|
||
"lutego",
|
||
"marca",
|
||
"kwietnia",
|
||
"maja",
|
||
"czerwca",
|
||
"lipca",
|
||
"sierpnia",
|
||
"września",
|
||
"października",
|
||
"listopada",
|
||
"grudnia",
|
||
]),
|
||
months_short: ["sty", "lut", "mar", "kwi", "maj", "cze", "lip", "sie", "wrz", "paź", "lis", "gru"],
|
||
days: ["niedziela", "poniedziałek", "wtorek", "środa", "czwartek", "piątek", "sobota"],
|
||
days_short: ["niedz.", "pon.", "wt.", "śr.", "czw.", "pt.", "sob."],
|
||
},
|
||
DateLanguage {
|
||
code: "sl",
|
||
name: "Slovenščina",
|
||
months: ["januar", "februar", "marec", "april", "maj", "junij", "julij", "avgust", "september", "oktober", "november", "december"],
|
||
months_with_day: Some([
|
||
"januarja",
|
||
"februarja",
|
||
"marca",
|
||
"aprila",
|
||
"maja",
|
||
"junija",
|
||
"julija",
|
||
"avgusta",
|
||
"septembra",
|
||
"oktobra",
|
||
"novembra",
|
||
"decembra",
|
||
]),
|
||
months_short: ["jan.", "feb.", "mar.", "apr.", "maj", "jun.", "jul.", "avg.", "sep.", "okt.", "nov.", "dec."],
|
||
days: ["nedelja", "ponedeljek", "torek", "sreda", "četrtek", "petek", "sobota"],
|
||
days_short: ["ned.", "pon.", "tor.", "sre.", "čet.", "pet.", "sob."],
|
||
},
|
||
DateLanguage {
|
||
code: "el",
|
||
name: "Ελληνικά",
|
||
months: [
|
||
"Ιανουάριος",
|
||
"Φεβρουάριος",
|
||
"Μάρτιος",
|
||
"Απρίλιος",
|
||
"Μάιος",
|
||
"Ιούνιος",
|
||
"Ιούλιος",
|
||
"Αύγουστος",
|
||
"Σεπτέμβριος",
|
||
"Οκτώβριος",
|
||
"Νοέμβριος",
|
||
"Δεκέμβριος",
|
||
],
|
||
months_with_day: Some([
|
||
"Ιανουαρίου",
|
||
"Φεβρουαρίου",
|
||
"Μαρτίου",
|
||
"Απριλίου",
|
||
"Μαΐου",
|
||
"Ιουνίου",
|
||
"Ιουλίου",
|
||
"Αυγούστου",
|
||
"Σεπτεμβρίου",
|
||
"Οκτωβρίου",
|
||
"Νοεμβρίου",
|
||
"Δεκεμβρίου",
|
||
]),
|
||
months_short: ["Ιαν", "Φεβ", "Μαρ", "Απρ", "Μάι", "Ιουν", "Ιουλ", "Αυγ", "Σεπ", "Οκτ", "Νοε", "Δεκ"],
|
||
days: ["Κυριακή", "Δευτέρα", "Τρίτη", "Τετάρτη", "Πέμπτη", "Παρασκευή", "Σάββατο"],
|
||
days_short: ["Κυρ", "Δευ", "Τρί", "Τετ", "Πέμ", "Παρ", "Σάβ"],
|
||
},
|
||
DateLanguage {
|
||
code: "tr",
|
||
name: "Türkçe",
|
||
months: ["Ocak", "Şubat", "Mart", "Nisan", "Mayıs", "Haziran", "Temmuz", "Ağustos", "Eylül", "Ekim", "Kasım", "Aralık"],
|
||
months_with_day: None,
|
||
months_short: ["Oca", "Şub", "Mar", "Nis", "May", "Haz", "Tem", "Ağu", "Eyl", "Eki", "Kas", "Ara"],
|
||
days: ["Pazar", "Pazartesi", "Salı", "Çarşamba", "Perşembe", "Cuma", "Cumartesi"],
|
||
days_short: ["Paz", "Pzt", "Sal", "Çar", "Per", "Cum", "Cmt"],
|
||
},
|
||
DateLanguage {
|
||
code: "lt",
|
||
name: "Lietuvių",
|
||
months: ["sausis", "vasaris", "kovas", "balandis", "gegužė", "birželis", "liepa", "rugpjūtis", "rugsėjis", "spalis", "lapkritis", "gruodis"],
|
||
months_with_day: Some([
|
||
"sausio",
|
||
"vasario",
|
||
"kovo",
|
||
"balandžio",
|
||
"gegužės",
|
||
"birželio",
|
||
"liepos",
|
||
"rugpjūčio",
|
||
"rugsėjo",
|
||
"spalio",
|
||
"lapkričio",
|
||
"gruodžio",
|
||
]),
|
||
months_short: ["saus.", "vas.", "kov.", "bal.", "geg.", "birž.", "liep.", "rugpj.", "rugs.", "spal.", "lapkr.", "gruod."],
|
||
days: ["sekmadienis", "pirmadienis", "antradienis", "trečiadienis", "ketvirtadienis", "penktadienis", "šeštadienis"],
|
||
days_short: ["sk", "pr", "an", "tr", "kt", "pn", "št"],
|
||
},
|
||
// 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.
|
||
DateLanguage {
|
||
code: "vi",
|
||
name: "Tiếng Việt",
|
||
months: [
|
||
"Tháng 1",
|
||
"Tháng 2",
|
||
"Tháng 3",
|
||
"Tháng 4",
|
||
"Tháng 5",
|
||
"Tháng 6",
|
||
"Tháng 7",
|
||
"Tháng 8",
|
||
"Tháng 9",
|
||
"Tháng 10",
|
||
"Tháng 11",
|
||
"Tháng 12",
|
||
],
|
||
months_with_day: Some([
|
||
"tháng 1",
|
||
"tháng 2",
|
||
"tháng 3",
|
||
"tháng 4",
|
||
"tháng 5",
|
||
"tháng 6",
|
||
"tháng 7",
|
||
"tháng 8",
|
||
"tháng 9",
|
||
"tháng 10",
|
||
"tháng 11",
|
||
"tháng 12",
|
||
]),
|
||
months_short: ["Thg 1", "Thg 2", "Thg 3", "Thg 4", "Thg 5", "Thg 6", "Thg 7", "Thg 8", "Thg 9", "Thg 10", "Thg 11", "Thg 12"],
|
||
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"],
|
||
},
|
||
];
|
||
|
||
/// The date language for `code` (any case), or `None` when there's none.
|
||
pub fn date_language(code: &str) -> Option<&'static DateLanguage> {
|
||
DATE_LANGUAGES.iter().find(|l| l.code.eq_ignore_ascii_case(code.trim()))
|
||
}
|
||
|
||
/// The characters of `text` that Fill & Sign can't write into a PDF yet, each once. Its text is
|
||
/// drawn in Helvetica with WinAnsi encoding (Western European letters), so anything else (`ř`,
|
||
/// Japanese, Chinese) would become `?` in the file.
|
||
pub fn unwritable(text: &str) -> String {
|
||
let mut out = String::new();
|
||
for c in text.chars() {
|
||
let mut buf = [0u8; 4];
|
||
if c != '?' && pdfcraft_fonts::win_ansi(c.encode_utf8(&mut buf)) == b"?" && !out.contains(c) {
|
||
out.push(c);
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// `fmt` trimmed, when it's a usable date pattern: not too long, showing at least a year,
|
||
/// month or day, and no time letters (`H h M s t`) unless escaped with `\`.
|
||
pub fn check_date_format(fmt: &str) -> Result<&str, String> {
|
||
let fmt = fmt.trim();
|
||
if fmt.is_empty() {
|
||
return Err("the date format is empty".into());
|
||
}
|
||
if fmt.chars().count() > MAX_DATE_FORMAT_CHARS {
|
||
return Err(format!("the date format is longer than {MAX_DATE_FORMAT_CHARS} characters"));
|
||
}
|
||
let letters: String = unescaped(fmt).filter_map(|(c, escaped)| (!escaped).then_some(c)).collect();
|
||
if let Some(c) = letters.chars().find(|c| matches!(c, 'H' | 'h' | 'M' | 's' | 't')) {
|
||
return Err(format!("`{c}` is a time letter (H h M s t); put \\ before it to show it as is"));
|
||
}
|
||
if !letters.chars().any(|c| matches!(c, 'y' | 'm' | 'd')) {
|
||
return Err("the date format shows no year (y), month (m) or day (d)".into());
|
||
}
|
||
// Only whole codes: `yyy` or `mmmmm` would silently show something else.
|
||
for (c, n, _) in runs(fmt).into_iter().filter(|r| !r.2) {
|
||
let fits = match c {
|
||
'y' => n == 2 || n == 4,
|
||
'm' | 'd' => (1..=4).contains(&n),
|
||
_ => true,
|
||
};
|
||
if !fits {
|
||
let code: String = std::iter::repeat_n(c, n).collect();
|
||
let use_ = match c {
|
||
'y' => "yy or yyyy",
|
||
'm' => "m, mm, mmm or mmmm",
|
||
_ => "d, dd, ddd or dddd",
|
||
};
|
||
return Err(format!("`{code}` is not a date code; use {use_}"));
|
||
}
|
||
}
|
||
Ok(fmt)
|
||
}
|
||
|
||
/// `fmt` as runs of the same character with whether it's escaped: `dd.\mm` → (`d`, 2, no),
|
||
/// (`.`, 1, no), (`m`, 1, yes), (`m`, 1, no). Escaped characters are never grouped.
|
||
fn runs(fmt: &str) -> Vec<(char, usize, bool)> {
|
||
let mut runs: Vec<(char, usize, bool)> = Vec::new();
|
||
for (c, escaped) in unescaped(fmt) {
|
||
match runs.last_mut() {
|
||
Some((last, n, false)) if !escaped && *last == c => *n += 1,
|
||
_ => runs.push((c, 1, escaped)),
|
||
}
|
||
}
|
||
runs
|
||
}
|
||
|
||
/// The day `(year, month, day)` written in `fmt` with `lang`'s names. Anything but the date
|
||
/// letters is copied as is; a month or day out of range shows as its number.
|
||
pub fn format_day(fmt: &str, (y, m, d): (i64, u32, u32), lang: &DateLanguage) -> String {
|
||
let runs = runs(fmt);
|
||
let with_day = runs.iter().any(|&(c, n, escaped)| !escaped && c == 'd' && n <= 2);
|
||
let month = (m as usize).checked_sub(1);
|
||
let weekday = weekday(y, m, d);
|
||
let mut out = String::new();
|
||
for (c, n, escaped) in runs {
|
||
match (c, n) {
|
||
_ if escaped => out.push(c),
|
||
('y', 4..) => out.push_str(&format!("{:04}", y.clamp(0, 9999))),
|
||
('y', _) => out.push_str(&format!("{:02}", y.rem_euclid(100))),
|
||
('m', 3..) => {
|
||
let names = if n >= 4 { lang.months_with_day.filter(|_| with_day).unwrap_or(lang.months) } else { lang.months_short };
|
||
match month.and_then(|i| names.get(i)) {
|
||
Some(name) => out.push_str(name),
|
||
None => out.push_str(&m.to_string()),
|
||
}
|
||
}
|
||
('m', 2) => out.push_str(&format!("{m:02}")),
|
||
('m', _) => out.push_str(&m.to_string()),
|
||
('d', 3..) => {
|
||
let names = if n >= 4 { lang.days } else { lang.days_short };
|
||
match weekday.and_then(|w| names.get(w)) {
|
||
Some(name) => out.push_str(name),
|
||
None => out.push_str(&d.to_string()),
|
||
}
|
||
}
|
||
('d', 2) => out.push_str(&format!("{d:02}")),
|
||
('d', _) => out.push_str(&d.to_string()),
|
||
_ => out.extend(std::iter::repeat_n(c, n)),
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// The weekday (0 = Sunday) of a Gregorian date in years 1–9999, or `None` for an invalid one.
|
||
fn weekday(y: i64, m: u32, d: u32) -> Option<usize> {
|
||
const T: [i64; 12] = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4];
|
||
if !(1..=9999).contains(&y) || !(1..=31).contains(&d) {
|
||
return None;
|
||
}
|
||
let t = *T.get((m as usize).checked_sub(1)?)?;
|
||
let y = y - i64::from(m < 3);
|
||
Some((y + y / 4 - y / 100 + y / 400 + t + i64::from(d)).rem_euclid(7) as usize)
|
||
}
|
||
|
||
/// The characters of `fmt`, each with whether a `\` before it makes it literal.
|
||
fn unescaped(fmt: &str) -> impl Iterator<Item = (char, bool)> + '_ {
|
||
let mut chars = fmt.chars();
|
||
std::iter::from_fn(move || match chars.next()? {
|
||
'\\' => Some(chars.next().map_or(('\\', true), |c| (c, true))),
|
||
c => Some((c, false)),
|
||
})
|
||
}
|
||
|
||
impl Session {
|
||
/// Preferences ▸ Date format: the pattern Fill & Sign dates use. Rejects unusable patterns
|
||
/// (see [`check_date_format`]) and keeps the previous one.
|
||
pub fn set_date_format(&mut self, fmt: &str) -> Result<(), String> {
|
||
let fmt = check_date_format(fmt)?;
|
||
self.date_format = (fmt != DEFAULT_DATE_FORMAT).then(|| fmt.to_string());
|
||
Ok(())
|
||
}
|
||
|
||
pub fn date_format(&self) -> &str {
|
||
self.date_format.as_deref().unwrap_or(DEFAULT_DATE_FORMAT)
|
||
}
|
||
|
||
/// Preferences ▸ Date format ▸ Language: the language of month and weekday names, or
|
||
/// `None` to follow the interface language. Rejects codes not in [`DATE_LANGUAGES`].
|
||
pub fn set_date_language(&mut self, code: Option<&str>) -> Result<(), String> {
|
||
self.date_language = match code {
|
||
None => None,
|
||
Some(c) => Some(date_language(c).map(|l| l.code.to_string()).ok_or_else(|| {
|
||
let codes: Vec<&str> = DATE_LANGUAGES.iter().map(|l| l.code).collect();
|
||
format!("unknown date language {c:?} (use one of {})", codes.join(", "))
|
||
})?),
|
||
};
|
||
Ok(())
|
||
}
|
||
|
||
pub fn date_language(&self) -> Option<&str> {
|
||
self.date_language.as_deref()
|
||
}
|
||
|
||
/// Today's local date in `fmt` (or the session's date format) with the names of `lang`
|
||
/// (or the session's date language; English when neither is set or known).
|
||
pub fn today_text(&self, fmt: Option<&str>, lang: Option<&str>) -> Result<String, String> {
|
||
let fmt = match fmt {
|
||
Some(f) => check_date_format(f)?,
|
||
None => self.date_format(),
|
||
};
|
||
let lang = lang.or(self.date_language()).and_then(date_language).unwrap_or(&DATE_LANGUAGES[0]);
|
||
Ok(format_day(fmt, self.today(), lang))
|
||
}
|
||
|
||
/// [`Session::today_text`] for writing into a PDF: refused when the date has characters
|
||
/// Fill & Sign can't write yet (see [`unwritable`]), rather than saving them as `?`.
|
||
pub fn today_text_for_pdf(&self, fmt: Option<&str>, lang: Option<&str>) -> Result<String, String> {
|
||
let text = self.today_text(fmt, lang)?;
|
||
match unwritable(&text) {
|
||
bad if bad.is_empty() => Ok(text),
|
||
bad => Err(format!(
|
||
"\"{text}\" can't be written into the PDF yet: Fill & Sign text is Western European only ({bad}); pick a numeric format such as dd.mm.yyyy"
|
||
)),
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
const DAY: (i64, u32, u32) = (2026, 3, 7);
|
||
const EN: &DateLanguage = &DATE_LANGUAGES[0];
|
||
|
||
fn in_lang(fmt: &str, code: &str) -> String {
|
||
format_day(fmt, DAY, date_language(code).unwrap())
|
||
}
|
||
|
||
#[test]
|
||
fn presets_write_the_day_in_their_own_order() {
|
||
let shown: Vec<String> = DATE_FORMATS.iter().map(|f| format_day(f, DAY, EN)).collect();
|
||
assert_eq!(
|
||
shown,
|
||
[
|
||
"3/7/2026",
|
||
"03/07/2026",
|
||
"7/3/2026",
|
||
"07/03/2026",
|
||
"07.03.2026",
|
||
"2026-03-07",
|
||
"2026.03.07.",
|
||
"7 Mar 2026",
|
||
"7 March 2026",
|
||
"Mar 7, 2026",
|
||
"March 7, 2026",
|
||
"Saturday, March 7, 2026",
|
||
]
|
||
);
|
||
assert!(DATE_FORMATS.iter().all(|f| check_date_format(f).is_ok()));
|
||
assert_eq!(DATE_FORMATS[0], DEFAULT_DATE_FORMAT);
|
||
}
|
||
|
||
#[test]
|
||
fn escaped_letters_are_shown_as_is() {
|
||
assert_eq!(format_day("d \\de mmmm \\de yyyy", DAY, EN), "7 de March de 2026");
|
||
assert_eq!(format_day("yyyy\\", DAY, EN), "2026\\");
|
||
assert_eq!(check_date_format("d \\de mmmm"), Ok("d \\de mmmm"));
|
||
}
|
||
|
||
#[test]
|
||
fn unusable_patterns_are_rejected() {
|
||
assert!(check_date_format(" ").is_err());
|
||
assert!(check_date_format("Date: dd/mm").unwrap_err().contains("`t`"));
|
||
assert!(check_date_format("mm/dd HH:MM").is_err());
|
||
assert!(check_date_format("Year").is_err(), "no date letters");
|
||
assert!(check_date_format(&"d".repeat(MAX_DATE_FORMAT_CHARS + 1)).is_err());
|
||
assert_eq!(check_date_format(" yyyy-mm-dd "), Ok("yyyy-mm-dd"));
|
||
}
|
||
|
||
#[test]
|
||
fn only_whole_date_codes_are_accepted() {
|
||
for (fmt, code) in [("yyyyyyyy", "`yyyyyyyy`"), ("yyy", "`yyy`"), ("d/m/y", "`y`"), ("mmmmm d", "`mmmmm`"), ("ddddd", "`ddddd`")] {
|
||
let e = check_date_format(fmt).unwrap_err();
|
||
assert!(e.contains(code) && e.contains("is not a date code"), "{fmt}: {e}");
|
||
}
|
||
for fmt in ["yy", "yyyy", "m", "mm", "mmm", "mmmm", "d", "dd", "ddd", "dddd", r"\y\y\y yyyy"] {
|
||
assert!(check_date_format(fmt).is_ok(), "{fmt}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn hostile_days_and_patterns_do_not_panic() {
|
||
for fmt in ["mmmm", "mmm", "dddd", "ddd", "\\", "yyyyyyyyy", "é d ü", "d. mmmm"] {
|
||
for day in [(i64::MAX, u32::MAX, u32::MAX), (i64::MIN, 0, 0), (2026, 13, 40), (0, 2, 29)] {
|
||
for lang in &DATE_LANGUAGES {
|
||
let _ = format_day(fmt, day, lang);
|
||
}
|
||
}
|
||
}
|
||
assert_eq!(format_day("mmmm dddd", (2026, 13, 40), EN), "13 40", "out of range shows numbers");
|
||
}
|
||
|
||
#[test]
|
||
fn the_session_stamps_today_in_its_format() {
|
||
// 2023-11-14 (UTC) on the injected clock.
|
||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||
assert_eq!(s.date_format(), DEFAULT_DATE_FORMAT);
|
||
assert_eq!(s.today_text(None, None).as_deref(), Ok("11/14/2023"));
|
||
s.set_date_format("dd.mm.yyyy").unwrap();
|
||
assert_eq!(s.today_text(None, None).as_deref(), Ok("14.11.2023"));
|
||
assert_eq!(s.today_text(Some("yyyy-mm-dd"), None).as_deref(), Ok("2023-11-14"));
|
||
assert!(s.today_text(Some("HH"), None).is_err());
|
||
assert!(s.set_date_format("nothing").is_err());
|
||
assert_eq!(s.date_format(), "dd.mm.yyyy", "a rejected pattern keeps the previous one");
|
||
}
|
||
|
||
#[test]
|
||
fn names_follow_the_date_language() {
|
||
assert_eq!(in_lang("dddd, d \\de mmmm \\de yyyy", "es"), "sábado, 7 de marzo de 2026");
|
||
assert_eq!(in_lang("ddd d mmm", "pt-br"), "sáb 7 mar");
|
||
assert_eq!(in_lang("dddd d mmmm yyyy", "fr"), "samedi 7 mars 2026");
|
||
assert_eq!(in_lang("dddd d mmmm yyyy", "it"), "sabato 7 marzo 2026");
|
||
assert_eq!(in_lang("ddd d mmm", "it"), "sab 7 mar");
|
||
assert_eq!(in_lang("dddd, d mmmm", "te"), "శనివారం, 7 మార్చి");
|
||
assert_eq!(in_lang("dddd, d mmmm", "ar"), "السبت, 7 مارس");
|
||
assert_eq!(in_lang("yyyy\\年mmmmd\\日 dddd", "ja"), "2026年3月7日 土曜日");
|
||
assert_eq!(in_lang("yyyy\\年mmmmd\\日 dddd", "zh-hans"), "2026年三月7日 星期六");
|
||
assert_eq!(in_lang("ddd", "zh-hant"), "週六");
|
||
assert_eq!(date_language("PT-BR").map(|l| l.code), Some("pt-br"));
|
||
assert!(date_language("xx").is_none());
|
||
for l in &DATE_LANGUAGES {
|
||
assert!(l.months.iter().chain(&l.months_short).chain(&l.days).chain(&l.days_short).all(|n| !n.is_empty()), "{}", l.code);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn czech_and_russian_months_change_after_a_day() {
|
||
assert_eq!(in_lang("d. mmmm yyyy", "cs"), "7. března 2026");
|
||
assert_eq!(in_lang("mmmm yyyy", "cs"), "březen 2026");
|
||
assert_eq!(in_lang("d mmmm yyyy", "ru"), "7 марта 2026");
|
||
assert_eq!(in_lang("mmmm yyyy", "ru"), "март 2026");
|
||
// A weekday (dddd) is not a day number.
|
||
assert_eq!(in_lang("dddd, mmmm", "cs"), "sobota, březen");
|
||
}
|
||
|
||
#[test]
|
||
fn the_session_writes_names_in_its_date_language() {
|
||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||
s.set_date_format("dddd d mmmm").unwrap();
|
||
assert_eq!(s.today_text(None, None).as_deref(), Ok("Tuesday 14 November"), "English by default");
|
||
assert_eq!(s.today_text(None, Some("es")).as_deref(), Ok("martes 14 noviembre"), "a caller's language");
|
||
s.set_date_language(Some("PT-BR")).unwrap();
|
||
assert_eq!(s.date_language(), Some("pt-br"));
|
||
assert_eq!(s.today_text(None, None).as_deref(), Ok("terça-feira 14 novembro"));
|
||
assert_eq!(s.today_text(Some("d mmmm"), Some("uk")).as_deref(), Ok("14 листопада"), "Ukrainian months after a day");
|
||
assert_eq!(s.today_text(Some("dddd d mmmm"), Some("de")).as_deref(), Ok("Dienstag 14 November"));
|
||
assert!(s.set_date_language(Some("xx")).unwrap_err().contains("zh-hant"), "only the interface languages");
|
||
assert_eq!(s.date_language(), Some("pt-br"), "an unknown language keeps the previous one");
|
||
s.set_date_language(Some("LT")).unwrap();
|
||
assert_eq!(s.date_language(), Some("lt"));
|
||
assert_eq!(s.today_text(Some("dddd d mmmm"), None).as_deref(), Ok("antradienis 14 lapkričio"));
|
||
s.set_date_language(Some("IT")).unwrap();
|
||
assert_eq!(s.date_language(), Some("it"));
|
||
assert_eq!(s.today_text_for_pdf(None, None).as_deref(), Ok("martedì 14 novembre"), "Italian names and accents can be written into a PDF");
|
||
s.set_date_language(None).unwrap();
|
||
assert_eq!(s.date_language(), None);
|
||
}
|
||
|
||
#[test]
|
||
fn vietnamese_month_and_weekday_names() {
|
||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||
s.set_date_language(Some("vi")).unwrap();
|
||
assert_eq!(s.date_language(), Some("vi"));
|
||
s.set_date_format("d mmmm yyyy").unwrap();
|
||
assert_eq!(s.today_text(None, None).as_deref(), Ok("14 tháng 11 2023"));
|
||
s.set_date_format("mmmm yyyy").unwrap();
|
||
assert_eq!(s.today_text(None, None).as_deref(), Ok("Tháng 11 2023"));
|
||
s.set_date_format("ddd, d mmm").unwrap();
|
||
assert_eq!(s.today_text(None, None).as_deref(), Ok("T3, 14 Thg 11"));
|
||
// Encoding follows the spelled text: `14 tháng 11 2023` is WinAnsi-representable;
|
||
// a weekday name such as `Thứ Ba` is not, so Fill & Sign refuses that format.
|
||
assert_eq!(s.today_text_for_pdf(Some("d mmmm yyyy"), Some("vi")).as_deref(), Ok("14 tháng 11 2023"));
|
||
let e = s.today_text_for_pdf(Some("dddd d mmmm yyyy"), Some("vi")).unwrap_err();
|
||
assert!(e.contains("can't be written into the PDF yet") && e.contains("dd.mm.yyyy"), "{e}");
|
||
assert!(s.today_text_for_pdf(Some("dd.mm.yyyy"), Some("vi")).is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn dates_the_pdf_cannot_hold_are_refused_not_damaged() {
|
||
assert_eq!(unwritable("8. října 2026"), "ř");
|
||
assert_eq!(unwritable("2026年10月8日"), "年月日");
|
||
assert_eq!(unwritable("sábado, 7 de março – 2026?"), "", "Western European, dashes and ? are fine");
|
||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||
s.set_date_format("d. mmmm yyyy").unwrap();
|
||
assert_eq!(s.today_text_for_pdf(None, Some("es")).as_deref(), Ok("14. noviembre 2023"));
|
||
assert_eq!(s.today_text_for_pdf(None, Some("cs")).as_deref(), Ok("14. listopadu 2023"), "no letter outside WinAnsi");
|
||
let e = s.today_text_for_pdf(Some("dddd"), Some("ja")).unwrap_err();
|
||
assert!(e.contains("火曜日") && e.contains("can't be written into the PDF yet"), "{e}");
|
||
assert!(s.today_text_for_pdf(Some("dd.mm.yyyy"), Some("ja")).is_ok(), "numbers are fine in any language");
|
||
assert!(s.today_text_for_pdf(Some("yyyy\\年mm\\月"), Some("ja")).unwrap_err().contains("年月"));
|
||
}
|
||
}
|