Files
pdfcraft/crates/engine/src/dates.rs
T
sanchesandClaude Sonnet 5.5 644348dd0f i18n: add Hindi and Korean interfaces
Two complete catalogs, hi and ko, 2,578 entries each (every
(context, English) key the bundled catalogs hold), registered in
LANGUAGES (24 -> 26) and in the date languages (hi, ko). Both use a
single @plural form.

Korean has no Hangul face in craft-fonts, so desktop builds draw it
with one installed face (Malgun Gothic, Apple SD Gothic Neo, Nanum;
never Noto CJK / Source Han), added after the embedded faces like the
Devanagari fallback. PDFCRAFT_SYSTEM_FONTS=0 turns it off.

Tests: catalog registration and persistence, command / All tools /
tl!() literal coverage for both, date names, Hangul fallback and
candidate list. cargo xtask assets, fmt and clippy are clean. Also
refreshes the ru.tsv SHA-256 in ATTRIBUTION.toml and the docs.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 00:07:07 +03:00

804 lines
38 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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; 26] = [
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"],
},
// 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: the month name is `tháng Một` with a day number and `Tháng Một` standing
// alone, as in English.
DateLanguage {
code: "vi",
name: "Tiếng Việt",
months: [
"Tháng Một",
"Tháng Hai",
"Tháng Ba",
"Tháng Tư",
"Tháng Năm",
"Tháng Sáu",
"Tháng Bảy",
"Tháng Tám",
"Tháng Chín",
"Tháng Mười",
"Tháng Mười Một",
"Tháng Mười Hai",
],
months_with_day: Some([
"tháng Một",
"tháng Hai",
"tháng Ba",
"tháng Tư",
"tháng Năm",
"tháng Sáu",
"tháng Bảy",
"tháng Tám",
"tháng Chín",
"tháng Mười",
"tháng Mười Một",
"tháng Mười Hai",
]),
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"],
},
// 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.
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");
}
/// Indonesian month names are the same with and after a day number; Vietnamese lowers `tháng`
/// after a day number and keeps it capitalised standing alone.
#[test]
fn indonesian_and_vietnamese_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");
assert_eq!(in_lang("d mmmm yyyy", "vi"), "7 tháng Ba 2026");
assert_eq!(in_lang("mmmm yyyy", "vi"), "Tháng Ba 2026");
assert_eq!(in_lang("ddd, d mmm", "vi"), "T7, 7 Thg 3");
// Indonesian month names are Latin, so they reach a PDF; Vietnamese ones are not Western
// European, and the date-text path refuses them with what to use instead.
let mut s = Session::new().with_clock(|| 1_700_000_000);
s.set_date_language(Some("vi")).unwrap();
assert!(s.today_text_for_pdf(Some("d mmmm yyyy"), None).unwrap_err().contains("dd.mm.yyyy"));
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);
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 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("年月"));
}
}