mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-11 05:18:40 +00:00
* Forms: date fields name months in the date language, not always English (#5303) `AFDate_FormatEx("dd-mmm-yy")` wrote "18-Oct-26" in an Italian interface, where a localized Acrobat writes "18-ott-26". Form date formats, `util.printd` and `util.scand` now take their month and weekday names from the date language (Preferences > Date format > Language, which follows the interface language until one is picked), and still read English names typed in. The names are process-wide, so their test runs in its own binary. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Forms: dates read back in every date language (#5303) Trying every date language showed what a field wrote not always reading back: names holding digits ("10月"), spaces ("tháng 10"), dots ("ม.ค.") or combining vowel signs (Thai, Hindi, Telugu) fell apart word by word, and Italian, Spanish, Romanian and Albanian share some short weekday and month names ("mar"). The language's names are now found whole in the text first, the longest at each place, and read as their English names; the format's order of `mmm` and `ddd` settles a shared name. Both AFDate parsing and util.scand do this, on text up to 1 KB. A test reads back every language, each name format, and hostile text. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Forms: date names no field font can draw are drawn in English (#5303) With the names in the date language, a Russian dd-mmm-yy field drawn in Helvetica showed "18-????.-26": no /DR font held "окт." and the WinAnsi stand-in drew each letter as ?. The same held for Japanese, Chinese, Greek, Arabic, Thai and others, and for Latin names outside WinAnsi (Czech "říj"). Before, the names were English and always drew. An AFDate/AFTime field now formats its value again with English names for a widget whose fonts can't draw the localized text; a custom Format script is run again with util.printd writing English names for such a widget, and its result kept only when it draws whole. A field whose font holds the names keeps them, and scripts otherwise still print the date language. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Forms: an English weekday before the month keeps the month (#5303) With Italian dates, "Tue 17 Mar 2026" in a "ddd d mmm yyyy" field read as no date: the language's names were found in the text, but not English ones, so "Tue" left the format's weekday unfilled and "Mar", which Italian shares between Tuesday and March, was taken as the weekday. Both AFDate parsing and util.scand lost the month, and Spanish, Romanian and Albanian ("mar") did the same. English names are now found too and fill their field of the format as localized ones do. A test reads English and mixed weekdays before the month in those languages. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Forms: an English date run is drawn even when it prints the value (#5303) A Helvetica field storing "18-Oct-26" whose Format script prints it again as dd-mmm-yy still showed "18-???.-26" in Russian. The English run printed "18-Oct-26", but it was dropped for equalling the stored value, which was how a failed run was told apart: a failing script handed back the value unchanged. The English run now says whether it ran (`None` when the script failed or the runner can't write English dates), and what it made is drawn whenever it draws whole. A test reprints the stored value under Russian, and checks that a failing script keeps its text and is reported undrawn. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Forms: a short weekday typed without its dot is the weekday (#5303) With French dates, "mar 6 janv 2026" in a "ddd d mmm yyyy" field read as no date through util.scand, and "mar 06/01/2026" in "ddd dd/mm/yyyy" through AFDate too. French writes Tuesday as "mar.", which a name search only found with its dot; since English names are found as well, the English "Mar" (March) was the only name of that length, so the weekday became a month. Romanian "mar" did the same. A short name ending in a dot is now also found typed without it, as the same kind of name, so the format's order of name fields decides between it and an equal name of the other kind. Both copies of the name search change. A test reads French and Romanian weekdays typed without their dot. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Forms: a shared name is the month when no weekday is typed (#5303) With Italian dates, "17 mar 2026" in a "ddd d mmm yyyy" field read as no date, nor did "mar 17, 2026" in "dddd, mmmm d, yyyy". AFDate leaves weekdays out of what it reads (they are shown, not typed), but the name search still expected the format's weekday first, so "mar", which Italian shares between Tuesday and March, filled it and left no month. Spanish and Albanian "mar" and Albanian "sht" (Saturday, September) did the same, and the keystroke check refused those dates. Where such a read finds no date, the names are now read again with no weekday expected, so the shared name is the month; a typed weekday still comes first ("mar 17 mar 2026"). util.scand reads its fields in order and needs the weekday, so it is unchanged. A test types those dates with the weekday left out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Forms: Turkish names typed in capitals are read (#5303) With Turkish dates, "1 MAYIS 2026" and "1 EKİM 2026" in a d mmmm yyyy field read as no date through AFDate and util.scand, and "SALI 6 OCAK 2026" through util.scand. Names were compared lower-cased, which turns I into i, not the dotless ı of "Mayıs" and "Salı", and İ into i with a combining dot above, which "Ekim" doesn't have. Names and typed text are now folded alike, the capital I and the dotted and dotless i all as i and the combining dot dropped, one piece for each char so positions in the text stay aligned: in both copies of the name search, the AFDate month reader and util.scand's name matcher. English reads as before. A test types Turkish names in capitals and English ones as a check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Forms: Greek names typed in capitals are read again (#5303) Folding names char by char turned a capital Σ into σ, so "ΙΑΝΟΥΆΡΙΟΣ" no longer matched "Ιανουάριος", whose last letter is the final ς. The fold now makes ς a σ on both sides, in the forms and JS parsers alike. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
714 lines
30 KiB
Rust
714 lines
30 KiB
Rust
use super::*;
|
||
|
||
/// Acrobat's AFDate_Format(n) / AFTime_Format(n) presets.
|
||
pub const DATE_PRESETS: [&str; 14] = [
|
||
"m/d",
|
||
"m/d/yy",
|
||
"mm/dd/yy",
|
||
"mm/yy",
|
||
"d-mmm",
|
||
"d-mmm-yy",
|
||
"dd-mmm-yy",
|
||
"yy-mm-dd",
|
||
"mmm-yy",
|
||
"mmmm-yy",
|
||
"mmm d, yyyy",
|
||
"mmmm d, yyyy",
|
||
"m/d/yy h:MM tt",
|
||
"m/d/yy HH:MM",
|
||
];
|
||
|
||
pub const TIME_PRESETS: [&str; 4] = ["HH:MM", "h:MM tt", "HH:MM:ss", "h:MM:ss tt"];
|
||
|
||
// ── running them ────────────────────────────────────────────────────────────────────────────
|
||
|
||
/// AFMakeNumber: a number from typed text (currency symbols, group separators, spaces and a
|
||
/// trailing % ignored; a comma decimal separator accepted).
|
||
pub fn make_number(s: &str) -> Option<f64> {
|
||
let t: String = s.trim().chars().filter(|c| !c.is_whitespace() && !matches!(c, '$' | '€' | '£' | '¥' | '\'' | '%')).collect();
|
||
if t.is_empty() {
|
||
return None;
|
||
}
|
||
let neg = t.starts_with('(') && t.ends_with(')');
|
||
let t = t.trim_start_matches('(').trim_end_matches(')');
|
||
// "1.234,56" or "1234,56": comma is the decimal separator when it comes last.
|
||
let normalized = match (t.rfind(','), t.rfind('.')) {
|
||
(Some(c), Some(d)) if c > d => t.replace('.', "").replace(',', "."),
|
||
(Some(c), None) if t.len() - c - 1 != 3 => t.replace(',', "."),
|
||
_ => t.replace(',', ""),
|
||
};
|
||
let v: f64 = normalized.parse().ok()?;
|
||
v.is_finite().then_some(if neg { -v } else { v })
|
||
}
|
||
|
||
/// A number as JavaScript prints it (integers without a fraction).
|
||
pub fn js_number(v: f64) -> String {
|
||
if v.fract() == 0.0 && v.abs() < 1e15 { format!("{}", v as i64) } else { format!("{v}") }
|
||
}
|
||
|
||
fn group(int: &str, sep: char) -> String {
|
||
let mut out = String::new();
|
||
for (i, c) in int.chars().enumerate() {
|
||
if i > 0 && (int.len() - i).is_multiple_of(3) {
|
||
out.push(sep);
|
||
}
|
||
out.push(c);
|
||
}
|
||
out
|
||
}
|
||
|
||
/// A number with `decimals` places in separator style `sep` (0 "1,234.56", 1 "1234.56",
|
||
/// 2 "1.234,56", 3 "1234,56", 4 "1'234.56").
|
||
fn number_text(v: f64, decimals: u8, sep: u8) -> String {
|
||
// Round half away from zero, as JavaScript's toFixed does for these values.
|
||
let p = 10f64.powi(i32::from(decimals));
|
||
let scaled = v.abs() * p;
|
||
// Past 2^53 every value is already whole, and scaling could overflow to infinity.
|
||
let r = if scaled.is_finite() { scaled.round() / p } else { v.abs() };
|
||
let s = format!("{:.*}", decimals as usize, r);
|
||
let (int, frac) = s.split_once('.').map_or((s.as_str(), ""), |(a, b)| (a, b));
|
||
let (g, d) = match sep {
|
||
1 => (None, '.'),
|
||
2 => (Some('.'), ','),
|
||
3 => (None, ','),
|
||
4 => (Some('\''), '.'),
|
||
_ => (Some(','), '.'),
|
||
};
|
||
let mut out = match g {
|
||
Some(c) => group(int, c),
|
||
None => int.to_string(),
|
||
};
|
||
if !frac.is_empty() {
|
||
out.push(d);
|
||
out.push_str(frac);
|
||
}
|
||
out
|
||
}
|
||
|
||
const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
|
||
|
||
const DAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||
|
||
/// The month and weekday names `mmmm`, `mmm`, `dddd` and `ddd` write (weekdays from Sunday), as
|
||
/// a localized Acrobat writes them in its own language (#5303).
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub struct DateNames {
|
||
pub months: [&'static str; 12],
|
||
pub months_short: [&'static str; 12],
|
||
pub days: [&'static str; 7],
|
||
pub days_short: [&'static str; 7],
|
||
}
|
||
|
||
/// English, the names until the app sets others.
|
||
pub const ENGLISH_DATE_NAMES: DateNames = DateNames {
|
||
months: MONTHS,
|
||
months_short: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"],
|
||
days: DAYS,
|
||
days_short: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"],
|
||
};
|
||
|
||
static DATE_NAMES: std::sync::RwLock<DateNames> = std::sync::RwLock::new(ENGLISH_DATE_NAMES);
|
||
|
||
/// Sets the names date formats write from now on (the app's date language); typed dates are read
|
||
/// in them and in English.
|
||
pub fn set_date_names(names: DateNames) {
|
||
*DATE_NAMES.write().unwrap_or_else(std::sync::PoisonError::into_inner) = names;
|
||
}
|
||
|
||
/// The names date formats write.
|
||
pub fn date_names() -> DateNames {
|
||
*DATE_NAMES.read().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||
}
|
||
|
||
/// `text` with the month and weekday names of `lists` (in the order months, short months,
|
||
/// weekdays, short weekdays) written as their English names, so a parser that reads English
|
||
/// reads them. The longest name wins at each place, case ignored, and only a whole name: one
|
||
/// that doesn't continue a word, nor end in a digit that a number continues. A name may hold
|
||
/// digits ("10月"), spaces ("tháng 10"), dots ("ม.ค.") or combining vowel signs, which a
|
||
/// word-by-word reader would split (#5303). Where a weekday and a month share a name (Italian
|
||
/// "mar"), the order of the format's name fields (`mmm`, `ddd`) decides. English names are
|
||
/// found too, so that one typed ("Tue" before "Mar") fills its field of the format as a
|
||
/// localized one does.
|
||
fn english_names(text: &str, fmt: &str, lists: [&[&str]; 4]) -> String {
|
||
const EN: [&[&str]; 4] = [&MONTHS, &MONTHS, &DAYS, &DAYS];
|
||
// No date is this long: hostile text is left as it is rather than scanned name by name.
|
||
if text.len() > MAX_DATE_TEXT_BYTES {
|
||
return text.to_string();
|
||
}
|
||
let chars: Vec<char> = text.chars().collect();
|
||
let typed_english: [&[&str]; 4] = [&MONTHS, &ENGLISH_DATE_NAMES.months_short, &DAYS, &ENGLISH_DATE_NAMES.days_short];
|
||
// Each name as folded chars, whether it is a month, and its English name.
|
||
let mut names: Vec<(Vec<String>, bool, &str)> = Vec::new();
|
||
for (k, (list, en)) in lists.into_iter().zip(EN).chain(typed_english.into_iter().zip(EN)).enumerate() {
|
||
for (name, english) in list.iter().zip(en.iter()) {
|
||
// A short name ending in a dot ("mar.") is also read typed without it, as the same
|
||
// kind of name, so that where it equals another ("Mar", March) the format decides.
|
||
let bare = name.trim_end_matches('.');
|
||
for name in std::iter::once(*name).chain((bare.len() < name.len()).then_some(bare)) {
|
||
let n: Vec<String> = name.chars().map(fold).collect();
|
||
if !n.is_empty() && n.len() <= MAX_NAME_CHARS {
|
||
names.push((n, k % 4 < 2, english));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
names.sort_by_key(|(n, ..)| std::cmp::Reverse(n.len()));
|
||
// The format's name fields in order: true for a month, false for a weekday.
|
||
let f: Vec<char> = fmt.chars().collect();
|
||
let mut expected = Vec::new();
|
||
let mut j = 0;
|
||
while let Some(&c) = f.get(j) {
|
||
let run = f.get(j..).map_or(1, |r| r.iter().take_while(|x| **x == c).count()).max(1);
|
||
if run >= 3 && (c == 'm' || c == 'd') {
|
||
expected.push(c == 'm');
|
||
}
|
||
j = j.saturating_add(run);
|
||
}
|
||
let mut slot = 0;
|
||
// A name's first letter may not continue a word; its last may not run on into a letter, nor
|
||
// (a digit) into a number.
|
||
let letter = |c: Option<&char>| c.is_some_and(|c| c.is_alphabetic());
|
||
let mut out = String::with_capacity(text.len());
|
||
let mut i = 0;
|
||
while let Some(&c) = chars.get(i) {
|
||
let fits = |n: &Vec<String>| {
|
||
let end = i.saturating_add(n.len());
|
||
let first = n.first().and_then(|e| e.chars().next());
|
||
let last = n.last().and_then(|e| e.chars().next());
|
||
chars.get(i..end).is_some_and(|t| t.iter().map(|c| fold(*c)).eq(n.iter().cloned()))
|
||
&& !(first.is_some_and(char::is_alphabetic) && letter(i.checked_sub(1).and_then(|p| chars.get(p))))
|
||
&& !match last {
|
||
Some(l) if l.is_ascii_digit() => chars.get(end).is_some_and(char::is_ascii_digit),
|
||
Some(l) if l.is_alphabetic() => letter(chars.get(end)),
|
||
_ => false,
|
||
}
|
||
};
|
||
let longest = names.iter().find(|(n, ..)| fits(n)).map(|(n, ..)| n.len());
|
||
let hit = longest.and_then(|len| {
|
||
let mut same = names.iter().filter(|(n, ..)| n.len() == len && fits(n));
|
||
let want = expected.get(slot).copied();
|
||
let all: Vec<_> = same.by_ref().collect();
|
||
all.iter().find(|(_, month, _)| Some(*month) == want).or_else(|| all.first()).copied()
|
||
});
|
||
match hit {
|
||
Some((n, month, english)) => {
|
||
if expected.get(slot) == Some(month) {
|
||
slot += 1;
|
||
}
|
||
// Apart from what touches it ("10月18"), but no space added at either end.
|
||
if out.ends_with(char::is_alphanumeric) {
|
||
out.push(' ');
|
||
}
|
||
out.push_str(english);
|
||
i = i.saturating_add(n.len());
|
||
if chars.get(i).is_some_and(|c| c.is_alphanumeric()) {
|
||
out.push(' ');
|
||
}
|
||
}
|
||
None => {
|
||
out.push(c);
|
||
i += 1;
|
||
}
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// `c` as names are compared: lower case, with the capital I and the Turkish dotted and dotless
|
||
/// i all "i", and a combining dot above (U+0307) dropped, so that "EKİM", "SALI" and "MAYIS" are
|
||
/// Turkish "Ekim", "Salı" and "Mayıs", which lower-casing alone makes "ekim" with a dotted i,
|
||
/// "sali" and "mayis" (#5303). The Greek final sigma is a sigma, since a capital Σ, which
|
||
/// lower-cases to σ on its own, ends "ΙΑΝΟΥΆΡΙΟΣ" for "Ιανουάριος". One piece for each char, so
|
||
/// positions in the text stay aligned.
|
||
fn fold(c: char) -> String {
|
||
match c {
|
||
'I' | 'ı' | 'İ' => "i".to_string(),
|
||
'ς' => "σ".to_string(),
|
||
_ => c.to_lowercase().filter(|&l| l != '\u{307}').collect(),
|
||
}
|
||
}
|
||
|
||
/// `s` [`fold`]ed char by char.
|
||
fn folded(s: &str) -> String {
|
||
s.chars().map(fold).collect()
|
||
}
|
||
|
||
/// Typed dates longer than this are not searched for names.
|
||
const MAX_DATE_TEXT_BYTES: usize = 1024;
|
||
|
||
/// Names longer than this are not looked for (settings are trusted, but a cap keeps the scan cheap).
|
||
const MAX_NAME_CHARS: usize = 32;
|
||
|
||
/// The month (1 to 12) a typed word names: a start of at least three letters of a month's name,
|
||
/// or its whole short name, in `names` or in English.
|
||
fn month_named(word: &str, names: &DateNames) -> Option<u32> {
|
||
let w = folded(word);
|
||
let starts = |list: &[&str; 12]| list.iter().position(|m| w.chars().count() >= 3 && folded(m).starts_with(&w));
|
||
let whole = |list: &[&str; 12]| list.iter().position(|m| folded(m).trim_end_matches('.') == w);
|
||
starts(&names.months).or_else(|| whole(&names.months_short)).or_else(|| starts(&MONTHS)).map(|i| i as u32 + 1)
|
||
}
|
||
|
||
/// A date and time read from text.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub struct DateTime {
|
||
pub y: i32,
|
||
pub m: u32,
|
||
pub d: u32,
|
||
pub hh: u32,
|
||
pub mm: u32,
|
||
pub ss: u32,
|
||
}
|
||
|
||
fn days_in(y: i32, m: u32) -> u32 {
|
||
match m {
|
||
2 if (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 => 29,
|
||
2 => 28,
|
||
4 | 6 | 9 | 11 => 30,
|
||
_ => 31,
|
||
}
|
||
}
|
||
|
||
fn weekday(y: i32, m: u32, d: u32) -> usize {
|
||
// Sakamoto's method, in i64 so no year or day can overflow it.
|
||
const T: [i64; 12] = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4];
|
||
let y = i64::from(y) - i64::from(m < 3);
|
||
let t = T.get((m as usize).wrapping_sub(1)).copied().unwrap_or(0);
|
||
(y + y / 4 - y / 100 + y / 400 + t + i64::from(d)).rem_euclid(7) as usize
|
||
}
|
||
|
||
/// One field of a date format, in the order the format has them.
|
||
struct Part {
|
||
/// `'y'`, `'m'`, `'d'`, `'H'`, `'M'` or `'s'`; a run of literal digits (the fixed year in
|
||
/// "dd.mm.26") is `'#'`.
|
||
kind: char,
|
||
/// The value of a `'#'` run; 0 for the others.
|
||
literal: u32,
|
||
/// The digits the field takes when typed run together with the next one: 4 for "yyyy", 2 for
|
||
/// "yy", "mm", "dd", "HH", "MM" and "ss". 0 when the width varies ("m", "d") or the field is
|
||
/// a name ("mmm").
|
||
width: usize,
|
||
/// The next field follows with nothing between them ("yyyymmdd"), so one run of typed digits
|
||
/// can hold both.
|
||
joined: bool,
|
||
}
|
||
|
||
/// The fields of a format (year, month, day, hours, minutes, seconds and literal digits), in
|
||
/// order.
|
||
fn order(fmt: &str) -> Vec<Part> {
|
||
let mut out: Vec<Part> = Vec::new();
|
||
// Weekday names (ddd, dddd) are shown, not typed: leave them out.
|
||
let mut cleaned = fmt.replace("dddd", " ");
|
||
cleaned = cleaned.replace("ddd", " ");
|
||
let mut prev = ' ';
|
||
// The letter of the last field's current run and its length so far.
|
||
let mut run: Option<(char, usize)> = None;
|
||
let fixed = |k: char, n: usize| match (k, n) {
|
||
('y', 2 | 4) | ('m' | 'd' | 'H' | 'M' | 's', 2) => n,
|
||
_ => 0,
|
||
};
|
||
for c in cleaned.chars() {
|
||
let continues = run.is_some_and(|(r, _)| r == c);
|
||
if let Some((r, n)) = run
|
||
&& !continues
|
||
{
|
||
// The run ended; it was the last field's width.
|
||
if let Some(last) = out.last_mut().filter(|p| p.kind != '#') {
|
||
last.width = fixed(if r == 'h' { 'H' } else { r }, n);
|
||
}
|
||
run = None;
|
||
}
|
||
if let Some(digit) = c.to_digit(10) {
|
||
match out.last_mut() {
|
||
Some(p) if p.kind == '#' && prev.is_ascii_digit() => p.literal = p.literal.saturating_mul(10).saturating_add(digit),
|
||
_ => out.push(Part { kind: '#', literal: digit, width: 0, joined: false }),
|
||
}
|
||
prev = c;
|
||
continue;
|
||
}
|
||
let k = match c {
|
||
'y' => 'y',
|
||
'm' => 'm',
|
||
'd' => 'd',
|
||
'H' | 'h' => 'H',
|
||
'M' => 'M',
|
||
's' => 's',
|
||
_ => {
|
||
prev = c;
|
||
continue;
|
||
}
|
||
};
|
||
if continues {
|
||
run = run.map(|(r, n)| (r, n.saturating_add(1)));
|
||
} else {
|
||
// A field starting right where the last one's letters stopped is joined to it.
|
||
let adjacent = matches!(prev, 'y' | 'm' | 'd' | 'H' | 'h' | 'M' | 's');
|
||
if out.last().map(|p| p.kind) != Some(k) {
|
||
if let Some(last) = out.last_mut().filter(|p| p.kind != '#') {
|
||
last.joined = adjacent;
|
||
}
|
||
out.push(Part { kind: k, literal: 0, width: 0, joined: false });
|
||
}
|
||
run = Some((c, 1));
|
||
}
|
||
prev = c;
|
||
}
|
||
if let (Some((r, n)), Some(last)) = (run, out.last_mut().filter(|p| p.kind != '#')) {
|
||
last.width = fixed(if r == 'h' { 'H' } else { r }, n);
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Parse typed text against a date/time format (AFDate_KeystrokeEx's leniency: any
|
||
/// separators, month names, two-digit years). `None` when it isn't a valid date.
|
||
pub fn parse_date(text: &str, fmt: &str) -> Option<DateTime> {
|
||
let names = date_names();
|
||
let lists: [&[&str]; 4] = [&names.months, &names.months_short, &names.days, &names.days_short];
|
||
// Weekdays are shown, not typed (see `order`): where the format has one but none was typed, a
|
||
// name the language shares between a weekday and a month (Italian "mar") is the month, as
|
||
// the names are read again with no weekday expected (#5303).
|
||
read_date(&english_names(text, fmt, lists), fmt, &names).or_else(|| {
|
||
let no_weekday = fmt.replace("dddd", " ").replace("ddd", " ");
|
||
(no_weekday != fmt).then(|| read_date(&english_names(text, &no_weekday, lists), fmt, &names)).flatten()
|
||
})
|
||
}
|
||
|
||
/// [`parse_date`] of `english`, typed text whose month and weekday names are English.
|
||
fn read_date(english: &str, fmt: &str, names: &DateNames) -> Option<DateTime> {
|
||
let t = english.trim();
|
||
if t.is_empty() {
|
||
return None;
|
||
}
|
||
let lower = t.to_lowercase();
|
||
// A bare trailing "p" ("5:30p") only counts when it follows a digit, not a word like "Sep".
|
||
let bare_p = lower.strip_suffix('p').is_some_and(|rest| rest.trim_end().ends_with(|c: char| c.is_ascii_digit()));
|
||
let pm = lower.contains("pm") || bare_p;
|
||
let bare_a = lower.strip_suffix('a').is_some_and(|rest| rest.trim_end().ends_with(|c: char| c.is_ascii_digit()));
|
||
let am = lower.contains("am") || bare_a;
|
||
// Tokens: runs of digits (kept as text, so a run can be split between fields typed without
|
||
// separators) and month names.
|
||
let mut nums: VecDeque<String> = VecDeque::new();
|
||
let mut month_name: Option<u32> = None;
|
||
let mut cur = String::new();
|
||
let flush = |cur: &mut String, nums: &mut VecDeque<String>, month: &mut Option<u32>| {
|
||
if cur.is_empty() {
|
||
return;
|
||
}
|
||
if cur.starts_with(|c: char| c.is_ascii_digit()) {
|
||
nums.push_back(std::mem::take(cur));
|
||
} else if let Some(m) = month_named(cur, names) {
|
||
*month = Some(m);
|
||
}
|
||
cur.clear();
|
||
};
|
||
for c in t.chars() {
|
||
// Digits and letters form separate tokens ("5Mar" is 5 and Mar).
|
||
let same_kind = cur.is_empty() || cur.chars().next().is_some_and(|x| x.is_ascii_digit()) == c.is_ascii_digit();
|
||
if c.is_alphanumeric() && same_kind {
|
||
cur.push(c);
|
||
} else {
|
||
flush(&mut cur, &mut nums, &mut month_name);
|
||
if c.is_alphanumeric() {
|
||
cur.push(c);
|
||
}
|
||
}
|
||
}
|
||
flush(&mut cur, &mut nums, &mut month_name);
|
||
let mut dt = DateTime { y: 0, m: 1, d: 1, hh: 0, mm: 0, ss: 0 };
|
||
// The next number for a field. A field joined to the next one in the format ("yyyymmdd")
|
||
// takes only its own width from a longer run of digits and leaves the rest for the next.
|
||
// A run typed on its own is split only when fewer runs were typed than the fields of its kind
|
||
// still to fill (`need`, this one included: date fields for a date field, time fields for a
|
||
// time field, since times may be left out), and only when its rest can fill the next field
|
||
// (`next_width`): with separators each run is one field's number, so "2026-10-18" keeps its
|
||
// four-digit year in a yymmdd or yymmddHHMM field, and "2026 10 018" is the 18th. What is left
|
||
// of a split run (`tail`: the front of `nums` is one) goes on being split along the fields
|
||
// written side by side, and must fill its last field exactly, so "202610018" leaves "018" for
|
||
// dd and is refused. `None` when there is no number left, it doesn't fit a u32, or such a
|
||
// rest has the wrong width.
|
||
let next_number = |nums: &mut VecDeque<String>, tail: &mut bool, part: &Part, need: usize, next_width: usize| -> Option<u32> {
|
||
let run = nums.pop_front()?;
|
||
let was_tail = std::mem::replace(tail, false);
|
||
let short = nums.len() < need.saturating_sub(1);
|
||
let digits = match (run.get(..part.width), run.get(part.width..)) {
|
||
(Some(head), Some(rest)) if part.joined && part.width > 0 && !rest.is_empty() && (was_tail || (short && rest.len() >= next_width)) => {
|
||
nums.push_front(rest.to_string());
|
||
*tail = true;
|
||
head
|
||
}
|
||
_ if was_tail && part.width > 0 && run.len() != part.width => return None,
|
||
_ => run.as_str(),
|
||
};
|
||
digits.parse::<u32>().ok()
|
||
};
|
||
let parts = order(fmt);
|
||
let takes_number = |p: &Part| matches!(p.kind, 'y' | 'd' | 'H' | 'M' | 's') || (p.kind == 'm' && month_name.is_none());
|
||
let is_time = |p: &Part| matches!(p.kind, 'H' | 'M' | 's');
|
||
// The fields of each kind still to fill: [date, time].
|
||
let mut wanted = [parts.iter().filter(|p| takes_number(p) && !is_time(p)).count(), parts.iter().filter(|p| is_time(p)).count()];
|
||
let mut tail = false;
|
||
let mut have_y = false;
|
||
for (i, part) in parts.iter().enumerate() {
|
||
let kind = usize::from(is_time(part));
|
||
let need = wanted.get(kind).copied().unwrap_or(0);
|
||
let next_width = parts.get(i + 1).map_or(0, |p| p.width);
|
||
if takes_number(part)
|
||
&& let Some(left) = wanted.get_mut(kind)
|
||
{
|
||
*left = left.saturating_sub(1);
|
||
}
|
||
let take = |nums: &mut VecDeque<String>, tail: &mut bool| next_number(nums, tail, part, need, next_width);
|
||
match part.kind {
|
||
// Literal digits in the format may be typed or left out ("15.05.26" or "15.05"
|
||
// for "dd.mm.26"); a different number there is left over and rejected below.
|
||
'#' if nums.front().and_then(|n| n.parse::<u32>().ok()) == Some(part.literal) => {
|
||
nums.pop_front();
|
||
tail = false;
|
||
}
|
||
'm' => {
|
||
dt.m = match month_name {
|
||
Some(m) => m,
|
||
None => take(&mut nums, &mut tail)?,
|
||
}
|
||
}
|
||
'd' => dt.d = take(&mut nums, &mut tail)?,
|
||
'y' => {
|
||
let y = i32::try_from(take(&mut nums, &mut tail)?).ok()?;
|
||
dt.y = if y < 100 { if y < 50 { 2000 + y } else { 1900 + y } } else { y };
|
||
have_y = true;
|
||
}
|
||
// Times may be left out; a number that is there must be readable.
|
||
'H' => dt.hh = if nums.is_empty() { 0 } else { take(&mut nums, &mut tail)? },
|
||
'M' => dt.mm = if nums.is_empty() { 0 } else { take(&mut nums, &mut tail)? },
|
||
's' => dt.ss = if nums.is_empty() { 0 } else { take(&mut nums, &mut tail)? },
|
||
_ => {}
|
||
}
|
||
}
|
||
if !nums.is_empty() {
|
||
return None;
|
||
}
|
||
if !have_y {
|
||
dt.y = 2000;
|
||
}
|
||
if pm && dt.hh < 12 {
|
||
dt.hh += 12;
|
||
}
|
||
if am && dt.hh == 12 {
|
||
dt.hh = 0;
|
||
}
|
||
let has_date = fmt.contains(['y', 'm', 'd']);
|
||
if has_date && (!(1..=12).contains(&dt.m) || dt.d == 0 || dt.d > days_in(dt.y, dt.m)) {
|
||
return None;
|
||
}
|
||
if dt.hh > 23 || dt.mm > 59 || dt.ss > 59 {
|
||
return None;
|
||
}
|
||
Some(dt)
|
||
}
|
||
|
||
/// Format a date/time with Acrobat's date format letters.
|
||
pub fn format_date(dt: DateTime, fmt: &str) -> String {
|
||
format_date_in(dt, fmt, &date_names())
|
||
}
|
||
|
||
/// [`format_date`] with month and weekday names from `names`.
|
||
pub fn format_date_in(dt: DateTime, fmt: &str, names: &DateNames) -> String {
|
||
let chars: Vec<char> = fmt.chars().collect();
|
||
let mut out = String::new();
|
||
let mut i = 0;
|
||
while i < chars.len() {
|
||
let c = chars[i];
|
||
let mut n = 1;
|
||
while i + n < chars.len() && chars[i + n] == c {
|
||
n += 1;
|
||
}
|
||
let h12 = if dt.hh.is_multiple_of(12) { 12 } else { dt.hh % 12 };
|
||
match (c, n) {
|
||
('y', 4..) => {
|
||
let _ = write!(out, "{:04}", dt.y);
|
||
}
|
||
('y', _) => {
|
||
let _ = write!(out, "{:02}", dt.y.rem_euclid(100));
|
||
}
|
||
('m', 3..) => match (dt.m as usize).checked_sub(1).and_then(|i| names.months.get(i).zip(names.months_short.get(i))) {
|
||
Some((name, _)) if n >= 4 => out.push_str(name),
|
||
Some((_, short)) => out.push_str(short),
|
||
None => {
|
||
let _ = write!(out, "{}", dt.m);
|
||
}
|
||
},
|
||
('m', 2) => {
|
||
let _ = write!(out, "{:02}", dt.m);
|
||
}
|
||
('m', _) => {
|
||
let _ = write!(out, "{}", dt.m);
|
||
}
|
||
('d', 3..) => {
|
||
let list = if n >= 4 { &names.days } else { &names.days_short };
|
||
out.push_str(list.get(weekday(dt.y, dt.m, dt.d)).copied().unwrap_or_default());
|
||
}
|
||
('d', 2) => {
|
||
let _ = write!(out, "{:02}", dt.d);
|
||
}
|
||
('d', _) => {
|
||
let _ = write!(out, "{}", dt.d);
|
||
}
|
||
('H', 2) => {
|
||
let _ = write!(out, "{:02}", dt.hh);
|
||
}
|
||
('H', _) => {
|
||
let _ = write!(out, "{}", dt.hh);
|
||
}
|
||
('h', 2) => {
|
||
let _ = write!(out, "{h12:02}");
|
||
}
|
||
('h', _) => {
|
||
let _ = write!(out, "{h12}");
|
||
}
|
||
('M', 2) => {
|
||
let _ = write!(out, "{:02}", dt.mm);
|
||
}
|
||
('M', _) => {
|
||
let _ = write!(out, "{}", dt.mm);
|
||
}
|
||
('s', 2) => {
|
||
let _ = write!(out, "{:02}", dt.ss);
|
||
}
|
||
('s', _) => {
|
||
let _ = write!(out, "{}", dt.ss);
|
||
}
|
||
('t', 2) => out.push_str(if dt.hh < 12 { "am" } else { "pm" }),
|
||
('t', _) => out.push(if dt.hh < 12 { 'a' } else { 'p' }),
|
||
(c, n) => out.extend(std::iter::repeat_n(c, n)),
|
||
}
|
||
i += n;
|
||
}
|
||
out
|
||
}
|
||
|
||
const SPECIAL_MASKS: [&str; 4] = ["99999", "99999-9999", "(999) 999-9999", "999-99-9999"];
|
||
|
||
fn mask_fits(mask: &str, text: &str) -> bool {
|
||
let t: Vec<char> = text.chars().collect();
|
||
let m: Vec<char> = mask.chars().collect();
|
||
t.len() == m.len()
|
||
&& m.iter().zip(&t).all(|(mc, tc)| match mc {
|
||
'9' => tc.is_ascii_digit(),
|
||
'A' => tc.is_alphabetic(),
|
||
'O' => tc.is_alphanumeric(),
|
||
'X' => true,
|
||
other => other == tc,
|
||
})
|
||
}
|
||
|
||
/// Fill a mask from the typed characters that fit its slots.
|
||
fn apply_mask(mask: &str, text: &str) -> Option<String> {
|
||
let mut src = text.chars().filter(|c| c.is_alphanumeric());
|
||
let mut out = String::new();
|
||
for mc in mask.chars() {
|
||
match mc {
|
||
'9' | 'A' | 'O' | 'X' => {
|
||
let c = src.next()?;
|
||
let ok = match mc {
|
||
'9' => c.is_ascii_digit(),
|
||
'A' => c.is_alphabetic(),
|
||
_ => true,
|
||
};
|
||
if !ok {
|
||
return None;
|
||
}
|
||
out.push(c);
|
||
}
|
||
other => out.push(other),
|
||
}
|
||
}
|
||
src.next().is_none().then_some(out)
|
||
}
|
||
|
||
/// The text shown for a value (the Format event).
|
||
pub fn format_value(f: &Format, value: &str) -> String {
|
||
format_value_in(f, value, &date_names())
|
||
}
|
||
|
||
/// [`format_value`] with month and weekday names from `names`.
|
||
pub fn format_value_in(f: &Format, value: &str, names: &DateNames) -> String {
|
||
if value.is_empty() {
|
||
return String::new();
|
||
}
|
||
match f {
|
||
Format::None => value.to_string(),
|
||
Format::Number { decimals, sep, neg, currency, prepend } => {
|
||
let Some(v) = make_number(value) else { return value.to_string() };
|
||
let body = number_text(v, *decimals, *sep);
|
||
let body = match (currency.is_empty(), *prepend) {
|
||
(true, _) => body,
|
||
(false, true) => format!("{currency}{body}"),
|
||
(false, false) => format!("{body}{currency}"),
|
||
};
|
||
match (v < 0.0, neg) {
|
||
(false, _) => body,
|
||
// Styles 2 and 3 use parentheses (3 is also red, which the appearance can't
|
||
// know here); 0 and 1 a minus sign.
|
||
(true, 2 | 3) => format!("({body})"),
|
||
(true, _) => format!("-{body}"),
|
||
}
|
||
}
|
||
Format::Percent { decimals, sep } => match make_number(value) {
|
||
Some(v) => format!(
|
||
"{}%",
|
||
if v < 0.0 { format!("-{}", number_text(v * 100.0, *decimals, *sep)) } else { number_text(v * 100.0, *decimals, *sep) }
|
||
),
|
||
None => value.to_string(),
|
||
},
|
||
Format::Date(fmt) | Format::Time(fmt) => parse_date(value, fmt).map_or_else(|| value.to_string(), |dt| format_date_in(dt, fmt, names)),
|
||
Format::Special(n) => apply_mask(SPECIAL_MASKS[(*n).min(3) as usize], value).unwrap_or_else(|| value.to_string()),
|
||
Format::Mask(m) => apply_mask(m, value).unwrap_or_else(|| value.to_string()),
|
||
}
|
||
}
|
||
|
||
/// The Keystroke event on commit: does the value fit the format? Returns the value to store
|
||
/// (numbers are normalised), or Acrobat's message.
|
||
pub fn keystroke(f: &Format, field: &str, value: &str) -> Result<String, String> {
|
||
let bad = || format!("The value entered does not match the format of the field [ {field} ]");
|
||
// Blank input clears a formatted field; without a format, whitespace is text like any other.
|
||
if *f != Format::None && value.trim().is_empty() {
|
||
return Ok(String::new());
|
||
}
|
||
match f {
|
||
Format::None => Ok(value.to_string()),
|
||
Format::Number { .. } => make_number(value).map(js_number).ok_or_else(bad),
|
||
Format::Percent { .. } => {
|
||
// Typed "12.5%" or "12.5" both mean 0.125 once stored? Acrobat stores the number
|
||
// as typed and shows it ×100: keep the typed number.
|
||
make_number(value).map(js_number).ok_or_else(bad)
|
||
}
|
||
Format::Date(fmt) | Format::Time(fmt) => parse_date(value, fmt)
|
||
.map(|_| value.trim().to_string())
|
||
.ok_or_else(|| format!("Invalid date/time: please ensure that the date/time exists. Field [ {field} ] should match format {fmt}")),
|
||
Format::Special(n) => {
|
||
let m = SPECIAL_MASKS[(*n).min(3) as usize];
|
||
apply_mask(m, value).filter(|s| mask_fits(m, s)).map(|_| value.trim().to_string()).ok_or_else(bad)
|
||
}
|
||
Format::Mask(m) => apply_mask(m, value).map(|_| value.trim().to_string()).ok_or_else(bad),
|
||
}
|
||
}
|
||
|
||
/// The Validate event: `Err` with Acrobat's message when the value is out of range.
|
||
pub fn validate(v: &Validate, value: &str) -> Result<(), String> {
|
||
let Validate::Range { min, max } = v else { return Ok(()) };
|
||
if value.trim().is_empty() {
|
||
return Ok(());
|
||
}
|
||
let Some(x) = make_number(value) else { return Ok(()) };
|
||
let fmt = |n: f64| js_number(n);
|
||
let ok = min.is_none_or(|m| x >= m) && max.is_none_or(|m| x <= m);
|
||
if ok {
|
||
return Ok(());
|
||
}
|
||
Err(match (min, max) {
|
||
(Some(a), Some(b)) => format!("Invalid value: must be greater than or equal to {} and less than or equal to {}.", fmt(*a), fmt(*b)),
|
||
(Some(a), None) => format!("Invalid value: must be greater than or equal to {}.", fmt(*a)),
|
||
(None, Some(b)) => format!("Invalid value: must be less than or equal to {}.", fmt(*b)),
|
||
(None, None) => String::new(),
|
||
})
|
||
}
|