diff --git a/crates/engine/src/dates.rs b/crates/engine/src/dates.rs index c9382c1b02..5ede67c811 100644 --- a/crates/engine/src/dates.rs +++ b/crates/engine/src/dates.rs @@ -582,6 +582,18 @@ pub fn date_language(code: &str) -> Option<&'static DateLanguage> { DATE_LANGUAGES.iter().find(|l| l.code.eq_ignore_ascii_case(code.trim())) } +/// Form date fields (`AFDate_FormatEx`, `util.printd`, `util.scand`) write month and weekday +/// names in date language `code` from now on, as a localized Acrobat writes its own language's: +/// `dd-mmm-yy` is `18-ott-26` in Italian (#5303). An unknown code is English. +pub fn use_form_date_language(code: &str) { + let l = date_language(code).unwrap_or(&DATE_LANGUAGES[0]); + let names = pdfcraft_forms::af::DateNames { months: l.months, months_short: l.months_short, days: l.days, days_short: l.days_short }; + if pdfcraft_forms::af::date_names() != names { + pdfcraft_forms::af::set_date_names(names); + pdfcraft_js::set_date_names(l.months, l.months_short, l.days, l.days_short); + } +} + /// 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. diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index d1ee5c15b0..cc78785815 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -199,6 +199,24 @@ impl Scripts for JsRunner { /// Run field `target`'s script for `event` over the form's fields, keeping what it showed and /// asked for; a refusal carries its last message. fn run(&mut self, event: FieldEvent, script: &str, target: &Field, value: &str, fields: &[Field]) -> ScriptResult { + // As in Acrobat, a failing script leaves the value alone (the error goes to the console). + self.run_logged(event, script, target, value, fields, true).unwrap_or_else(|| ScriptResult { + rc: true, + value: value.to_string(), + changes: Vec::new(), + message: None, + }) + } + + fn rerun_with_english_dates(&mut self, event: FieldEvent, script: &str, target: &Field, value: &str, fields: &[Field]) -> Option { + pdfcraft_js::with_english_printd(true, || self.run_logged(event, script, target, value, fields, false)) + } +} + +impl JsRunner { + /// [`Scripts::run`], keeping what the script showed and asked for only when `log`; `None` + /// when the script failed. + fn run_logged(&mut self, event: FieldEvent, script: &str, target: &Field, value: &str, fields: &[Field], log: bool) -> Option { let states: Vec<_> = fields .iter() .map(|f| { @@ -212,17 +230,18 @@ impl Scripts for JsRunner { let states = std::sync::Arc::new(states); let ev = pdfcraft_js::Event::field(event.name(), &target.name, value); let o = pdfcraft_js::run_shared(script, &ev, &self.doc, std::sync::Arc::clone(&states), &self.doc_scripts, Limits::default()); - self.output.absorb(&o); - if o.error.is_some() { - // As in Acrobat, a failing script leaves the value alone (the error goes to the console). - return ScriptResult { rc: true, value: value.to_string(), changes: Vec::new(), message: None }; + if log { + self.output.absorb(&o); } - ScriptResult { + if o.error.is_some() { + return None; + } + Some(ScriptResult { rc: o.rc, value: o.value.clone(), changes: changes(&states, &o.changed), message: (!o.rc).then(|| o.alerts.last().map(|m| m.text.clone())).flatten(), - } + }) } } diff --git a/crates/engine/tests/form_date_language.rs b/crates/engine/tests/form_date_language.rs new file mode 100644 index 0000000000..1d4293e12c --- /dev/null +++ b/crates/engine/tests/form_date_language.rs @@ -0,0 +1,453 @@ +//! Form date fields name months in the app's date language (#5303). Its own test binary: the +//! names are process-wide, so other tests must not see them change, and its tests take turns. + +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; + +use pdfcraft_cos::{Document, Object}; +use pdfcraft_engine::dates::use_form_date_language; +use pdfcraft_engine::js::JsRunner; +use pdfcraft_forms::FieldValue; +use pdfcraft_forms::af::{DateTime, Format, format_date, format_value, keystroke, parse_date}; +use pdfcraft_js::{DocInfo, Event, Limits, run}; + +/// Held by each test while it changes the process-wide date names, which start as English (a +/// failed test may have left others). +fn names_lock() -> MutexGuard<'static, ()> { + static LOCK: Mutex<()> = Mutex::new(()); + let lock = LOCK.lock().unwrap_or_else(PoisonError::into_inner); + use_form_date_language("en"); + lock +} + +/// `util.printd(fmt)` of 18 October 2026, then of what `util.scand(fmt, typed)` reads. +fn printd(fmt: &str, typed: &str) -> String { + let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false }; + let script = format!("event.value = util.printd('{fmt}', new Date(2026, 9, 18)) + '|' + util.printd('{fmt}', util.scand('{fmt}', '{typed}'));"); + let o = run(&script, &Event::field("Format", "date", ""), &doc, &[], &[], Limits::default()); + assert_eq!(o.error, None); + o.value +} + +#[test] +fn form_dates_follow_the_date_language() { + let _names = names_lock(); + let oct18 = DateTime { y: 2026, m: 10, d: 18, hh: 0, mm: 0, ss: 0 }; + let dd_mmm_yy = Format::Date("dd-mmm-yy".into()); + // English until the app says otherwise. + assert_eq!(format_date(oct18, "dd-mmm-yy dddd ddd mmmm"), "18-Oct-26 Sunday Sun October"); + use_form_date_language("it"); + // AFDate_FormatEx("dd-mmm-yy"), what the issue's form used. + assert_eq!(format_value(&dd_mmm_yy, "18-10-26"), "18-ott-26"); + assert_eq!(format_date(oct18, "dddd d mmmm yyyy, ddd"), "domenica 18 ottobre 2026, dom"); + // What it writes reads back, and so does English typing. + assert_eq!(parse_date("18-ott-26", "dd-mmm-yy"), Some(oct18)); + assert_eq!(parse_date("18 ottobre 2026", "dd mmmm yyyy"), Some(oct18)); + assert_eq!(parse_date("18-Oct-26", "dd-mmm-yy"), Some(oct18)); + assert!(keystroke(&dd_mmm_yy, "date", "18-ott-26").is_ok()); + // util.printd writes Italian names; util.scand reads them. + assert_eq!(printd("dd-mmm-yy", "18-ott-26"), "18-ott-26|18-ott-26"); + assert_eq!(printd("dd-mmm-yy", "18-Oct-26"), "18-ott-26|18-ott-26", "English typing reads too"); + assert_eq!(printd("dddd d mmmm yyyy", "domenica 18 ottobre 2026"), "domenica 18 ottobre 2026|domenica 18 ottobre 2026"); + // German short names end in a dot; they are written and read whole. + use_form_date_language("de"); + assert_eq!(format_value(&dd_mmm_yy, "18-10-26"), "18-Okt.-26"); + assert_eq!(parse_date("18-Okt.-26", "dd-mmm-yy"), Some(oct18)); + // An unknown language is English again. + use_form_date_language("xx"); + assert_eq!(format_value(&dd_mmm_yy, "18-10-26"), "18-Oct-26"); + assert_eq!(printd("dd-mmm-yy", "18-Oct-26"), "18-Oct-26|18-Oct-26"); + every_date_language_reads_back_what_it_writes(); + // Hostile typing never panics and stays quick: long, odd and mixed-script text. + use_form_date_language("th"); + let junk = ["", " ", "ม.", "ม.ค.ม.ค.ม.ค.", "\u{0}", "1月1月1月", "tháng tháng", "Ottobre", "🙂ott🙂", "İstanbul ott 26"]; + for t in junk.iter().map(|s| s.to_string()).chain(["ม.ค.1".repeat(5000), "9".repeat(100_000)]) { + let started = std::time::Instant::now(); + let _ = parse_date(&t, "dd-mmm-yy"); + let _ = keystroke(&dd_mmm_yy, "date", &t); + assert!(started.elapsed() < std::time::Duration::from_secs(2), "{} chars took {:?}", t.len(), started.elapsed()); + } + use_form_date_language("en"); +} + +/// An English weekday typed before the month fills the format's weekday, so a month name that +/// the date language shares with a weekday (Italian, Spanish, Romanian and Albanian "mar") is +/// still read as the month: "Tue 17 Mar 2026" read as "Tue 17 Tuesday 2026" (#5303). +#[test] +fn an_english_weekday_before_a_shared_month_name_reads_as_english() { + let _names = names_lock(); + let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false }; + let mar17 = DateTime { y: 2026, m: 3, d: 17, hh: 0, mm: 0, ss: 0 }; + let mut bad = Vec::new(); + for language in ["it", "es", "ro", "sq"] { + use_form_date_language(language); + let ddd_d_mmm = "ddd d mmm yyyy"; + let typed = [ + // What the language writes, then English, then English and localized mixed. + (ddd_d_mmm, format_date(mar17, ddd_d_mmm)), + (ddd_d_mmm, "Tue 17 Mar 2026".into()), + (ddd_d_mmm, "Tuesday 17 Mar 2026".into()), + (ddd_d_mmm, "tue 17 mar 2026".into()), + (ddd_d_mmm, format!("Tue 17 {}", format_date(mar17, "mmm yyyy"))), + (ddd_d_mmm, format!("{} 17 Mar 2026", format_date(mar17, "ddd"))), + ("mmm d yyyy ddd", "Mar 17 2026 Tue".into()), + ("dddd d mmmm yyyy", "Tuesday 17 March 2026".into()), + ]; + for (fmt, text) in typed { + if parse_date(&text, fmt) != Some(mar17) { + bad.push(format!("{language} forms {fmt}: {text:?} -> {:?}", parse_date(&text, fmt))); + } + let script = format!("var d = util.scand('{fmt}', {text:?}); event.value = d ? util.printd('yyyy-mm-dd', d) : 'null';"); + let o = run(&script, &Event::field("Format", "date", ""), &doc, &[], &[], Limits::default()); + if o.value != "2026-03-17" { + bad.push(format!("{language} scand {fmt}: {text:?} -> {:?} {:?}", o.value, o.error)); + } + } + } + use_form_date_language("en"); + assert!(bad.is_empty(), "{}", bad.join("; ")); +} + +/// Every date language: what a date field writes reads back as the same date, for each date +/// letter that writes a name, through the form path and through `util.scand`. Called from the +/// one test, which owns the process-wide names. +fn every_date_language_reads_back_what_it_writes() { + let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false }; + let mut bad = Vec::new(); + for l in pdfcraft_engine::dates::DATE_LANGUAGES.iter() { + use_form_date_language(l.code); + for m in 1..=12u32 { + let dt = DateTime { y: 2026, m, d: 18, hh: 0, mm: 0, ss: 0 }; + for fmt in ["dd-mmm-yy", "d mmmm yyyy", "dddd, mmmm d, yyyy", "ddd d mmm yyyy", "mmm d, yyyy ddd", "yyyymmmdd"] { + let text = format_date(dt, fmt); + // A short month ending in a number ("Thg 1") glued to the day is ambiguous + // ("Thg 118": January 18 or November 8): nothing can read it back. + if fmt == "yyyymmmdd" && l.months_short.iter().any(|n| n.ends_with(|c: char| c.is_ascii_digit())) { + continue; + } + if parse_date(&text, fmt).map(|p| (p.y, p.m, p.d)) != Some((2026, m, 18)) { + bad.push(format!("{} forms {fmt}: {text:?}", l.code)); + } + let script = format!("var d = util.scand('{fmt}', {text:?}); event.value = d ? util.printd('yyyy-mm-dd', d) : 'null';"); + let o = run(&script, &Event::field("Format", "date", ""), &doc, &[], &[], Limits::default()); + if o.value != format!("2026-{m:02}-18") { + bad.push(format!("{} scand {fmt}: {text:?} -> {:?} {:?}", l.code, o.value, o.error)); + } + } + } + } + use_form_date_language("en"); + assert!(bad.is_empty(), "{} failures:\n{}", bad.len(), bad.iter().take(40).cloned().collect::>().join("\n")); +} + +/// A form whose text fields are each `(name, font, format script)`: `/Helv` is Helvetica with +/// WinAnsi, `/Song` a Chinese font with a Unicode CMap, which also holds Cyrillic. +fn form(fields: &[(&str, &str, &str)]) -> Document { + let n = fields.len(); + let first = 6; + let refs = |from: usize| (from..from + n).map(|i| format!("{i} 0 R")).collect::>().join(" "); + let mut objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + format!("<< /Type /Page /Parent 2 0 R /Annots [{}] >>", refs(first)), + format!("<< /Fields [{}] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R /Song {} 0 R >> >> >>", refs(first), first + n), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + ]; + for (i, (name, font, js)) in fields.iter().enumerate() { + let js = js.replace('\\', "\\\\").replace('(', "\\(").replace(')', "\\)"); + let y = 700 - 30 * i; + objs.push(format!( + "<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /DA (/{font} 10 Tf 0 g) /Rect [50 {y} 300 {}] /P 3 0 R /AA << /F << /S /JavaScript /JS ({js}) >> >> >>", + y + 20 + )); + } + objs.push("<< /Type /Font /Subtype /Type0 /BaseFont /STSong-Light /Encoding /UniGB-UCS2-H /DescendantFonts [<< /Type /Font /Subtype /CIDFontType0 /BaseFont /STSong-Light /CIDSystemInfo << /Registry (Adobe) /Ordering (GB1) /Supplement 4 >> >>] >>".into()); + let mut out = b"%PDF-1.7 +" + .to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice( + format!( + "{} 0 obj +{o} +endobj +", + i + 1 + ) + .as_bytes(), + ); + } + let xref = out.len(); + out.extend_from_slice( + format!( + "xref +0 {} +0000000000 65535 f +", + objs.len() + 1 + ) + .as_bytes(), + ); + for o in offsets { + out.extend_from_slice( + format!( + "{o:010} 00000 n +" + ) + .as_bytes(), + ); + } + out.extend_from_slice( + format!( + "trailer +<< /Size {} /Root 1 0 R >> +startxref +{xref} +%%EOF +", + objs.len() + 1 + ) + .as_bytes(), + ); + Document::open(Arc::new(out)).expect("opens") +} + +/// The content of field `name`'s normal appearance. +fn appearance(doc: &Document, name: &str) -> Vec { + let all = pdfcraft_forms::fields(doc); + let f = all.iter().find(|f| f.name == name).expect("field"); + let widget = doc.get(f.widgets[0].obj); + let normal = widget.as_dict().and_then(|d| d.get(b"AP")).and_then(|a| doc.resolve(a).as_dict().and_then(|a| a.reference(b"N"))).expect("/AP /N"); + match &*doc.get(normal) { + Object::Stream(s) => s.decoded().expect("decodes"), + other => panic!("not a stream: {other:?}"), + } +} + +/// What a Unicode-CMap (UCS-2) font is given for `text`. +fn ucs2(text: &str) -> Vec { + text.encode_utf16().flat_map(u16::to_be_bytes).collect() +} + +fn contains(hay: &[u8], needle: &[u8]) -> bool { + hay.windows(needle.len()).any(|w| w == needle) +} + +/// Fills `form`'s field `name` with 18 October 2026 through the JavaScript runner. +fn fill(doc: &mut Document, name: &str) { + fill_with(doc, name, "18-10-26"); +} + +/// Fills `form`'s field `name` with `value` through the JavaScript runner. +fn fill_with(doc: &mut Document, name: &str, value: &str) { + let mut js = JsRunner::new(doc, "form.pdf"); + pdfcraft_forms::set_value_with(doc, name, &FieldValue::Text(value.into()), &mut js).expect("fills"); +} + +/// Month names in the date language that no font of the field can draw are drawn in English, +/// never as `?` ("18-????.-26" in Russian); a font that holds them keeps them (#5303). +#[test] +fn date_names_no_field_font_can_draw_are_drawn_in_english() { + let _names = names_lock(); + let date = "AFDate_FormatEx(\"dd-mmm-yy\");"; + let fields = [("helv", "Helv", date), ("song", "Song", date)]; + for (language, local) in [("ru", "окт."), ("cs", "říj"), ("ja", "10月"), ("el", "Οκτ")] { + use_form_date_language(language); + let mut doc = form(&fields); + fill(&mut doc, "helv"); + fill(&mut doc, "song"); + let helv = appearance(&doc, "helv"); + assert!(contains(&helv, b"(18-Oct-26) Tj"), "{language}: {}", String::from_utf8_lossy(&helv)); + assert!(!helv.contains(&b'?'), "{language}: {}", String::from_utf8_lossy(&helv)); + assert!(pdfcraft_forms::undrawn_fields(&doc).is_empty(), "{language}"); + let song = appearance(&doc, "song"); + assert!(contains(&song, &ucs2(&format!("18-{local}-26"))), "{language} keeps its name: {song:?}"); + } + // Names Helvetica holds are kept: Italian "ott". + use_form_date_language("it"); + let mut doc = form(&fields); + fill(&mut doc, "helv"); + assert!(contains(&appearance(&doc, "helv"), b"(18-ott-26) Tj")); + use_form_date_language("en"); +} + +/// A Format script's `util.printd` is run again with English names for a field whose fonts +/// can't draw the date language's, and kept where they can (#5303). +#[test] +fn format_scripts_print_dates_in_english_where_the_field_cannot_draw_them() { + let _names = names_lock(); + let script = "event.value = event.value ? util.printd(\"dd-mmm-yy\", util.scand(\"dd-mm-yy\", event.value)) + \" !\" : \"\";"; + let fields = [("helv", "Helv", script), ("song", "Song", script)]; + use_form_date_language("ru"); + let mut doc = form(&fields); + fill(&mut doc, "helv"); + fill(&mut doc, "song"); + let helv = appearance(&doc, "helv"); + assert!(contains(&helv, b"(18-Oct-26 !) Tj"), "{}", String::from_utf8_lossy(&helv)); + assert!(!helv.contains(&b'?'), "{}", String::from_utf8_lossy(&helv)); + assert!(pdfcraft_forms::undrawn_fields(&doc).is_empty()); + assert!(contains(&appearance(&doc, "song"), &ucs2("18-окт.-26 !"))); + // A script whose text has no English either keeps it, reported as undrawn. + let greeting = [("hi", "Helv", "event.value = \"Привет \" + util.printd(\"mmm\", new Date(2026, 9, 18));")]; + let mut doc = form(&greeting); + fill(&mut doc, "hi"); + assert_eq!(pdfcraft_forms::undrawn_fields(&doc), [("hi".to_string(), 'П')]); + // The English run is the appearance's alone: scripts go on printing the date language. + assert_eq!(printd("dd-mmm-yy", "18-окт.-26"), "18-окт.-26|18-окт.-26"); + use_form_date_language("en"); +} + +/// A Format script's English run is kept when it ran, even where it prints the stored value +/// again: a Helvetica field storing "18-Oct-26", printed again as `dd-mmm-yy`, showed +/// "18-????.-26" in Russian. A script that fails keeps what it showed, reported undrawn (#5303). +#[test] +fn an_english_run_that_prints_the_stored_value_is_drawn() { + let _names = names_lock(); + let script = "event.value = util.printd(\"dd-mmm-yy\", util.scand(\"dd-mmm-yy\", event.value));"; + use_form_date_language("ru"); + let mut doc = form(&[("helv", "Helv", script), ("fails", "Helv", "throw new Error(\"no\");")]); + fill_with(&mut doc, "helv", "18-Oct-26"); + let helv = appearance(&doc, "helv"); + assert!(contains(&helv, b"(18-Oct-26) Tj"), "{}", String::from_utf8_lossy(&helv)); + assert!(!helv.contains(&b'?'), "{}", String::from_utf8_lossy(&helv)); + assert!(pdfcraft_forms::undrawn_fields(&doc).is_empty()); + fill_with(&mut doc, "fails", "18-окт.-26"); + assert_eq!(pdfcraft_forms::undrawn_fields(&doc), [("fails".to_string(), 'о')]); + use_form_date_language("en"); +} + +/// What `util.scand(fmt, text)` reads, as `yyyy-mm-dd`, or `null`. +fn scanned(fmt: &str, text: &str) -> String { + let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false }; + let script = format!("var d = util.scand('{fmt}', {text:?}); event.value = d ? util.printd('yyyy-mm-dd', d) : 'null';"); + let o = run(&script, &Event::field("Format", "date", ""), &doc, &[], &[], Limits::default()); + assert_eq!(o.error, None); + o.value +} + +/// A short weekday typed without its dot ("mar" for French "mar.", Tuesday) is the weekday where +/// the format has one, not the English "Mar" (March) that is the only other name of its length: +/// "mar 6 janv 2026" read as no date in French and Romanian (#5303). +#[test] +fn a_short_weekday_typed_without_its_dot_is_the_weekday() { + let _names = names_lock(); + let mut bad = Vec::new(); + let cases = [ + ("fr", "ddd d mmm yyyy", "Mar 6 janv 2026", "2026-01-06"), + ("fr", "ddd d mmm yyyy", "mar 17 mars 2026", "2026-03-17"), + ("fr", "mmm d yyyy ddd", "janv 6 2026 mar", "2026-01-06"), + ("fr", "ddd dd/mm/yyyy", "mar 06/01/2026", "2026-01-06"), + ("ro", "ddd d mmm yyyy", "mar 6 ian 2026", "2026-01-06"), + ("ro", "ddd dd/mm/yyyy", "mar 06/01/2026", "2026-01-06"), + ]; + for (language, fmt, text, want) in cases { + use_form_date_language(language); + let got = scanned(fmt, text); + if got != want { + bad.push(format!("{language} scand {fmt}: {text:?} -> {got}")); + } + let read = parse_date(text, fmt).map(|d| format!("{:04}-{:02}-{:02}", d.y, d.m, d.d)); + if read.as_deref() != Some(want) { + bad.push(format!("{language} forms {fmt}: {text:?} -> {read:?}")); + } + } + use_form_date_language("en"); + assert!(bad.is_empty(), "{}", bad.join("; ")); +} + +/// A weekday left out of a format with one ("17 mar 2026" for "ddd d mmm yyyy") lets a name the +/// date language shares between a weekday and a month be the month: Italian, Spanish and +/// Albanian "mar" (Tuesday, March) and Albanian "sht" (Saturday, September) were read as the +/// weekday, leaving no month (#5303). Typed weekdays still go first. +#[test] +fn a_shared_name_is_the_month_when_the_weekday_is_left_out() { + let _names = names_lock(); + let mut bad = Vec::new(); + let cases = [ + ("it", "ddd d mmm yyyy", "17 Mar 2026", (2026, 3, 17)), + ("es", "ddd d mmm yyyy", "17 Mar 2026", (2026, 3, 17)), + ("sq", "ddd d mmm yyyy", "17 Mar 2026", (2026, 3, 17)), + ("it", "ddd d mmm yyyy", "17 mar 2026", (2026, 3, 17)), + ("it", "dddd, mmmm d, yyyy", "mar 17, 2026", (2026, 3, 17)), + ("it", "dddd, mmm d, yyyy", "mar 17 2026", (2026, 3, 17)), + ("sq", "ddd d mmm yyyy", "5 sht 2026", (2026, 9, 5)), + ("it", "ddd d mmm yyyy", "mar 17 Mar 2026", (2026, 3, 17)), + ("it", "ddd d mmm yyyy", "mar 17 mar 2026", (2026, 3, 17)), + ]; + for (language, fmt, text, want) in cases { + use_form_date_language(language); + let read = parse_date(text, fmt).map(|d| (d.y, d.m, d.d)); + if read != Some(want) { + bad.push(format!("{language} forms {fmt}: {text:?} -> {read:?}")); + } + if keystroke(&Format::Date(fmt.into()), "date", text).is_err() { + bad.push(format!("{language} keystroke {fmt}: {text:?} refused")); + } + } + use_form_date_language("en"); + assert!(bad.is_empty(), "{}", bad.join("; ")); +} + +/// Turkish names typed in capitals are read: lower-casing letter by letter made "MAYIS" "mayis" +/// (not "mayıs"), "SALI" "sali" and "EKİM" "eki̇m" (an i with a dot above), so no name matched. +/// Dotted, dotless and capital i are one letter when names are read (#5303). +#[test] +fn turkish_names_typed_in_capitals_are_read() { + let _names = names_lock(); + use_form_date_language("tr"); + let mut bad = Vec::new(); + for (fmt, text, want) in [ + ("d mmmm yyyy", "1 MAYIS 2026", (2026, 5, 1)), + ("d mmmm yyyy", "1 EKİM 2026", (2026, 10, 1)), + ("d mmmm yyyy", "1 Ekim 2026", (2026, 10, 1)), + ("d mmm yyyy", "1 EKİ 2026", (2026, 10, 1)), + ("dddd d mmmm yyyy", "SALI 6 OCAK 2026", (2026, 1, 6)), + ("dddd d mmmm yyyy", "Salı 6 Ocak 2026", (2026, 1, 6)), + ] { + let read = parse_date(text, fmt).map(|d| (d.y, d.m, d.d)); + if read != Some(want) { + bad.push(format!("forms {fmt}: {text:?} -> {read:?}")); + } + let (y, m, d) = want; + let got = scanned(fmt, text); + if got != format!("{y:04}-{m:02}-{d:02}") { + bad.push(format!("scand {fmt}: {text:?} -> {got}")); + } + } + // English is read as before, capitals and all. + use_form_date_language("en"); + for (fmt, text) in [("d mmmm yyyy", "1 APRIL 2026"), ("dddd d mmmm yyyy", "FRIDAY 1 MAY 2026"), ("d mmm yyyy", "1 MAY 2026")] { + if parse_date(text, fmt).is_none() || scanned(fmt, text) == "null" { + bad.push(format!("en {fmt}: {text:?}")); + } + } + assert!(bad.is_empty(), "{}", bad.join("; ")); +} + +/// #5464 review: Greek names typed in capitals end in a capital Σ, which is the final ς of the +/// name ("ΙΑΝΟΥΆΡΙΟΣ" for "Ιανουάριος"); they read as they did before names were folded. +#[test] +fn greek_names_typed_in_capitals_are_read() { + let _names = names_lock(); + use_form_date_language("el"); + let mut bad = Vec::new(); + for (fmt, text, want) in [ + ("d mmmm yyyy", "1 ΙΑΝΟΥΆΡΙΟΣ 2026", (2026, 1, 1)), + ("d mmmm yyyy", "1 Ιανουάριος 2026", (2026, 1, 1)), + ("d mmmm yyyy", "1 ιανουάριοσ 2026", (2026, 1, 1)), + ("mmmm d, yyyy", "ΜΆΙΟΣ 5, 2026", (2026, 5, 5)), + ("dddd, mmmm d, yyyy", "FRIDAY, ΙΑΝΟΥΆΡΙΟΣ 2, 2026", (2026, 1, 2)), + ] { + let read = parse_date(text, fmt).map(|d| (d.y, d.m, d.d)); + if read != Some(want) { + bad.push(format!("forms {fmt}: {text:?} -> {read:?}")); + } + let (y, m, d) = want; + let got = scanned(fmt, text); + if got != format!("{y:04}-{m:02}-{d:02}") { + bad.push(format!("scand {fmt}: {text:?} -> {got}")); + } + } + assert!(bad.is_empty(), "{}", bad.join("; ")); +} diff --git a/crates/forms/src/af.rs b/crates/forms/src/af.rs index 86c7971c31..f5167374d1 100644 --- a/crates/forms/src/af.rs +++ b/crates/forms/src/af.rs @@ -20,7 +20,10 @@ mod script; #[cfg(test)] use calc::{Notation, eval_notation, notation_name, notation_tokens, unique}; pub use calc::{calculate, calculation_reads, notation_js, reads_built_names, rename_in_calculation}; -pub use format::{DATE_PRESETS, DateTime, TIME_PRESETS, format_date, format_value, js_number, keystroke, make_number, parse_date, validate}; +pub use format::{ + DATE_PRESETS, DateNames, DateTime, ENGLISH_DATE_NAMES, TIME_PRESETS, date_names, format_date, format_date_in, format_value, format_value_in, + js_number, keystroke, make_number, parse_date, set_date_names, validate, +}; use script::{Arg, call}; pub use script::{NOTATION_NOT_STORABLE, calculate_js, format_js, notation_storable, parse_calculate, parse_format, parse_validate, validate_js}; diff --git a/crates/forms/src/af/format.rs b/crates/forms/src/af/format.rs index df4720bec7..37d2163cb0 100644 --- a/crates/forms/src/af/format.rs +++ b/crates/forms/src/af/format.rs @@ -89,6 +89,165 @@ const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "Jun 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 = 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 = 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, 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 = 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 = 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| { + 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 { + 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 { @@ -202,7 +361,20 @@ fn order(fmt: &str) -> Vec { /// 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 { - let t = text.trim(); + 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 { + let t = english.trim(); if t.is_empty() { return None; } @@ -223,10 +395,8 @@ pub fn parse_date(text: &str, fmt: &str) -> Option { } if cur.starts_with(|c: char| c.is_ascii_digit()) { nums.push_back(std::mem::take(cur)); - } else if cur.len() >= 3 - && let Some(i) = MONTHS.iter().position(|m| m.to_lowercase().starts_with(&cur.to_lowercase())) - { - *month = Some(i as u32 + 1); + } else if let Some(m) = month_named(cur, names) { + *month = Some(m); } cur.clear(); }; @@ -337,6 +507,11 @@ pub fn parse_date(text: &str, fmt: &str) -> Option { /// 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 = fmt.chars().collect(); let mut out = String::new(); let mut i = 0; @@ -354,9 +529,9 @@ pub fn format_date(dt: DateTime, fmt: &str) -> String { ('y', _) => { let _ = write!(out, "{:02}", dt.y.rem_euclid(100)); } - ('m', 3..) => match MONTHS.get((dt.m as usize).wrapping_sub(1)) { - Some(name) if n >= 4 => out.push_str(name), - Some(name) => out.push_str(name.get(..3).unwrap_or(name)), + ('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); } @@ -368,8 +543,8 @@ pub fn format_date(dt: DateTime, fmt: &str) -> String { let _ = write!(out, "{}", dt.m); } ('d', 3..) => { - let name = DAYS.get(weekday(dt.y, dt.m, dt.d)).copied().unwrap_or_default(); - out.push_str(if n >= 4 { name } else { name.get(..3).unwrap_or(name) }); + 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); @@ -451,6 +626,11 @@ fn apply_mask(mask: &str, text: &str) -> Option { /// 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(); } @@ -479,7 +659,7 @@ pub fn format_value(f: &Format, value: &str) -> String { ), None => value.to_string(), }, - Format::Date(fmt) | Format::Time(fmt) => parse_date(value, fmt).map_or_else(|| value.to_string(), |dt| format_date(dt, fmt)), + 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()), } diff --git a/crates/forms/src/appearance.rs b/crates/forms/src/appearance.rs index c9ff86d17a..8403b729d9 100644 --- a/crates/forms/src/appearance.rs +++ b/crates/forms/src/appearance.rs @@ -739,8 +739,18 @@ pub fn value_overflow(doc: &Document, f: &Field, w: &Widget) -> Option field_appearance_fit(doc, f, w, &f.value, true).2 } +/// Whether `text` draws in widget `wd` of `f` with no `?`: in the field's `/DR` font when that +/// holds it, as [`field_appearance_fit`] picks, or else in the Helvetica (WinAnsi) stand-in. +fn draws(doc: &Document, f: &Field, wd: &Dict, text: &str) -> bool { + let da = parse_da(wd.get(b"DA").and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text())).as_deref().unwrap_or(&f.da)); + let enc = dr_font(doc, &da.font).map(|(_, enc)| enc).filter(|enc| enc.can_encode(text)).unwrap_or(FontText::WinAnsi); + enc.first_unencodable(text).is_none() +} + /// [`field_appearance_drawn`], and whether the text drawn is cut off by the box. fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String], format: bool) -> (Stream, Option, Option) { + let wobj = doc.get(w.obj); + let wd = wobj.as_dict().cloned().unwrap_or_default(); // The Format event: what is shown, not what is stored. let formatted: Vec; let values = if format @@ -748,13 +758,16 @@ fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String] && values.len() == 1 && f.actions.format != crate::af::Format::None { - formatted = vec![crate::af::format_value(&f.actions.format, &values[0])]; + let shown = crate::af::format_value(&f.actions.format, &values[0]); + // Month and weekday names in the date language that no font here can draw ("окт." in + // Helvetica) are drawn in English rather than as `?` (#5303). + let english = || crate::af::format_value_in(&f.actions.format, &values[0], &crate::af::ENGLISH_DATE_NAMES); + let dated = matches!(f.actions.format, crate::af::Format::Date(_) | crate::af::Format::Time(_)); + formatted = vec![if !dated || draws(doc, f, &wd, &shown) { shown } else { english() }]; &formatted[..] } else { values }; - let wobj = doc.get(w.obj); - let wd = wobj.as_dict().cloned().unwrap_or_default(); let (page_w, page_h) = ((w.rect[2] - w.rect[0]).max(1.0), (w.rect[3] - w.rect[1]).max(1.0)); let place = placement(mk_rotation(doc, &wd), page_w, page_h); let (width, height) = (place.layout_w, place.layout_h); diff --git a/crates/forms/src/scripting.rs b/crates/forms/src/scripting.rs index 7dd7bfde6c..31e6812e6b 100644 --- a/crates/forms/src/scripting.rs +++ b/crates/forms/src/scripting.rs @@ -60,6 +60,21 @@ pub struct ScriptResult { pub trait Scripts { /// Run `script` for `event` on `target`, whose value is `value`; `fields` is the whole form. fn run(&mut self, event: FieldEvent, script: &str, target: &Field, value: &str, fields: &[Field]) -> ScriptResult; + + /// [`Self::run`] again for an appearance, with dates written in English names (`util.printd`) + /// because no font of the field can draw the date language's (#5303); what it logs was + /// already logged by the first run. `None` when it can't: no English dates here, or the + /// script failed. What it returns is what the script made, even when that is the value. + fn rerun_with_english_dates( + &mut self, + _event: FieldEvent, + _script: &str, + _target: &Field, + _value: &str, + _fields: &[Field], + ) -> Option { + None + } } /// No script engine (or JavaScript turned off): scripts change nothing. @@ -98,6 +113,13 @@ pub(crate) fn formatted(doc: &Document, f: &Field, value: &str, scripts: &mut dy Some(r.value) } +/// [`formatted`] with dates in English names, for widgets whose fonts can't draw the date +/// language's; `None` when the field has no Format script or the English run didn't happen. +pub(crate) fn formatted_in_english(doc: &Document, f: &Field, value: &str, scripts: &mut dyn Scripts) -> Option { + let js = f.actions.scripts.format.as_ref()?; + scripts.rerun_with_english_dates(FieldEvent::Format, js, f, value, &fields(doc)).map(|r| r.value) +} + /// Apply script changes to other fields (values written without re-running their scripts; /// read-only, required and visibility as flags). `except` is the event's own field, whose value /// the event sets. diff --git a/crates/forms/src/values.rs b/crates/forms/src/values.rs index cf191c2240..9d670e62db 100644 --- a/crates/forms/src/values.rs +++ b/crates/forms/src/values.rs @@ -426,9 +426,24 @@ fn redraw(doc: &mut Document, f: &Field, values: &[String], scripts: &mut dyn Sc }; let in_place = f.kind == FieldKind::Text && f.has(flags::PASSWORD); let mut undrawn = false; + // The Format script again with English month and weekday names, once, for widgets whose + // fonts can't draw the date language's (#5303). + let mut english: Option> = None; for w in &f.widgets { let (stream, missing) = match &shown { - Some(s) => appearance::field_appearance_drawn(doc, f, w, std::slice::from_ref(s), false), + Some(s) => { + let drawn = appearance::field_appearance_drawn(doc, f, w, std::slice::from_ref(s), false); + // Taken when the English run happened and draws whole, even where it is the + // stored value ("18-Oct-26" printed again from itself). + let again = match (&drawn.1, values) { + (Some(_), [one]) => english.get_or_insert_with(|| scripting::formatted_in_english(doc, f, one, scripts)).as_ref(), + _ => None, + }; + match again.map(|e| appearance::field_appearance_drawn(doc, f, w, std::slice::from_ref(e), false)) { + Some(english) if english.1.is_none() => english, + _ => drawn, + } + } None => appearance::field_appearance_drawn(doc, f, w, values, true), }; undrawn |= missing.is_some(); diff --git a/crates/js/src/format.rs b/crates/js/src/format.rs index 7f207b1916..26e2fad989 100644 --- a/crates/js/src/format.rs +++ b/crates/js/src/format.rs @@ -134,6 +134,177 @@ const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "Jun const DAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; +/// Month and weekday names (weekdays from Sunday): full, then short. +type Names = ([&'static str; 12], [&'static str; 12], [&'static str; 7], [&'static str; 7]); + +const ENGLISH: Names = ( + MONTHS, + ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"], + DAYS, + ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"], +); + +static NAMES: std::sync::RwLock = std::sync::RwLock::new(ENGLISH); + +/// Sets the month and weekday names `util.printd` writes for `mmmm`, `mmm`, `dddd` and `ddd` +/// (weekdays from Sunday), as a localized Acrobat writes its own language's (#5303). +/// `util.scand` reads them as well as English. +pub fn set_date_names(months: [&'static str; 12], months_short: [&'static str; 12], days: [&'static str; 7], days_short: [&'static str; 7]) { + *NAMES.write().unwrap_or_else(std::sync::PoisonError::into_inner) = (months, months_short, days, days_short); +} + +/// `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 = text.chars().collect(); + let typed_english: [&[&str]; 4] = [&ENGLISH.0, &ENGLISH.1, &ENGLISH.2, &ENGLISH.3]; + // Each name as folded chars, whether it is a month, and its English name. + let mut names: Vec<(Vec, 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 = 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 = 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| { + 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; + +fn names() -> Names { + *NAMES.read().unwrap_or_else(std::sync::PoisonError::into_inner) +} + +std::thread_local! { + /// Whether `util.printd` on this thread writes English names, whatever the date language. + static PRINTD_ENGLISH: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +/// Runs `run` with `util.printd` writing English month and weekday names when `english` (scripts +/// it starts carry this to their own thread): for an appearance whose fonts can't draw the date +/// language's names, which would come out as `?` (#5303). `util.scand` still reads both. +pub fn with_english_printd(english: bool, run: impl FnOnce() -> R) -> R { + struct Restore(bool); + impl Drop for Restore { + fn drop(&mut self) { + PRINTD_ENGLISH.with(|e| e.set(self.0)); + } + } + let _restore = Restore(PRINTD_ENGLISH.with(|e| e.replace(english))); + run() +} + +/// Whether [`with_english_printd`] is on for this thread. +pub(crate) fn printd_english() -> bool { + PRINTD_ENGLISH.with(std::cell::Cell::get) +} + +/// The names `util.printd` writes. +fn printd_names() -> Names { + if printd_english() { ENGLISH } else { names() } +} + /// `util.printd(cFormat, oDate)`: yyyy yy mmmm mmm mm m dddd ddd dd d HH H hh h MM M ss s tt, /// and the numeric formats 0 (D:yyyymmddHHMMss), 1 (yyyy.mm.dd HH:MM:ss) and 2 (m/d/yy h:MM:ss tt). pub(crate) fn printd(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { @@ -161,6 +332,7 @@ pub(crate) fn printd(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResu let (y, mo, day, wd, h, mi, sec) = (part("getFullYear")?, part("getMonth")?, part("getDate")?, part("getDay")?, part("getHours")?, part("getMinutes")?, part("getSeconds")?); let mut out = String::new(); + let (months, months_short, days, days_short) = printd_names(); let b = fmt.as_bytes(); let mut i = 0; while i < b.len() { @@ -186,13 +358,13 @@ pub(crate) fn printd(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResu } } b'm' => match run { - r if r >= 4 => out.push_str(MONTHS[mo as usize % 12]), - 3 => out.push_str(&MONTHS[mo as usize % 12][..3]), + r if r >= 4 => out.push_str(months.get(mo.rem_euclid(12) as usize).copied().unwrap_or_default()), + 3 => out.push_str(months_short.get(mo.rem_euclid(12) as usize).copied().unwrap_or_default()), n => push_num(&mut out, mo + 1, n), }, b'd' => match run { - r if r >= 4 => out.push_str(DAYS[wd as usize % 7]), - 3 => out.push_str(&DAYS[wd as usize % 7][..3]), + r if r >= 4 => out.push_str(days.get(wd.rem_euclid(7) as usize).copied().unwrap_or_default()), + 3 => out.push_str(days_short.get(wd.rem_euclid(7) as usize).copied().unwrap_or_default()), n => push_num(&mut out, day, n), }, b'H' => push_num(&mut out, h, run), @@ -435,11 +607,13 @@ fn scan_date(fmt: &str, text: &str, this_year: i32) -> Option { p += 1; } (ScanPart::Field { unit: DateUnit::MonthName, .. }, ScanToken::Letters(l)) => { - v.mo = Some(named(&MONTHS, l)?.saturating_add(1)); + let (months, months_short, ..) = names(); + v.mo = Some(named(&months, l).or_else(|| named(&months_short, l)).or_else(|| named(&MONTHS, l))?.saturating_add(1)); p += 1; } (ScanPart::Field { unit: DateUnit::Weekday, .. }, ScanToken::Letters(l)) => { - named(&DAYS, l)?; + let (.., days, days_short) = names(); + named(&days, l).or_else(|| named(&days_short, l)).or_else(|| named(&DAYS, l))?; p += 1; } (ScanPart::Field { unit: DateUnit::AmPm, .. }, ScanToken::Letters(l)) => { @@ -509,10 +683,11 @@ fn scan_date(fmt: &str, text: &str, this_year: i32) -> Option { /// The index of the name `word` is, in full or as its first three letters, in any case. fn named(names: &[&str], word: &str) -> Option { - let word = word.to_lowercase(); + let word = folded(word); let i = names.iter().position(|n| { - let n = n.to_lowercase(); - word == n || n.get(..3) == Some(word.as_str()) + let n = folded(n); + // A short name may end in a dot ("Okt."); three letters may be cut from any name. + word == n || word == n.trim_end_matches('.') || n.chars().take(3).collect::() == word })?; u32::try_from(i).ok() } @@ -524,6 +699,8 @@ pub(crate) fn scand(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResul return Ok(JsValue::null()); }; let fmt = text(&arg(args, 0), ctx)?; + let (months, months_short, days, days_short) = names(); + let typed = english_names(&typed, &fmt, [&months, &months_short, &days, &days_short]); let date = JsDate::new(ctx); let this_year = date.get_full_year(ctx)?.to_number(ctx)?; let this_year = if this_year.is_finite() { this_year as i32 } else { 2000 }; diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index 93f57e7528..0bf4032efd 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -44,6 +44,7 @@ mod run; #[cfg(test)] pub(crate) use format::PRINTD_SCANNED; use format::{printd, printf, printx, scand}; +pub use format::{set_date_names, with_english_printd}; pub(crate) use globals::menu_item; use globals::platform; use host::{arg, as_number, error, function, s, text}; diff --git a/crates/js/src/run.rs b/crates/js/src/run.rs index 07c0e984bb..ab7e32ed53 100644 --- a/crates/js/src/run.rs +++ b/crates/js/src/run.rs @@ -129,11 +129,13 @@ pub fn run_shared(script: &str, event: &Event, doc: &DocInfo, fields: Arc thread.join().unwrap_or_else(|_| failed("the script stopped with an internal error".into())), Err(e) => failed(format!("the script engine could not start: {e}")), diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 59026c26aa..fd8d4a8096 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -452,6 +452,9 @@ impl eframe::App for PdfCraftApp { // Notices raised outside `ui` (opened files, OS events, the control channel) translate too. let lang = i18n::Lang::from_pref(&self.language); i18n::set_current(lang); + // Form date fields name months in the date language (Preferences ▸ Date format), which + // follows the interface language until one is picked (#5303). + pdfcraft_engine::dates::use_form_date_language(self.session.date_language().unwrap_or(lang.code())); #[cfg(not(target_arch = "wasm32"))] { if let Some(worker) = &self.open_worker {