diff --git a/crates/js/src/formcalc.rs b/crates/js/src/formcalc.rs index c4925aa26..7b0d0e88a 100644 --- a/crates/js/src/formcalc.rs +++ b/crates/js/src/formcalc.rs @@ -1948,7 +1948,7 @@ fn parse_date(text: &str, picture: &str) -> Option<(i64, u32, u32)> { } /// Number of days in month `m` (1-12) of year `y`, leap years included. -fn days_in(y: i64, m: u32) -> u32 { +pub(crate) fn days_in(y: i64, m: u32) -> u32 { // The length of the month from chrono (the first of the next month, less the first of this // one). An invalid month keeps the old 31-day default. // Month lengths repeat every 400 years, so the year within its cycle is enough (and always diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index f6d5a076c..777a13f31 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -14,7 +14,7 @@ //! `isBoxChecked`, `checkThisBox`, `exportValues`, `charLimit`, `textColor`, `fillColor`, //! `setFocus`. //! - `app` (`alert`, `beep`, `response`, `launchURL`, `viewerType`, `viewerVersion`, -//! `platform`, `setTimeOut`), `util` (`printf`, `printd`, `printx`), `console` (`println`, +//! `platform`, `setTimeOut`), `util` (`printf`, `printd`, `printx`, `scand`), `console` (`println`, //! `show`, `clear`), `display` and `color`. //! //! The engine is a sandbox: no file, network or timer access exists, and loops and recursion @@ -28,7 +28,7 @@ use std::collections::BTreeSet; use std::sync::Arc; use boa_engine::context::HostHooks; -use boa_engine::object::builtins::{JsArray, JsFunction}; +use boa_engine::object::builtins::{JsArray, JsDate, JsFunction}; use boa_engine::object::{FunctionObjectBuilder, ObjectInitializer}; use boa_engine::property::Attribute; use boa_engine::realm::Realm; @@ -888,6 +888,324 @@ fn printd(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult Ok(s(&out)) } +/// A field of a `util.scand` format. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum DateUnit { + /// `yyyy`: four digits. + Year, + /// `yy`: two digits, read as 1950–2049. + ShortYear, + Month, + /// `mmm`, `mmmm`: a month's name or its three-letter abbreviation. + MonthName, + Day, + /// `ddd`, `dddd`: a weekday's name, read and not checked. + Weekday, + Hour, + /// `h`, `hh`: 0–12, with `tt` for the afternoon. + Hour12, + Minute, + Second, + /// `t`, `tt`: am or pm. + AmPm, +} + +impl DateUnit { + /// A year, month or day: a format needs one of these for `util.scand` to read anything. + fn is_date(self) -> bool { + matches!(self, Self::Year | Self::ShortYear | Self::Month | Self::MonthName | Self::Day) + } + + /// Read from a run of digits, not from letters. + fn is_numeric(self) -> bool { + !matches!(self, Self::MonthName | Self::Weekday | Self::AmPm) + } +} + +/// A piece of a `util.scand` format. +#[derive(Debug, PartialEq, Eq)] +enum ScanPart { + /// A field, the digits it takes when typed together with the next one (`width`), and whether + /// the next one follows it with nothing between them in the format (`joined`). + Field { unit: DateUnit, width: usize, joined: bool }, + /// Letters the format writes as they are, which the text must have there. + Word(String), +} + +/// The fields and literal words of a `util.scand` format, in order. A numeric field is marked +/// `joined` when another numeric field follows it directly; anything but letters and digits +/// outside a field is left out. +fn scan_parts(fmt: &str) -> Vec { + let chars: Vec = fmt.chars().collect(); + let mut parts: Vec = Vec::new(); + // The last part is a field written right before this point, or letters still being collected. + let (mut after_field, mut in_word) = (false, false); + let mut i = 0; + while let Some(&c) = chars.get(i) { + // Counted for field letters only, so a long run of anything else is read once. + let run = if "ymdHhMst".contains(c) { chars.get(i..).unwrap_or_default().iter().take_while(|x| **x == c).count() } else { 1 }; + let field = match (c, run) { + ('y', 4..) => Some((DateUnit::Year, 4)), + ('y', _) => Some((DateUnit::ShortYear, 2)), + ('m', 3..) => Some((DateUnit::MonthName, 0)), + ('m', n) => Some((DateUnit::Month, n)), + ('d', 3..) => Some((DateUnit::Weekday, 0)), + ('d', n) => Some((DateUnit::Day, n)), + ('H', n) => Some((DateUnit::Hour, n.min(2))), + ('h', n) => Some((DateUnit::Hour12, n.min(2))), + ('M', n) => Some((DateUnit::Minute, n.min(2))), + ('s', n) => Some((DateUnit::Second, n.min(2))), + ('t', _) => Some((DateUnit::AmPm, 0)), + _ => None, + }; + if let Some((unit, width)) = field { + if after_field + && unit.is_numeric() + && let Some(ScanPart::Field { unit: last, joined, .. }) = parts.last_mut() + && last.is_numeric() + { + *joined = true; + } + parts.push(ScanPart::Field { unit, width, joined: false }); + (after_field, in_word) = (true, false); + i += run; + continue; + } + // `\` writes the next character as it is. + let (literal, step) = if c == '\\' { (chars.get(i + 1).copied(), 2) } else { (Some(c), 1) }; + i += step; + after_field = false; + match literal { + Some(l) if l.is_alphanumeric() => match parts.last_mut() { + Some(ScanPart::Word(w)) if in_word => w.push(l), + _ => { + parts.push(ScanPart::Word(l.to_string())); + in_word = true; + } + }, + _ => in_word = false, + } + } + parts +} + +/// A run of digits or of letters in the text `util.scand` reads. +enum ScanToken<'a> { + Digits(&'a str), + Letters(&'a str), +} + +/// The text's runs of digits and of letters, in order; anything else only separates them, and +/// may not begin or end the text. `None` when it does. +fn scan_tokens(text: &str) -> Option>> { + // Some(true) for a digit, Some(false) for a letter, None for a separator. + let kind = |c: char| (c.is_ascii_digit() || c.is_alphabetic()).then_some(c.is_ascii_digit()); + kind(text.chars().next()?)?; + kind(text.chars().next_back()?)?; + let mut out = Vec::new(); + let mut start: Option<(usize, bool)> = None; + for (at, c) in text.char_indices().chain(std::iter::once((text.len(), ' '))) { + let k = kind(c); + if let Some((from, digits)) = start + && k != Some(digits) + { + let piece = text.get(from..at)?; + out.push(if digits { ScanToken::Digits(piece) } else { ScanToken::Letters(piece) }); + start = None; + } + if let Some(digits) = k + && start.is_none() + { + start = Some((at, digits)); + } + } + Some(out) +} + +/// What `util.scand` reads. +#[derive(Debug, PartialEq, Eq)] +enum Scanned { + /// The text is empty: today, at noon. + Today, + /// A date and time: year, month (1–12), day, hours, minutes, seconds. + At(i32, u32, u32, u32, u32, u32), +} + +/// The fields `util.scand` has read so far. +#[derive(Default)] +struct ScanValues { + y: Option, + mo: Option, + d: Option, + h: Option, + mi: Option, + sec: Option, + pm: Option, + /// The hour was read from `h` or `hh`. + twelve: bool, +} + +impl ScanValues { + /// Records `v` as the value of `unit`: a `yy` year as 1950–2049, and an `h` or `hh` hour + /// marked as 12-hour. Month names, weekdays and am/pm are not read here. + fn set(&mut self, unit: DateUnit, v: u32) { + match unit { + DateUnit::Year => self.y = i32::try_from(v).ok(), + DateUnit::ShortYear => self.y = i32::try_from(v).ok().map(|v| if v < 50 { 2000 + v } else { 1900 + v }), + DateUnit::Month => self.mo = Some(v), + DateUnit::Day => self.d = Some(v), + DateUnit::Hour => self.h = Some(v), + DateUnit::Hour12 => { + self.h = Some(v); + self.twelve = true; + } + DateUnit::Minute => self.mi = Some(v), + DateUnit::Second => self.sec = Some(v), + DateUnit::MonthName | DateUnit::Weekday | DateUnit::AmPm => {} + } + } +} + +/// Reads `text` by a `util.scand` format, as another viewer was observed to (black-box): +/// +/// - Fields may be separated by anything but digits and letters, whether or not the format +/// separates them, but nothing may come before the first or after the last. +/// - Digits typed together fill the format's fields written together with exactly as many +/// digits as each has letters (`yyyymd`: 4, 1 and 1). A run of digits for one field alone may +/// have one or two, except a four-digit year. +/// - `yy` is 1950–2049; month names may be written in full or as three letters, in any case. +/// - Impossible dates are refused, but a month or day of 0 is read as 1. +/// - A format with no year, month or day reads nothing; an empty text is today, at noon. +/// +/// Not observed: a year left out is `this_year`, and a month or day left out is 1. +fn scan_date(fmt: &str, text: &str, this_year: i32) -> Option { + let parts = scan_parts(fmt); + if !parts.iter().any(|p| matches!(p, ScanPart::Field { unit, .. } if unit.is_date())) { + return None; + } + if text.is_empty() { + return Some(Scanned::Today); + } + let tokens = scan_tokens(text)?; + let mut v = ScanValues::default(); + let (mut p, mut t) = (0, 0); + while let Some(part) = parts.get(p) { + let token = tokens.get(t)?; + t += 1; + match (part, token) { + (ScanPart::Word(w), ScanToken::Letters(l) | ScanToken::Digits(l)) => { + if w.to_lowercase() != l.to_lowercase() { + return None; + } + p += 1; + } + (ScanPart::Field { unit: DateUnit::MonthName, .. }, ScanToken::Letters(l)) => { + v.mo = Some(named(&MONTHS, l)?.saturating_add(1)); + p += 1; + } + (ScanPart::Field { unit: DateUnit::Weekday, .. }, ScanToken::Letters(l)) => { + named(&DAYS, l)?; + p += 1; + } + (ScanPart::Field { unit: DateUnit::AmPm, .. }, ScanToken::Letters(l)) => { + v.pm = Some(match l.to_lowercase().as_str() { + "am" | "a" => false, + "pm" | "p" => true, + _ => return None, + }); + p += 1; + } + (ScanPart::Field { unit, width, joined }, ScanToken::Digits(run)) if unit.is_numeric() => { + // The fields written together from here, no more than the run's digits can fill, + // and the fewest of them the run fills. + let mut group = vec![(*unit, *width)]; + let (mut more, mut widths) = (*joined, *width); + while more + && widths < run.len() + && let Some(ScanPart::Field { unit, width, joined }) = parts.get(p + group.len()) + { + group.push((*unit, *width)); + widths = widths.saturating_add(*width); + more = *joined; + } + let mut sum = 0; + let fills = group.iter().position(|(_, w)| { + sum += w; + sum == run.len() + }); + if let Some(last) = fills { + let mut at = 0; + for (unit, w) in group.iter().take(last + 1) { + v.set(*unit, run.get(at..at + w)?.parse().ok()?); + at += w; + } + p += last + 1; + } else if *unit != DateUnit::Year && run.len() <= 2 { + v.set(*unit, run.parse().ok()?); + p += 1; + } else { + return None; + } + } + _ => return None, + } + } + if t != tokens.len() { + return None; + } + let y = v.y.unwrap_or(this_year); + let mo = v.mo.unwrap_or(1).max(1); + let d = v.d.unwrap_or(1).max(1); + if mo > 12 || i64::from(d) > i64::from(formcalc::days_in(i64::from(y), mo)) { + return None; + } + let mut h = v.h.unwrap_or(0); + if v.twelve && h > 12 { + return None; + } + match v.pm { + Some(true) if h < 12 => h += 12, + Some(false) if h == 12 => h = 0, + _ => {} + } + let (mi, sec) = (v.mi.unwrap_or(0), v.sec.unwrap_or(0)); + (h <= 23 && mi <= 59 && sec <= 59).then_some(Scanned::At(y, mo, d, h, mi, sec)) +} + +/// 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 i = names.iter().position(|n| { + let n = n.to_lowercase(); + word == n || n.get(..3) == Some(word.as_str()) + })?; + u32::try_from(i).ok() +} + +/// `util.scand(cFormat, cDate)`: the date `cDate` gives by a `util.printd` format, as a local +/// `Date`, or `null` when it doesn't read as one (see [`scan_date`]). Only text is read. +fn scand(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let Some(typed) = arg(args, 1).as_string().map(|t| t.to_std_string_escaped()) else { + return Ok(JsValue::null()); + }; + let fmt = text(&arg(args, 0), ctx)?; + 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 }; + match scan_date(&fmt, &typed, this_year) { + None => return Ok(JsValue::null()), + Some(Scanned::Today) => { + date.set_hours(&[12.into(), 0.into(), 0.into(), 0.into()], ctx)?; + } + Some(Scanned::At(y, mo, d, h, mi, sec)) => { + // setFullYear, not the constructor, so years 0–99 stay as they are. + date.set_full_year(&[y.into(), (mo.saturating_sub(1)).into(), d.into()], ctx)?; + date.set_hours(&[h.into(), mi.into(), sec.into(), 0.into()], ctx)?; + } + } + Ok(date.into()) +} + /// `util.printx(cFormat, cSource)`: `?` any character, `X` letter or digit, `A` letter, `9` /// digit, `*` the rest, `\` escape, `>` / `<` / `=` upper / lower / as is. fn printx(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { @@ -979,8 +1297,10 @@ fn color_object(ctx: &mut Context) -> JsObject { /// The XFA version scripts see as `xfa_version`. const XFA_VERSION: f64 = 3.5; -/// Define the globals every script sees: the document's functions and properties (`getField`, -/// `numPages`, `info`, the XFA version), and `app`, `util`, `console`, `display` and `color`. +/// Defines the form object model's globals: the document's functions (`getField`, `resetForm`, +/// `print`, `submitForm` and others) and properties (`numFields`, `pageNum`, `numPages`, +/// `documentFileName`, `info`, the XFA version), and the `app`, `util`, `console`, `display` and +/// `color` objects. fn install(ctx: &mut Context) -> JsResult<()> { let globals: [(&str, usize, NativeFunction); 9] = [ ("getField", 1, NativeFunction::from_fn_ptr(get_field)), @@ -1044,6 +1364,7 @@ fn install(ctx: &mut Context) -> JsResult<()> { .function(NativeFunction::from_fn_ptr(printf), js_string!("printf"), 1) .function(NativeFunction::from_fn_ptr(printd), js_string!("printd"), 2) .function(NativeFunction::from_fn_ptr(printx), js_string!("printx"), 2) + .function(NativeFunction::from_fn_ptr(scand), js_string!("scand"), 2) .build(); ctx.register_global_property(js_string!("util"), util, rw)?; let console = ObjectInitializer::new(ctx) diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index f95015247..358df9836 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -1520,6 +1520,203 @@ fn shared_field_snapshots_isolate_writes_errors_and_script_globals() { assert!(read.changed.is_empty()); } +mod scand { + use super::*; + use crate::xfa::{XfaDoc, XfaEvent, run_xfa}; + + /// Runs `util.scand(format, text)` for each case and checks the date it gives, written as + /// `yyyy-mm-dd HH:MM:ss`, or `null`. + fn assert_scand(cases: &[(&str, &str, &str)]) { + let list: Vec = cases.iter().map(|(f, t, _)| format!("['{f}', '{t}']")).collect(); + let script = format!( + "event.value = [{}].map(function (c) {{ var d = util.scand(c[0], c[1]); \ + return d === null ? 'null' : util.printd('yyyy-mm-dd HH:MM:ss', d); }}).join('|');", + list.join(", ") + ); + let o = go(&script, &Event::field("Calculate", "total", "")); + assert_eq!(o.error, None); + let got: Vec<&str> = o.value.split('|').collect(); + assert_eq!(got.len(), cases.len()); + for ((f, t, want), got) in cases.iter().zip(got) { + assert_eq!(got, *want, "util.scand('{f}', '{t}')"); + } + } + + // The cases below are another viewer's answers, observed black-box with a synthetic file. + + /// Any separator may come between fields, whether the format has that one, another or none, + /// but a text with a space or a letter before or after the date reads nothing. + #[test] + fn scand_allows_separators_between_fields_but_nothing_before_or_after_them() { + assert_scand(&[ + ("yyyymmdd", "20261018", "2026-10-18 00:00:00"), + ("yyyymmdd", "2026-10-18", "2026-10-18 00:00:00"), + ("yyyymmdd", "2026 10 18", "2026-10-18 00:00:00"), + ("mm/dd/yyyy", "10/18/2026", "2026-10-18 00:00:00"), + ("mm/dd/yyyy", "10-18-2026", "2026-10-18 00:00:00"), + ("yyyymmdd", " 20261018 ", "null"), + ("yyyymmdd", "20261018x", "null"), + ("yyyymmdd", "x20261018", "null"), + ]); + } + + /// Digits typed together fill the joined fields with exactly as many digits as each field has + /// letters, so a run of any other length reads nothing. + #[test] + fn scand_splits_digits_typed_together_by_the_formats_letter_counts() { + assert_scand(&[ + ("yyyymmdd", "261018", "null"), + ("yyyymmdd", "2026118", "null"), + ("yyyymmdd", "202610181", "null"), + ("yymmdd", "20261018", "null"), + ("yyyymdd", "2026118", "2026-01-18 00:00:00"), + ("yyyymmd", "2026118", "2026-11-08 00:00:00"), + ("yyyymd", "2026118", "null"), + ("yyyymd", "202618", "2026-01-08 00:00:00"), + ("yyyymd", "20261018", "null"), + ("yymd", "2618", "2026-01-08 00:00:00"), + ("yymd", "261018", "null"), + ]); + } + + /// A month past 12, a day past the month's end and February 29 outside a leap year are + /// refused, while a month or day of 0 is read as 1. + #[test] + fn scand_refuses_impossible_dates_and_reads_a_zero_month_or_day_as_1() { + assert_scand(&[ + ("yyyymmdd", "20261318", "null"), + ("yyyymmdd", "20260230", "null"), + ("yyyymmdd", "20260229", "null"), + ("yyyymmdd", "20240229", "2024-02-29 00:00:00"), + ("mm/dd/yyyy", "13/45/2026", "null"), + ("yyyymmdd", "20260018", "2026-01-18 00:00:00"), + ("yyyymmdd", "20261000", "2026-10-01 00:00:00"), + ]); + } + + /// A field typed by itself may have one or two digits, but a `yyyy` year needs four; `yy` reads + /// as 1950–2049; month names may be full or three letters in any case; weekday names are read. + #[test] + fn scand_reads_lone_fields_two_digit_years_and_month_and_weekday_names() { + assert_scand(&[ + ("mm/dd/yyyy", "1/5/2026", "2026-01-05 00:00:00"), + ("m/d/yyyy", "1/5/2026", "2026-01-05 00:00:00"), + ("m/d/yyyy", "01/05/2026", "2026-01-05 00:00:00"), + ("m/d/yy", "1/5/26", "2026-01-05 00:00:00"), + ("mm/dd/yyyy", "10/18/26", "null"), + ("yyyy/mm/dd", "26/10/18", "null"), + ("yymmdd", "261018", "2026-10-18 00:00:00"), + ("yymmdd", "491018", "2049-10-18 00:00:00"), + ("yymmdd", "501018", "1950-10-18 00:00:00"), + ("mmm d, yyyy", "Oct 18, 2026", "2026-10-18 00:00:00"), + ("mmm d, yyyy", "October 18, 2026", "2026-10-18 00:00:00"), + ("mmm d, yyyy", "oct 18, 2026", "2026-10-18 00:00:00"), + ("mmmm d, yyyy", "Oct 18, 2026", "2026-10-18 00:00:00"), + ("mmmm d, yyyy", "October 18, 2026", "2026-10-18 00:00:00"), + ("dd-mmm-yyyy", "18-Oct-2026", "2026-10-18 00:00:00"), + ("dddd, mmmm d, yyyy", "Sunday, October 18, 2026", "2026-10-18 00:00:00"), + ]); + } + + /// A time is read along with a date, but a format with only time fields reads nothing. + #[test] + fn scand_reads_a_time_only_with_a_date() { + assert_scand(&[ + ("mm/dd/yyyy HH:MM:ss", "10/18/2026 21:07:05", "2026-10-18 21:07:05"), + ("HH:MM", "09:07", "null"), + ("HH:MM", "9:07", "null"), + ("h:MM tt", "9:07 pm", "null"), + ("h:MM tt", "9:07 am", "null"), + ("h:MM tt", "12:07 am", "null"), + ("h:MM tt", "9:07pm", "null"), + ]); + } + + /// An empty text reads as today at noon, and a text that is not a string (a number, or none + /// at all) reads nothing. + #[test] + fn scand_reads_only_text_and_an_empty_text_is_today_at_noon() { + let o = go( + "var d = util.scand('yyyymmdd', ''), n = new Date(); \ + event.value = [d.toDateString() == n.toDateString(), d.getHours(), d.getMinutes(), d.getSeconds(), \ + util.scand('yyyymmdd', 20261018), util.scand(), util.scand('yyyymmdd')].join('|');", + &Event::field("Calculate", "total", ""), + ); + assert_eq!(o.error, None); + assert_eq!(o.value, "true|12|0|0|||"); + } + + // Not observed: the choices made where the other viewer wasn't asked. + + /// A year left out is this year, `h:MM tt` reads a 12-hour time and refuses hours it cannot + /// have, and a letter the format writes must be typed there. + #[test] + fn scand_takes_this_year_when_none_is_given_and_reads_12_hour_times_and_written_letters() { + assert_scand(&[ + ("mm/dd/yyyy h:MM tt", "10/18/2026 9:07 pm", "2026-10-18 21:07:00"), + ("mm/dd/yyyy h:MM tt", "10/18/2026 12:07 AM", "2026-10-18 00:07:00"), + ("mm/dd/yyyy h:MM tt", "10/18/2026 13:07 pm", "null"), + ("mm/dd/yyyy HH:MM", "10/18/2026 24:00", "null"), + ("yyyy-mm-ddTHH:MM", "2026-10-18T21:07", "2026-10-18 21:07:00"), + ("yyyy-mm-ddTHH:MM", "2026-10-18X21:07", "null"), + ]); + let o = go( + "var d = util.scand('mm/dd', '10/18'); event.value = [d.getFullYear() == new Date().getFullYear(), d.getMonth(), d.getDate()].join('|');", + &Event::field("Calculate", "total", ""), + ); + assert_eq!(o.error, None); + assert_eq!(o.value, "true|9|18"); + } + + /// A format of 50,000 literal letters and one of 100,000 fields, with texts to match, are read + /// in linear time. The limit is generous so a loaded runner passes (it also covers starting + /// the engine and building the strings); a quadratic scan of inputs this size takes minutes. + #[test] + fn scand_reads_long_formats_and_texts_in_linear_time() { + // Scripts can pass anything; native code isn't bounded by the loop limit. + let started = std::time::Instant::now(); + let o = go( + "var x = new Array(50001).join('x'), md = new Array(50001).join('md'), ones = new Array(100000).join('1 ') + '1'; \ + event.value = [util.scand(x + 'yyyy', x + '2026') !== null, util.scand('yyyy' + md, '2026 ' + ones) !== null].join('|');", + &Event::field("Calculate", "total", ""), + ); + assert_eq!(o.error, None); + assert_eq!(o.value, "true|true"); + assert!(started.elapsed() < std::time::Duration::from_secs(10), "{:?}", started.elapsed()); + } + + /// The check an XFA form runs on leaving a date field tries six formats with `util.scand` and + /// writes the first that reads the text back as `yyyymmdd`, or reports an invalid date. + #[test] + fn xfa_scripts_check_typed_dates_with_util_scand() { + // The check a form runs when a date field is left: the first of six formats that reads + // the typed text, written back as yyyymmdd; or an invalid date. Results as another viewer gives them. + let check = |typed: &str| { + let script = format!( + "var six = ['yyyymmdd', 'yymmdd', 'yyyymdd', 'yyyymmd', 'yyyymd', 'yymd'], out = 'invalid'; \ + for (var i = 0; i < six.length; i++) {{ var d = util.scand(six[i], '{typed}'); \ + if (d !== null) {{ out = util.printd('yyyymmdd', d); break; }} }} out" + ); + let event = XfaEvent { activity: "exit".into(), target: "form1[0].page1[0].total[0]".into(), ..Default::default() }; + run_xfa(&script, &event, &XfaDoc::default(), &super::xfa_model::form(), Limits::default()) + }; + for (typed, want) in [ + ("20261018", "20261018"), + ("261018", "20261018"), + ("2026118", "20260118"), + ("202618", "20260108"), + ("2618", "20260108"), + ("2026-10-18", "20261018"), + ("20261318", "invalid"), + ("20260230", "invalid"), + ] { + let o = check(typed); + assert_eq!(o.error, None, "{typed}: {o:?}"); + assert_eq!(o.result.as_deref(), Some(want), "{typed}"); + } + } +} + /// The check XFA forms run on opening: without both globals, or with a version below 2.8, it /// alerts that the viewer can't show the form. const XFA_CHECK: &str = "if (typeof(xfa_installed) == 'undefined' || typeof(xfa_version) == 'undefined' || xfa_version < 2.8) app.alert('old viewer'); \ diff --git a/crates/js/src/xfa.rs b/crates/js/src/xfa.rs index 45abeeb02..60d1b3e07 100644 --- a/crates/js/src/xfa.rs +++ b/crates/js/src/xfa.rs @@ -15,7 +15,7 @@ use boa_engine::object::builtins::{JsArray, JsProxy}; use boa_engine::property::Attribute; use boa_engine::{Context, JsObject, JsResult, JsString, JsValue, NativeFunction, Source, js_string}; -use crate::{Limits, arg, as_number, error, function, platform, printd, printf, printx, refuse, s, text}; +use crate::{Limits, arg, as_number, error, function, platform, printd, printf, printx, refuse, s, scand, text}; /// What kind of template object a node is. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -1233,6 +1233,9 @@ fn xfa_resolve(ctx: &mut Context, many: bool) -> JsResult { resolver(ctx, current, many) } +/// Defines the globals for one script on node `current`: `xfa` (`form`, `host`, `layout`, `event`, +/// `resolveNode` and more), `event` as the same object as `xfa.event`, `app`, `console`, `util`, +/// and the nodes in scope by name, with `_name` for each repeatable one's instance manager. fn install(ctx: &mut Context, event: &XfaEvent, doc: &XfaDoc, current: usize) -> JsResult<()> { let rw = Attribute::CONFIGURABLE; let form = node_object(ctx, 0)?; @@ -1343,6 +1346,7 @@ fn install(ctx: &mut Context, event: &XfaEvent, doc: &XfaDoc, current: usize) -> .function(NativeFunction::from_fn_ptr(printf), js_string!("printf"), 1) .function(NativeFunction::from_fn_ptr(printd), js_string!("printd"), 2) .function(NativeFunction::from_fn_ptr(printx), js_string!("printx"), 2) + .function(NativeFunction::from_fn_ptr(scand), js_string!("scand"), 2) .build(); ctx.register_global_property(js_string!("util"), util, rw)?; // Unqualified names: the current node's children, its siblings, then each ancestor's diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index c788f8196..7d9b9ed4c 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5165,8 +5165,8 @@ milestone = "M6" status = "partial" commands = ["tools.js_console"] tools = ["js_run"] -evidence = ["crates/js/src/tests.rs::calculate_scripts_read_fields_and_set_the_value", "crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats", "crates/automation/tests/it/automation.rs::javascript_through_tools", "crates/js/src/tests.rs::an_xfa_form_sees_the_xfa_plugin_installed", "crates/js/src/tests.rs::a_plain_form_sees_the_xfa_version_but_not_installed", "crates/engine/src/tests.rs::scripts_in_an_xfa_form_see_the_xfa_plugin_installed"] -notes = "boa (pure Rust) with the forms object model: event, getField/Field (value, readonly, display, colours, items, check boxes), app, util.printf/printd/printx, console, display, color; xfa_version (3.5) in every document and xfa_installed in XFA forms, so their version check passes; no annotations, layers or dialogs (app.response returns null) yet, and no app.findComponent." +evidence = ["crates/js/src/tests.rs::calculate_scripts_read_fields_and_set_the_value", "crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats", "crates/automation/tests/it/automation.rs::javascript_through_tools", "crates/js/src/tests.rs::an_xfa_form_sees_the_xfa_plugin_installed", "crates/js/src/tests.rs::a_plain_form_sees_the_xfa_version_but_not_installed", "crates/engine/src/tests.rs::scripts_in_an_xfa_form_see_the_xfa_plugin_installed", "crates/js/src/tests.rs::scand_allows_separators_between_fields_but_nothing_before_or_after_them"] +notes = "boa (pure Rust) with the forms object model: event, getField/Field (value, readonly, display, colours, items, check boxes), app, util.printf/printd/printx/scand (scand reads dates as another viewer was observed to, in AcroForm and XFA scripts), console, display, color; xfa_version (3.5) in every document and xfa_installed in XFA forms, so their version check passes; no annotations, layers or dialogs (app.response returns null) yet, and no app.findComponent." [[feature]] id = "form.js-console"