mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-05 18:19:50 +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>
This commit is contained in:
12 files changed
+932
-32
No files matched your search
@@ -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.
|
||||
|
||||
+25
-6
@@ -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<ScriptResult> {
|
||||
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<ScriptResult> {
|
||||
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(),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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::<Vec<_>>().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::<Vec<_>>().join(" ");
|
||||
let mut objs: Vec<String> = 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<u8> {
|
||||
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<u8> {
|
||||
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("; "));
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
|
||||
+191
-11
@@ -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<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 {
|
||||
@@ -202,7 +361,20 @@ fn order(fmt: &str) -> Vec<Part> {
|
||||
/// 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 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<DateTime> {
|
||||
let t = english.trim();
|
||||
if t.is_empty() {
|
||||
return None;
|
||||
}
|
||||
@@ -223,10 +395,8 @@ pub fn parse_date(text: &str, fmt: &str) -> Option<DateTime> {
|
||||
}
|
||||
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<DateTime> {
|
||||
|
||||
/// 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;
|
||||
@@ -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<String> {
|
||||
|
||||
/// 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()),
|
||||
}
|
||||
|
||||
@@ -739,8 +739,18 @@ pub fn value_overflow(doc: &Document, f: &Field, w: &Widget) -> Option<Overflow>
|
||||
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<char>, Option<Overflow>) {
|
||||
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<String>;
|
||||
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);
|
||||
|
||||
@@ -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<ScriptResult> {
|
||||
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<String> {
|
||||
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.
|
||||
|
||||
@@ -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<Option<String>> = 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();
|
||||
|
||||
+186
-9
@@ -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<Names> = 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<char> = 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<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;
|
||||
|
||||
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<bool> = 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<R>(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<JsValue> {
|
||||
@@ -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<Scanned> {
|
||||
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<Scanned> {
|
||||
|
||||
/// 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<u32> {
|
||||
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::<String>() == 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 };
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -129,11 +129,13 @@ pub fn run_shared(script: &str, event: &Event, doc: &DocInfo, fields: Arc<Vec<Fi
|
||||
let (kept, skipped) = (&kept, &skipped);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
// The script's thread writes dates as this one asked (`with_english_printd`).
|
||||
let english = crate::format::printd_english();
|
||||
std::thread::scope(|scope| {
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("pdfcraft-js".into())
|
||||
.stack_size(SCRIPT_STACK)
|
||||
.spawn_scoped(scope, || run_here(script, event, doc, fields, kept, skipped, limits));
|
||||
.spawn_scoped(scope, || crate::format::with_english_printd(english, || run_here(script, event, doc, fields, kept, skipped, limits)));
|
||||
match spawned {
|
||||
Ok(thread) => 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}")),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user