mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 10:08:00 -06:00
Merge remote-tracking branch 'upstream/main' into fix/exec-menu-attachments
# Conflicts: # crates/engine/src/tests.rs # crates/js/src/tests.rs # crates/ui-egui/tests/forms.rs
This commit is contained in:
@@ -12,6 +12,9 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
boa_engine = { version = "0.22.0", default-features = false, features = ["annex-b"] }
|
||||
# FormCalc Date/Num2Date calendar conversion goes through chrono (no hand-rolled calendar code);
|
||||
# std-only, so it stays wasm-safe.
|
||||
chrono = { workspace = true }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
boa_engine = { version = "0.22.0", default-features = false, features = ["annex-b", "js"] }
|
||||
|
||||
+91
-28
@@ -1097,10 +1097,16 @@ impl Interp<'_> {
|
||||
self.charge(1)
|
||||
}
|
||||
|
||||
/// Property `name` of form object `i` as FormCalc reads it: its value, attributes,
|
||||
/// relatives and children by name.
|
||||
fn node_member(&mut self, i: usize, name: &str) -> R<Val> {
|
||||
let Some(n) = self.node(i) else { return Ok(Val::Null) };
|
||||
Ok(match name {
|
||||
"rawValue" | "value" | "formattedValue" => self.node_value(i),
|
||||
"formattedValue" => match &n.formatted {
|
||||
Some(f) => Val::Str(f.clone()),
|
||||
None => self.node_value(i),
|
||||
},
|
||||
"rawValue" | "value" => self.node_value(i),
|
||||
"name" => Val::Str(n.name.clone()),
|
||||
"somExpression" => Val::Str(n.som.clone()),
|
||||
"className" => Val::Str(n.kind.class_name().into()),
|
||||
@@ -1838,30 +1844,50 @@ impl Interp<'_> {
|
||||
|
||||
// ── built-in functions ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// `NaiveDate::num_days_from_ce` of 1900-01-01 minus one: a civil date's day count rebased so
|
||||
/// that 1900-01-01 is FormCalc day 1 (and 1970-01-01 is day 25_568).
|
||||
const FORMCALC_EPOCH_FROM_CE: i64 = 693_595;
|
||||
|
||||
/// Days in one 400-year Gregorian cycle; the calendar repeats after it.
|
||||
const DAYS_PER_400_YEARS: i64 = 146_097;
|
||||
|
||||
/// Days from 1900-01-01 (day 1, as FormCalc counts) to a civil date.
|
||||
///
|
||||
/// chrono's `NaiveDate` stops near ±262143, but FormCalc accepts day numbers up to `MAX_DAYS`
|
||||
/// (years past 270000). The calendar repeats every 400 years, so chrono converts the date
|
||||
/// within its cycle and whole cycles are added back; the result matches the proleptic Gregorian
|
||||
/// count for every year. Callers pass dates `parse_date`/`civil_from_days` already validated;
|
||||
/// anything else reads as 0.
|
||||
fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
|
||||
// Howard Hinnant's algorithm, epoch 1970-01-01, shifted to FormCalc's 1900-01-01 = 1.
|
||||
let y = if m <= 2 { y - 1 } else { y };
|
||||
let era = if y >= 0 { y } else { y - 399 } / 400;
|
||||
let yoe = y - era * 400;
|
||||
let mp = (i64::from(m) + 9) % 12;
|
||||
let doy = (153 * mp + 2) / 5 + i64::from(d) - 1;
|
||||
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||
let unix = era * 146_097 + doe - 719_468;
|
||||
unix + 25_568
|
||||
use chrono::Datelike;
|
||||
let (cycles, year) = (i128::from(y.div_euclid(400)), y.rem_euclid(400));
|
||||
i32::try_from(year)
|
||||
.ok()
|
||||
.and_then(|year| chrono::NaiveDate::from_ymd_opt(year, m, d))
|
||||
.and_then(|date| {
|
||||
let days = i128::from(date.num_days_from_ce()) - i128::from(FORMCALC_EPOCH_FROM_CE) + cycles * i128::from(DAYS_PER_400_YEARS);
|
||||
i64::try_from(days).ok()
|
||||
})
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// The civil date (year, month, day) at FormCalc day number `n` (day 1 is 1900-01-01), for any
|
||||
/// `n`: chrono converts the day within its 400-year cycle and the cycles are added back as years.
|
||||
fn civil_from_days(n: i64) -> (i64, u32, u32) {
|
||||
let z = n - 25_568 + 719_468;
|
||||
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
||||
let doe = z - era * 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
|
||||
(if m <= 2 { y + 1 } else { y }, m, d)
|
||||
use chrono::Datelike;
|
||||
// Days since CE day 0 (0000-12-31), in i128 so no `n` overflows.
|
||||
let from_ce = i128::from(n) + i128::from(FORMCALC_EPOCH_FROM_CE);
|
||||
let (cycles, rest) = (from_ce.div_euclid(i128::from(DAYS_PER_400_YEARS)), from_ce.rem_euclid(i128::from(DAYS_PER_400_YEARS)));
|
||||
// `rest` is 0..146_097, so CE day `rest` lies in years 0..=400 (day 0 is 0000-12-31).
|
||||
let date = i32::try_from(rest).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
|
||||
match date {
|
||||
Some(date) => {
|
||||
let y = i128::from(date.year()) + cycles * 400;
|
||||
(i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), date.month(), date.day())
|
||||
}
|
||||
// Unreachable: every `rest` in 0..146_097 is a valid CE day.
|
||||
None => (1900, 1, 1),
|
||||
}
|
||||
}
|
||||
|
||||
/// Day numbers (and years) beyond this are refused rather than computed.
|
||||
@@ -1925,10 +1951,15 @@ 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 {
|
||||
match m {
|
||||
2 if (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 => 29,
|
||||
2 => 28,
|
||||
4 | 6 | 9 | 11 => 30,
|
||||
// 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
|
||||
// in chrono's range, whatever `y` is).
|
||||
let y = y.rem_euclid(400);
|
||||
let first = |y: i64, m: u32| i32::try_from(y).ok().and_then(|y| chrono::NaiveDate::from_ymd_opt(y, m, 1));
|
||||
let next = if m == 12 { first(y + 1, 1) } else { first(y, m + 1) };
|
||||
match (first(y, m), next) {
|
||||
(Some(a), Some(b)) => (b - a).num_days() as u32,
|
||||
_ => 31,
|
||||
}
|
||||
}
|
||||
@@ -2027,6 +2058,10 @@ fn word_num(n: f64, cents: bool) -> String {
|
||||
}
|
||||
let cents_total = (n * 100.0).round() as u64;
|
||||
let whole = if cents { cents_total / 100 } else { n.trunc() as u64 };
|
||||
if whole >= 1_000_000_000_000 {
|
||||
// Rounding to cents carried past the supported range.
|
||||
return String::new();
|
||||
}
|
||||
let mut parts = Vec::new();
|
||||
let groups = [(1_000_000_000u64, "Billion"), (1_000_000, "Million"), (1_000, "Thousand")];
|
||||
let mut rest = whole;
|
||||
@@ -2276,8 +2311,11 @@ impl Interp<'_> {
|
||||
}
|
||||
let num = self.to_num(&v);
|
||||
let decimals = pic.split_once('.').map_or(0, |(_, f)| f.chars().filter(|c| matches!(c, '9' | 'z' | 'Z')).count()).min(20);
|
||||
// `9` in the integer part forces a digit; `z` does not.
|
||||
let min_int = pic.split('.').next().map_or(0, |i| i.chars().filter(|c| *c == '9').count()).min(20);
|
||||
let width = min_int + if decimals > 0 { decimals + 1 } else { 0 };
|
||||
let grouped = pic.contains(',');
|
||||
let mut text = fixed(num.abs(), decimals);
|
||||
let mut text = format!("{:0>width$}", fixed(num.abs(), decimals));
|
||||
if grouped {
|
||||
let (int, frac) = text.split_once('.').map_or((text.as_str(), ""), |(a, b)| (a, b));
|
||||
let mut g = String::new();
|
||||
@@ -2369,9 +2407,16 @@ impl Interp<'_> {
|
||||
}
|
||||
"time2num" | "isotime2num" => {
|
||||
let s = self.arg_str(args, 0)?;
|
||||
let parts: Vec<u64> = s.split(':').filter_map(|p| p.trim().parse::<u64>().ok()).map(|v| v.min(1_000_000)).collect();
|
||||
let (h, m, sec) = (parts.first().copied().unwrap_or(0), parts.get(1).copied().unwrap_or(0), parts.get(2).copied().unwrap_or(0));
|
||||
Val::Num(((h * 3600 + m * 60 + sec) * 1000) as f64)
|
||||
// ISO 8601 allows a leading "T", a trailing "Z" and fractional seconds.
|
||||
let t = s.trim();
|
||||
let t = t.strip_prefix(['T', 't']).unwrap_or(t);
|
||||
let t = t.strip_suffix(['Z', 'z']).unwrap_or(t);
|
||||
let mut parts = t.split(':');
|
||||
let mut whole = || parts.next().and_then(|p| p.trim().parse::<u64>().ok()).unwrap_or(0).min(1_000_000);
|
||||
let (h, m) = (whole(), whole());
|
||||
let sec =
|
||||
parts.next().and_then(|p| p.trim().parse::<f64>().ok()).filter(|v| v.is_finite() && *v >= 0.0).unwrap_or(0.0).min(1_000_000.0);
|
||||
Val::Num(((h * 3600 + m * 60) * 1000) as f64 + (sec * 1000.0).round())
|
||||
}
|
||||
// ── financial
|
||||
"fv" => {
|
||||
@@ -2591,11 +2636,29 @@ mod tests {
|
||||
assert_eq!(days_from_civil(1900, 1, 1), 1);
|
||||
assert_eq!(days_from_civil(1970, 1, 1), 25_568);
|
||||
assert_eq!(civil_from_days(days_from_civil(2024, 2, 29)), (2024, 2, 29));
|
||||
// Past chrono's ±262143 the 400-year cycles carry the year; values from the pre-chrono
|
||||
// formula, both directions, both sides of the epoch.
|
||||
for (n, date) in [
|
||||
(99_000_000, (272_952, 10, 16)),
|
||||
(99_999_999, (275_690, 9, 11)),
|
||||
(-99_000_000, (-269_153, 3, 16)),
|
||||
(-99_999_999, (-271_891, 4, 20)),
|
||||
(72_354_905, (200_000, 12, 31)),
|
||||
(-73_742_460, (-200_000, 1, 1)),
|
||||
] {
|
||||
assert_eq!(civil_from_days(n), date, "{n}");
|
||||
assert_eq!(days_from_civil(date.0, date.1, date.2), n, "{date:?}");
|
||||
}
|
||||
assert_eq!(days_in(272_952, 2), 29, "272952 is a leap year");
|
||||
assert_eq!(days_in(-269_153, 2), 28);
|
||||
assert_eq!(days_in(i64::MAX, 12), 31);
|
||||
let _ = (civil_from_days(i64::MAX), civil_from_days(i64::MIN), days_from_civil(i64::MAX, 12, 31), days_from_civil(i64::MIN, 1, 1));
|
||||
assert_eq!(parse_date("2001-02-03", "YYYY-MM-DD"), Some((2001, 2, 3)));
|
||||
assert_eq!(parse_date("Feb 3, 2001", "MMM D, YYYY"), Some((2001, 2, 3)));
|
||||
assert_eq!(parse_date("2/3/01", "M/D/YY"), Some((2001, 2, 3)));
|
||||
assert_eq!(format_date(2001, 2, 3, "MMMM D, YYYY"), "February 3, 2001");
|
||||
assert_eq!(word_num(1234.5, true), "One Thousand Two Hundred Thirty-Four Dollars And 50 Cents");
|
||||
assert_eq!(word_num(999_999_999_999.999, true), "");
|
||||
assert_eq!(num_text(3.0), "3");
|
||||
assert_eq!(num_text(2.5), "2.5");
|
||||
assert_eq!(num_text(0.1 + 0.2), "0.3");
|
||||
|
||||
+13
-4
@@ -25,6 +25,7 @@
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use boa_engine::object::builtins::{JsArray, JsFunction};
|
||||
use boa_engine::object::{FunctionObjectBuilder, ObjectInitializer};
|
||||
@@ -220,7 +221,7 @@ impl Default for Limits {
|
||||
}
|
||||
|
||||
struct Host {
|
||||
fields: Vec<FieldState>,
|
||||
fields: Arc<Vec<FieldState>>,
|
||||
changed: BTreeSet<usize>,
|
||||
alerts: Vec<String>,
|
||||
console: Vec<String>,
|
||||
@@ -312,7 +313,8 @@ fn read(ctx: &Context, name: &JsString) -> JsResult<FieldState> {
|
||||
fn write(ctx: &Context, name: &JsString, change: impl FnOnce(&mut FieldState)) -> JsResult<()> {
|
||||
let i = field_index(ctx, name)?;
|
||||
let mut h = host(ctx)?.borrow_mut();
|
||||
change(&mut h.fields[i]);
|
||||
let field = Arc::make_mut(&mut h.fields).get_mut(i).ok_or_else(|| error("the field is no longer available"))?;
|
||||
change(field);
|
||||
h.changed.insert(i);
|
||||
Ok(())
|
||||
}
|
||||
@@ -1164,6 +1166,13 @@ fn refuse(source: &str) -> Option<String> {
|
||||
/// Run `script` for `event`, after the document-level scripts (`doc_scripts`, which define the
|
||||
/// functions field scripts call), with the form's `fields`.
|
||||
pub fn run(script: &str, event: &Event, doc: &DocInfo, fields: &[FieldState], doc_scripts: &[String], limits: Limits) -> Outcome {
|
||||
run_shared(script, event, doc, Arc::new(fields.to_vec()), doc_scripts, limits)
|
||||
}
|
||||
|
||||
/// Like [`run`], sharing an immutable initial field snapshot. Scripts still get a fresh
|
||||
/// context; the first field write copies the snapshot, so neither the caller nor another
|
||||
/// script can observe uncommitted writes. Read-only calculations avoid copying the whole form.
|
||||
pub fn run_shared(script: &str, event: &Event, doc: &DocInfo, fields: Arc<Vec<FieldState>>, doc_scripts: &[String], limits: Limits) -> Outcome {
|
||||
let failed = |why: String| Outcome { rc: true, value: event.value.clone(), change: event.change.clone(), error: Some(why), ..Default::default() };
|
||||
if let Some(why) = std::iter::once(script).chain(doc_scripts.iter().map(String::as_str)).find_map(refuse) {
|
||||
return failed(why);
|
||||
@@ -1185,12 +1194,12 @@ pub fn run(script: &str, event: &Event, doc: &DocInfo, fields: &[FieldState], do
|
||||
run_here(script, event, doc, fields, doc_scripts, limits)
|
||||
}
|
||||
|
||||
fn run_here(script: &str, event: &Event, doc: &DocInfo, fields: &[FieldState], doc_scripts: &[String], limits: Limits) -> Outcome {
|
||||
fn run_here(script: &str, event: &Event, doc: &DocInfo, fields: Arc<Vec<FieldState>>, doc_scripts: &[String], limits: Limits) -> Outcome {
|
||||
let mut ctx = Context::default();
|
||||
ctx.runtime_limits_mut().set_loop_iteration_limit(limits.loop_iterations);
|
||||
ctx.runtime_limits_mut().set_recursion_limit(limits.recursion);
|
||||
ctx.insert_data(RefCell::new(Host {
|
||||
fields: fields.to_vec(),
|
||||
fields,
|
||||
changed: BTreeSet::new(),
|
||||
alerts: Vec::new(),
|
||||
console: Vec::new(),
|
||||
|
||||
@@ -380,12 +380,15 @@ mod xfa_model {
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the JavaScript `script` for `activity` on the test form, from the node at `target`.
|
||||
fn go(script: &str, target: &str, activity: &str) -> XfaOutcome {
|
||||
let ev = XfaEvent { activity: activity.into(), target: target.into(), ..Default::default() };
|
||||
let doc = XfaDoc { file_name: "f.pdf".into(), page: 1, page_count: 3 };
|
||||
run_xfa(script, &ev, &doc, &form(), Limits::default())
|
||||
}
|
||||
|
||||
/// A calculate script finds its siblings by name and yields its last expression, and a
|
||||
/// value it sets comes back as an effect.
|
||||
#[test]
|
||||
fn calculate_scripts_see_siblings_by_name_and_yield_their_last_expression() {
|
||||
let o = go("qty.rawValue * price.rawValue", "form1[0].page1[0].total[0]", "calculate");
|
||||
@@ -395,6 +398,39 @@ mod xfa_model {
|
||||
assert_eq!(o.effects, vec![XfaEffect::SetValue { som: "form1[0].page1[0].total[0]".into(), value: "5".into() }]);
|
||||
}
|
||||
|
||||
/// A form whose one field `d` holds the ISO date 2026-10-20, shown as 20261020.
|
||||
pub(super) fn dated_form() -> XfaNode {
|
||||
let d = XfaNode { formatted: Some("20261020".into()), ..field("d", "form1[0].page1[0].d[0]", "2026-10-20", false) };
|
||||
XfaNode {
|
||||
name: "form1".into(),
|
||||
som: "form1[0]".into(),
|
||||
kind: Some(XfaKind::Subform),
|
||||
children: vec![XfaNode {
|
||||
name: "page1".into(),
|
||||
som: "form1[0].page1[0]".into(),
|
||||
kind: Some(XfaKind::Subform),
|
||||
children: vec![d],
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// `formattedValue` reads the value as the field's picture shows it, and `rawValue` the
|
||||
/// value itself; once the script sets the value, `formattedValue` gives that value.
|
||||
#[test]
|
||||
fn formatted_value_reads_the_shown_value_until_the_script_sets_one() {
|
||||
let ev = XfaEvent { activity: "calculate".into(), target: "form1[0].page1[0].d[0]".into(), ..Default::default() };
|
||||
let doc = XfaDoc::default();
|
||||
let run = |script: &str| run_xfa(script, &ev, &doc, &dated_form(), Limits::default());
|
||||
let o = run("this.formattedValue + ' ' + this.rawValue");
|
||||
assert_eq!(o.error, None, "{o:?}");
|
||||
assert_eq!(o.result.as_deref(), Some("20261020 2026-10-20"));
|
||||
let o = run("this.rawValue = '2026-10-21'; this.formattedValue");
|
||||
assert_eq!(o.result.as_deref(), Some("2026-10-21"));
|
||||
}
|
||||
|
||||
/// Long values a script sets, and long results, are cut where they are stored, with a note.
|
||||
#[test]
|
||||
fn long_values_and_results_are_cut_where_they_are_stored() {
|
||||
// The value kept for later reads is the one the effect saves.
|
||||
@@ -607,6 +643,22 @@ mod formcalc {
|
||||
o.result.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// FormCalc reads `formattedValue` as the field's picture shows it too, until the script
|
||||
/// sets the value.
|
||||
#[test]
|
||||
fn formcalc_formatted_value_reads_the_shown_value_until_the_script_sets_one() {
|
||||
let ev = XfaEvent { activity: "calculate".into(), target: "form1[0].page1[0].d[0]".into(), ..Default::default() };
|
||||
let doc = XfaDoc::default();
|
||||
let run = |script: &str| run_formcalc(script, &ev, &doc, &super::xfa_model::dated_form(), Limits::default());
|
||||
let o = run("Concat($.formattedValue, \" \", $.rawValue)");
|
||||
assert_eq!(o.error, None, "{o:?}");
|
||||
assert_eq!(o.result.as_deref(), Some("20261020 2026-10-20"));
|
||||
let o = run("$.rawValue = \"2026-10-21\"\n$.formattedValue");
|
||||
assert_eq!(o.error, None, "{o:?}");
|
||||
assert_eq!(o.result.as_deref(), Some("2026-10-21"));
|
||||
}
|
||||
|
||||
/// FormCalc arithmetic, string conversion and comparisons follow FormCalc's own rules.
|
||||
#[test]
|
||||
fn arithmetic_strings_and_comparisons_follow_formcalc_rules() {
|
||||
assert_eq!(calc("1 + 2 * 3"), "7");
|
||||
@@ -651,6 +703,17 @@ mod formcalc {
|
||||
assert_eq!(calc(r#"Num2Date(45350, "YYYY-MM-DD x")"#), "2024-02-29 x");
|
||||
}
|
||||
|
||||
/// Accepted day numbers past chrono's own date range (about year 262143) keep the dates the
|
||||
/// pre-chrono algorithm gave, on both sides of the epoch, through Num2Date and Format.
|
||||
#[test]
|
||||
fn date_numbers_beyond_chronos_range_keep_their_dates() {
|
||||
assert_eq!(calc(r#"Num2Date(99000000, "YYYY-MM-DD")"#), "272952-10-16");
|
||||
assert_eq!(calc(r#"Format("YYYY-MM-DD", 99000000)"#), "272952-10-16");
|
||||
assert_eq!(calc(r#"Num2Date(99999999, "YYYY-MM-DD")"#), "275690-09-11");
|
||||
assert_eq!(calc(r#"Num2Date(-99000000, "YYYY-MM-DD")"#), "-269153-03-16");
|
||||
assert_eq!(calc(r#"Num2Date(-99999999, "YYYY-MM-DD")"#), "-271891-04-20");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn str_and_format_round_exact_ties_away_from_zero() {
|
||||
assert_eq!(calc("Str(2.5, 3, 0)"), " 3");
|
||||
@@ -708,6 +771,9 @@ mod formcalc {
|
||||
assert_eq!(calc(r#"Num2Date(45350, "YYYY-MM-DD")"#), "2024-02-29");
|
||||
assert_eq!(calc(r#"Num2Date(Date2Num("2024-02-29") + 1, "MMMM D, YYYY")"#), "March 1, 2024");
|
||||
assert_eq!(calc(r#"Time2Num("01:00:00")"#), "3600000");
|
||||
assert_eq!(calc(r#"IsoTime2Num("12:30:45Z")"#), "45045000");
|
||||
assert_eq!(calc(r#"IsoTime2Num("12:30:45.250")"#), "45045250");
|
||||
assert_eq!(calc(r#"IsoTime2Num("T12:30:45")"#), "45045000");
|
||||
assert_eq!(calc(r#"Num2Time(3661000)"#), "01:01:01");
|
||||
assert_eq!(calc("Round(Pmt(10000, 0.01, 12), 2)"), "888.49");
|
||||
assert_eq!(calc("Round(FV(100, 0.01, 12), 2)"), "1268.25");
|
||||
@@ -720,6 +786,15 @@ mod formcalc {
|
||||
assert!(go("Nope(1)", "form1[0].page1[0].total[0]", "calculate").error.unwrap().contains("unknown function"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_nine_pads_the_integer_part() {
|
||||
assert_eq!(calc(r#"Format("99999", 42)"#), "00042");
|
||||
assert_eq!(calc(r#"Format("9999", 5)"#), "0005");
|
||||
assert_eq!(calc(r#"Format("999.99", 4.5)"#), "004.50");
|
||||
assert_eq!(calc(r#"Format("zzz9", 42)"#), "42");
|
||||
assert_eq!(calc(r#"Format("99", 12345)"#), "12345");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn date2num_rejects_days_past_the_end_of_the_month() {
|
||||
assert_eq!(calc(r#"Date2Num("2023-02-30")"#), "0");
|
||||
@@ -1143,6 +1218,40 @@ mod session {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_field_snapshots_isolate_writes_errors_and_script_globals() {
|
||||
let initial = Arc::new(fields());
|
||||
let before = initial.as_ref().clone();
|
||||
let event = Event::field("Calculate", "total", "");
|
||||
let write = run_shared(
|
||||
"var scriptLocal = 1; getField('qty').value = 9; getField('agree').checkThisBox(0, true); event.value = getField('qty').value;",
|
||||
&event,
|
||||
&doc(),
|
||||
Arc::clone(&initial),
|
||||
&[],
|
||||
Limits::default(),
|
||||
);
|
||||
assert_eq!(write.error, None);
|
||||
assert_eq!(write.value, "9");
|
||||
assert_eq!(write.changed.iter().find(|f| f.name == "qty").unwrap().value, ["9"]);
|
||||
assert_eq!(write.changed.iter().find(|f| f.name == "agree").unwrap().value, ["Yes"]);
|
||||
assert_eq!(*initial, before, "script writes cannot mutate the caller's snapshot");
|
||||
let failed = run_shared("getField('qty').value = 99; throw new Error('rejected');", &event, &doc(), Arc::clone(&initial), &[], Limits::default());
|
||||
assert!(failed.error.is_some());
|
||||
assert_eq!(*initial, before, "an exception cannot leak partial writes into the initial snapshot");
|
||||
let read = run_shared(
|
||||
"event.value = typeof scriptLocal + ':' + getField('qty').value + ':' + getField('agree').isBoxChecked(0);",
|
||||
&event,
|
||||
&doc(),
|
||||
initial,
|
||||
&[],
|
||||
Limits::default(),
|
||||
);
|
||||
assert_eq!(read.error, None);
|
||||
assert_eq!(read.value, "undefined:4:false");
|
||||
assert!(read.changed.is_empty());
|
||||
}
|
||||
|
||||
mod menu_items {
|
||||
use super::*;
|
||||
use crate::xfa::{XfaDoc, XfaEffect, XfaEvent, run_xfa};
|
||||
|
||||
+17
-1
@@ -64,6 +64,9 @@ pub struct XfaNode {
|
||||
/// This instance's index among its same-named siblings.
|
||||
pub index: usize,
|
||||
pub children: Vec<XfaNode>,
|
||||
/// Fields: the value shown in the field's display picture, what `formattedValue` reads;
|
||||
/// `None` when the picture doesn't change it (the raw value is read then).
|
||||
pub formatted: Option<String>,
|
||||
}
|
||||
|
||||
/// The event a script runs for.
|
||||
@@ -207,6 +210,8 @@ pub(crate) struct HNode {
|
||||
pub(crate) children: Vec<usize>,
|
||||
/// Removed by `removeInstance`: skipped everywhere.
|
||||
pub(crate) gone: bool,
|
||||
/// See [`XfaNode::formatted`]; dropped when a script sets the value, which it no longer shows.
|
||||
pub(crate) formatted: Option<String>,
|
||||
}
|
||||
|
||||
/// The form as a script sees and changes it, with the effects it records.
|
||||
@@ -348,6 +353,7 @@ impl XHost {
|
||||
// the snapshot stays bounded however often a long value is written.
|
||||
let (text, cut) = clip_value(text);
|
||||
n.value = text.clone();
|
||||
n.formatted = None;
|
||||
let som = n.som.clone();
|
||||
if cut {
|
||||
self.clipped_values += 1;
|
||||
@@ -359,10 +365,13 @@ impl XHost {
|
||||
|
||||
type Shared = RefCell<XHost>;
|
||||
|
||||
/// The form host of the script running in `ctx`.
|
||||
fn host(ctx: &Context) -> JsResult<&Shared> {
|
||||
ctx.get_data::<Shared>().ok_or_else(|| error("the XFA script host is not set up"))
|
||||
}
|
||||
|
||||
/// The snapshot `n` and its descendants flattened into `out` under `parent` (to [`MAX_DEPTH`]
|
||||
/// and [`MAX_NODES`]): the index of `n`'s node, `None` past the limits.
|
||||
fn flatten(n: &XfaNode, parent: Option<usize>, out: &mut Vec<HNode>, depth: usize) -> Option<usize> {
|
||||
if depth > MAX_DEPTH || out.len() >= MAX_NODES {
|
||||
return None;
|
||||
@@ -383,6 +392,7 @@ fn flatten(n: &XfaNode, parent: Option<usize>, out: &mut Vec<HNode>, depth: usiz
|
||||
parent,
|
||||
children: Vec::new(),
|
||||
gone: false,
|
||||
formatted: n.formatted.clone(),
|
||||
});
|
||||
let mut kids = Vec::new();
|
||||
for c in &n.children {
|
||||
@@ -620,6 +630,8 @@ fn clone_subtree(h: &mut XHost, src: usize, parent: Option<usize>, depth: usize)
|
||||
parent,
|
||||
children: Vec::new(),
|
||||
gone: false,
|
||||
// The copy holds no value, so nothing is shown in its picture either.
|
||||
formatted: None,
|
||||
},
|
||||
n.children.clone(),
|
||||
)
|
||||
@@ -819,6 +831,8 @@ pub(crate) fn resolve_som(h: &XHost, from: usize, expr: &str) -> Vec<usize> {
|
||||
cur
|
||||
}
|
||||
|
||||
/// The live descendants of node `from` named `name` (`from..name` in a SOM path), in document
|
||||
/// order, to [`MAX_DEPTH`].
|
||||
pub(crate) fn descendants_named(h: &XHost, from: usize, name: &str, depth: usize) -> Vec<usize> {
|
||||
let mut out = Vec::new();
|
||||
if depth > MAX_DEPTH {
|
||||
@@ -839,6 +853,8 @@ pub(crate) fn descendants_named(h: &XHost, from: usize, name: &str, depth: usize
|
||||
out
|
||||
}
|
||||
|
||||
/// A property read on a form node's Proxy: its value (and `formattedValue` as its picture shows
|
||||
/// it), attributes, relatives, methods, instance managers and children by name.
|
||||
fn node_get(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
let idx = node_index(&arg(args, 0), ctx)?;
|
||||
let key = arg(args, 1);
|
||||
@@ -855,7 +871,7 @@ fn node_get(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValu
|
||||
Ok(match key.as_str() {
|
||||
IDX => JsValue::from(idx as f64),
|
||||
"rawValue" | "value" => host(ctx)?.borrow().nodes.get(idx).map_or(JsValue::null(), value_js),
|
||||
"formattedValue" => s(&value),
|
||||
"formattedValue" => s(&host(ctx)?.borrow().nodes.get(idx).and_then(|n| n.formatted.clone()).unwrap_or(value)),
|
||||
"name" => s(&name),
|
||||
"somExpression" => s(&som),
|
||||
"className" => s(kind.class_name()),
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
//! One script engine for a whole form event. Every script of the event runs on one thread
|
||||
//! (with the script stack), each in a fresh JavaScript context on a fresh copy of the form as
|
||||
//! the caller holds it, so scripts can't see each other's variables or objects and a script
|
||||
//! that failed halfway leaves nothing behind; what a script changed reaches the next one only
|
||||
//! through the caller, which applies the effects and sends the form as it then is. Before,
|
||||
//! that failed halfway leaves no state behind in the engine; what a script changed (up to its
|
||||
//! error, when it failed) reaches the next one only through the caller, which applies the
|
||||
//! effects and sends the form as it then is. Before,
|
||||
//! every script also got a thread of its own and the caller rebuilt the form for each: a form
|
||||
//! with hundreds of calculations spent its open-time budget on that.
|
||||
//!
|
||||
|
||||
Reference in New Issue
Block a user