diff --git a/crates/automation/src/forms.rs b/crates/automation/src/forms.rs index 2fd51038de..37f1ed2cbb 100644 --- a/crates/automation/src/forms.rs +++ b/crates/automation/src/forms.rs @@ -176,7 +176,7 @@ fn calculate_json(f: &FormField) -> Option { match (&f.actions.calculate, &f.actions.scripts.calculate) { (Calculate::Simple { op, fields }, _) => Some(json!({ "op": op_name(*op), "fields": fields })), (Calculate::Notation(n), _) => Some(json!({ "notation": n })), - (Calculate::None, Some(js)) => Some(json!({ "script": js })), + (Calculate::Custom(js), _) | (Calculate::None, Some(js)) => Some(json!({ "script": js })), (Calculate::None, None) => None, } } diff --git a/crates/forms/src/af.rs b/crates/forms/src/af.rs index 2ea52df387..12f3ee5ea9 100644 --- a/crates/forms/src/af.rs +++ b/crates/forms/src/af.rs @@ -98,6 +98,12 @@ pub enum Calculate { Simple { op: CalcOp, fields: Vec }, /// Simplified field notation, e.g. `Price * Quantity`. Notation(String), + /// A custom calculation script, written as given (Field Properties ▸ Calculate ▸ Custom + /// calculation script). Reading a field never gives this: a script that isn't one of the + /// forms above stays in [`Actions::unsupported`] and [`Scripts::calculate`], where the + /// JavaScript engine runs it. Authoring writes it back byte for byte; a blank one writes no + /// script. + Custom(String), } /// A field's recognised scripts. diff --git a/crates/forms/src/af/calc.rs b/crates/forms/src/af/calc.rs index 66738246c2..b7f9bd4c4c 100644 --- a/crates/forms/src/af/calc.rs +++ b/crates/forms/src/af/calc.rs @@ -445,6 +445,7 @@ impl Calculate { match self { Calculate::None => Vec::new(), Calculate::Simple { fields, .. } => unique(fields.iter().cloned()), + Calculate::Custom(js) => calculation_reads(js), Calculate::Notation(expr) => unique(notation_tokens(expr).into_iter().filter_map(|(_, token)| match token { Notation::Name(name) => Some(name), _ => None, @@ -620,7 +621,8 @@ const MAX_EXPANDED_VALUES: usize = 1 << 20; pub fn calculate(c: &Calculate, values: &dyn Fn(&str) -> Vec) -> Option { let num = |s: &String| make_number(s).unwrap_or(0.0); match c { - Calculate::None => None, + // A custom script is the JavaScript engine's to run. + Calculate::None | Calculate::Custom(_) => None, // A product of no values is 0, as an empty sum, average, minimum or maximum is, not the // empty product 1: a blank quantity × price row reads 0, and a total over the rows // doesn't count it as 1. (The form's `values` leaves out a field that has no value.) diff --git a/crates/forms/src/af/script.rs b/crates/forms/src/af/script.rs index 8212da5bf2..3af5e2dca3 100644 --- a/crates/forms/src/af/script.rs +++ b/crates/forms/src/af/script.rs @@ -383,9 +383,12 @@ fn notation_text(src: &str) -> &str { } } +/// The `/AA /C` JavaScript that stores `c`; `None` when it stores none (no calculation, or a +/// blank custom script). pub fn calculate_js(c: &Calculate) -> Option { match c { Calculate::None => None, + Calculate::Custom(js) => (!js.trim().is_empty()).then(|| js.clone()), Calculate::Simple { op, fields } => Some(format!( "AFSimple_Calculate({}, new Array({}));", js_str(op.code()), diff --git a/crates/forms/src/af/tests.rs b/crates/forms/src/af/tests.rs index b4b6452a3c..d19d9f2a5f 100644 --- a/crates/forms/src/af/tests.rs +++ b/crates/forms/src/af/tests.rs @@ -487,6 +487,20 @@ fn destructured_for_of_targets_keep_regex_iterables_opaque() { } } +/// A custom calculation script (#6630) is written as given, a blank one as no script; it reads the +/// fields it names, and running it is the JavaScript engine's, not the AF calculator's. +#[test] +fn a_custom_calculation_script_is_kept_as_written() { + let js = "event.value = this.getField(\"a\").value * getField(\"b\").value; // getField(\"c\")"; + let c = Calculate::Custom(js.into()); + assert_eq!(calculate_js(&c).as_deref(), Some(js)); + for blank in ["", " \r\n\t"] { + assert_eq!(calculate_js(&Calculate::Custom(blank.into())), None, "{blank:?}"); + } + assert_eq!(c.reads(), ["a", "b"]); + assert_eq!(calculate(&c, &|_| vec!["2".into()]), None); +} + #[test] fn simple_calculations_fold_a_repeated_group_without_collecting_it() { let group = |_: &str| vec!["2".to_string(); 3]; diff --git a/crates/forms/src/author/edit.rs b/crates/forms/src/author/edit.rs index 2c727779eb..ea718369bf 100644 --- a/crates/forms/src/author/edit.rs +++ b/crates/forms/src/author/edit.rs @@ -94,6 +94,21 @@ fn button_export_options(f: &Field, index: usize, value: &str) -> Result Option { + let js = crate::af::calculate_js(c)?; + let text = match c { + crate::af::Calculate::Custom(_) => PdfString::text(&js), + _ => PdfString::literal(js.into_bytes()), + }; + let mut a = Dict::new(); + a.set(b"S".to_vec(), Object::name("JavaScript")); + a.set(b"JS".to_vec(), text); + Some(Object::Dict(a)) +} + /// Change a field's properties (General and Options tabs) and redraw it. pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result { if props.font_size.is_some_and(|size| !size.is_finite() || size < 0.0) { @@ -328,10 +343,12 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result Option { + let f = field(&fields(doc), name).obj; + let aa = doc.get(f).as_dict().and_then(|d| d.get(b"AA").cloned()).map(|a| doc.resolve(&a))?; + let c = aa.as_dict().and_then(|aa| aa.get(b"C").cloned()).map(|c| doc.resolve(&c))?; + Some(c.as_dict().and_then(|c| c.get(b"JS")).and_then(|s| s.as_string()).expect("/JS is a string").clone()) +} + +/// Field Properties ▸ Calculate ▸ Custom calculation script (#6630): the script is written as +/// given, as a text string, and reads back as the field's custom script, in the calculation +/// order; a blank one removes the script and the field's place in the order. +#[test] +fn a_custom_calculation_script_is_written_as_given_and_a_blank_one_removes_it() { + use crate::af::Calculate; + let mut doc = one_page(); + let text = NewField::Text { multiline: false }; + add_field(&mut doc, 0, [50.0, 700.0, 250.0, 720.0], &text, Some("codice")).unwrap(); + add_field(&mut doc, 0, [50.0, 660.0, 250.0, 680.0], &text, Some("barcode")).unwrap(); + let js = "// Code 39: tra asterischi, così\r\nevent.value = \"*\" + this.getField(\"codice\").value + \"*\";"; + let custom = |js: &str| FieldProps { calculate: Some(Calculate::Custom(js.into())), ..FieldProps::default() }; + set_props(&mut doc, "barcode", &custom(js)).unwrap(); + for doc in [doc.clone(), reopen(&doc)] { + let all = fields(&doc); + let actions = &field(&all, "barcode").actions; + assert_eq!((&actions.calculate, actions.scripts.calculate.as_deref()), (&Calculate::None, Some(js)), "read back as a script"); + assert!(actions.unsupported.contains(&"calculate")); + let stored = calculate_action_js(&doc, "barcode").expect("an /AA /C action"); + assert_eq!(stored.bytes, pdfcraft_cos::PdfString::text(js).bytes, "a text string, so ì isn't UTF-8 bytes another viewer reads as ì's pieces"); + let order = calculation_order(&doc, &all); + assert_eq!(order.fields.iter().map(|f| (f.name.as_str(), f.listed)).collect::>(), [("barcode", true)]); + assert_eq!(order.fields[0].reads, ["codice"]); + } + for blank in ["", " \n\t"] { + let mut doc = doc.clone(); + set_props(&mut doc, "barcode", &custom(blank)).unwrap(); + let all = fields(&doc); + let actions = &field(&all, "barcode").actions; + assert_eq!((&actions.calculate, actions.scripts.calculate.as_deref()), (&Calculate::None, None), "{blank:?}"); + assert!(calculate_action_js(&doc, "barcode").is_none(), "{blank:?} writes no /C"); + assert!(calculation_order(&doc, &all).fields.is_empty(), "{blank:?} leaves the calculation order"); + } +} + +/// A sum and a notation are still written as the AF calls they are, byte for byte, beside the +/// custom script that is written as a text string (#6630). +#[test] +fn a_sum_and_a_notation_are_written_as_their_af_calls() { + use crate::af::{CalcOp, Calculate, calculate_js}; + let mut doc = one_page(); + let text = NewField::Text { multiline: false }; + for (i, name) in ["a", "b", "total"].into_iter().enumerate() { + let y = 700.0 - 40.0 * i as f64; + add_field(&mut doc, 0, [50.0, y, 250.0, y + 20.0], &text, Some(name)).unwrap(); + } + let sum = Calculate::Simple { op: CalcOp::Sum, fields: vec!["a".into(), "b".into()] }; + for c in [sum, Calculate::Notation("a * b".into())] { + set_props(&mut doc, "total", &FieldProps { calculate: Some(c.clone()), ..FieldProps::default() }).unwrap(); + let js = calculate_js(&c).unwrap(); + assert_eq!(calculate_action_js(&doc, "total").map(|s| s.bytes), Some(js.into_bytes()), "{c:?}"); + let all = fields(&reopen(&doc)); + assert_eq!((&field(&all, "total").actions.calculate, &field(&all, "total").actions.scripts.calculate), (&c, &None)); + } +} + /// An unsupported first AF call keeps the entire script and reaches the caller's interpreter. #[test] fn unsupported_first_validation_call_preserves_custom_script_dispatch() { diff --git a/crates/ui-egui/src/tools/prepare/draft.rs b/crates/ui-egui/src/tools/prepare/draft.rs index 994fbaa8b5..d184506948 100644 --- a/crates/ui-egui/src/tools/prepare/draft.rs +++ b/crates/ui-egui/src/tools/prepare/draft.rs @@ -141,6 +141,9 @@ pub struct FieldDraft { pub format: Format, pub validate: Validate, pub calculate: Calculate, + /// Calculate tab: the last edit of the custom script was refused for its length (said until + /// the next edit is taken, another calculation is chosen, or the dialog is opened again). + pub script_refused: bool, /// Every other field's name (the Calculate tab picks from them). pub others: Vec, /// Actions tab: each trigger's action, and the one being added. @@ -210,7 +213,13 @@ impl FieldDraft { check_style: None, format: f.actions.format.clone(), validate: f.actions.validate.clone(), - calculate: f.actions.calculate.clone(), + calculate: match (&f.actions.calculate, &f.actions.scripts.calculate) { + // A script that is no sum or notation is the field's custom calculation script: + // shown as one, and written back only when it is changed (#6630). + (Calculate::None, Some(js)) => Calculate::Custom(js.clone()), + (c, _) => c.clone(), + }, + script_refused: false, others: Vec::new(), actions: Vec::new(), new_action: ActionDraft::default(), diff --git a/crates/ui-egui/src/tools/prepare/tabs.rs b/crates/ui-egui/src/tools/prepare/tabs.rs index 04831353bd..b3a9052401 100644 --- a/crates/ui-egui/src/tools/prepare/tabs.rs +++ b/crates/ui-egui/src/tools/prepare/tabs.rs @@ -325,17 +325,30 @@ pub(super) fn actions_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::them }); } +/// The longest custom calculation script the Calculate tab offers to edit. A file can carry far +/// more (forms read up to 64 MiB of script), and laying that out in a text box every frame would +/// hang the dialog; of a longer one the start is shown, read-only, and the script is kept as it is +/// unless another calculation is chosen. The JavaScript engine runs no script past 256 KiB either. +const MAX_EDITED_SCRIPT: usize = 256 * 1024; + pub(super) fn calculate_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Tokens) { #[derive(PartialEq, Clone, Copy)] enum Mode { None, Simple, Notation, + Custom, } + // Choosing Custom again brings back the script the field had, not an empty box. + let custom = |d: &FieldDraft| match d.original.as_ref().as_ref().map(|o| &o.calculate) { + Some(Calculate::Custom(js)) => Calculate::Custom(js.clone()), + _ => Calculate::Custom(String::new()), + }; let mode = match d.calculate { Calculate::None => Mode::None, Calculate::Simple { .. } => Mode::Simple, Calculate::Notation(_) => Mode::Notation, + Calculate::Custom(_) => Mode::Custom, }; let mut m = mode; widgets::radio_value(ui, &mut m, Mode::None, tl!("Value is not calculated")); @@ -345,6 +358,7 @@ pub(super) fn calculate_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::th Mode::None => Calculate::None, Mode::Simple => Calculate::Simple { op: CalcOp::Sum, fields: Vec::new() }, Mode::Notation => Calculate::Notation(String::new()), + Mode::Custom => custom(d), }; } if let Calculate::Simple { op, fields } = &mut d.calculate { @@ -383,4 +397,58 @@ pub(super) fn calculate_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::th ui.add(crate::widgets::block(expr).hint_text("Price * Quantity").desired_rows(2).desired_width(420.0)); ui.label(egui::RichText::new(tl!("Field names with + - * / and parentheses; put \\ before spaces in names.")).small().color(t.text_faint)); } + let mut m3 = match d.calculate { + Calculate::Custom(_) => Mode::Custom, + _ => m2, + }; + let radio = widgets::radio_value(ui, &mut m3, Mode::Custom, tl!("Custom calculation script:")); + if radio.clicked() && !matches!(d.calculate, Calculate::Custom(_)) { + d.calculate = custom(d); + } + if !matches!(d.calculate, Calculate::Custom(_)) { + d.script_refused = false; + } + if let Calculate::Custom(js) = &mut d.calculate { + // The box is named by its radio, and a note about it is its description too, so a screen + // reader on the box hears it. + let script_box = |ui: &mut egui::Ui, text: &mut dyn egui::TextBuffer| { + let edit = crate::widgets::block(text).code_editor().hint_text("event.value = …;").desired_rows(6).desired_width(420.0); + let scroll = egui::ScrollArea::vertical().id_salt("calc-script").max_height(180.0); + scroll.show(ui, |ui| ui.add(edit.char_limit(MAX_EDITED_SCRIPT + 1)).labelled_by(radio.id).id).inner + }; + let describe = |ui: &egui::Ui, id: egui::Id, note: &str| { + let note = note.to_owned(); + ui.ctx().accesskit_node_builder(id, |node| node.set_description(note)); + }; + if js.len() > MAX_EDITED_SCRIPT { + // Too long to edit: its start is shown, read-only (selectable, so it can be copied). + let kib = (js.len() / 1024).to_string(); + let note = + crate::i18n::fmt(tl!("This script is too long to edit here ({kib} KiB); its start is shown. It is kept as it is."), &[("kib", &kib)]); + let mut start: &str = &js[..js.floor_char_boundary(MAX_EDITED_SCRIPT)]; + let id = script_box(ui, &mut start); + describe(ui, id, ¬e); + ui.label(egui::RichText::new(note).small().color(t.text_faint)); + } else { + // The box edits a copy: an edit that would take the script past the limit (a large + // paste) is refused and said so, rather than hiding the box the user is typing in. + // The box itself takes at most one character past the limit, so a huge paste is cut + // there before it is laid out, and is still over the limit to refuse. The limit is on + // bytes (`len`), as the JavaScript engine's is; the cut counts characters, which is + // what laying out costs. + let mut draft = js.clone(); + let id = script_box(ui, &mut draft); + if draft != *js { + d.script_refused = draft.len() > MAX_EDITED_SCRIPT; + if !d.script_refused { + *js = draft; + } + } + if d.script_refused { + let refusal = tl!("A script edited here can be at most 256 KiB long; that change wasn't made."); + describe(ui, id, refusal); + ui.colored_label(ui.visuals().error_fg_color, refusal); + } + } + } } diff --git a/crates/ui-egui/tests/it/prepare.rs b/crates/ui-egui/tests/it/prepare.rs index 3d9c8e968f..81e96c0311 100644 --- a/crates/ui-egui/tests/it/prepare.rs +++ b/crates/ui-egui/tests/it/prepare.rs @@ -336,6 +336,229 @@ fn format_validate_and_calculate_tabs() { assert_eq!(f.actions.calculate, Calculate::Notation("name".into())); } +/// The radio of the Calculate tab's custom calculation script (#6630). +const CUSTOM: &str = "Custom calculation script:"; + +/// What the Calculate tab says when an edit would take the custom script past 256 KiB. +const REFUSAL: &str = "A script edited here can be at most 256 KiB long; that change wasn't made."; + +/// The longest custom script the Calculate tab edits, in bytes. +const MAX_EDITED_SCRIPT: usize = 256 * 1024; + +/// The harness in Prepare Form, with `city`'s calculation set to `js`, a script of its own. +fn with_calculate_script(js: &str) -> Harness<'static, PdfCraftApp> { + let mut h = harness(); + set_calculate_script(&mut h, js); + h.state_mut().execute("form.prepare"); + h.run_steps(2); + h +} + +/// Sets `city`'s calculate script, as form_set_script does. +fn set_calculate_script(h: &mut Harness<'static, PdfCraftApp>, js: &str) { + let edit = pdfcraft_engine::Edit::SetFieldScript { name: "city".into(), event: "calculate".into(), script: Some(js.into()) }; + assert!(h.state_mut().apply_edit(edit)); + h.run_steps(2); +} + +/// Opens Field Properties of `name` on the Calculate tab. +fn open_calculate(h: &mut Harness<'static, PdfCraftApp>, name: &str) { + h.state_mut().open_field_props(name, 0); + h.run_steps(2); + h.get_by_label("Calculate").click(); + h.run_steps(2); +} + +/// Clicks the dialog button `label` (OK or Cancel). +fn press(h: &mut Harness<'static, PdfCraftApp>, label: &str) { + h.get_by_label(label).click(); + h.run_steps(3); +} + +/// Clicks the Calculate tab's radio `label`. +fn choose(h: &mut Harness<'static, PdfCraftApp>, label: &str) { + h.get_by_role_and_label(Role::RadioButton, label).click(); + h.run_steps(2); +} + +/// Whether the Calculate tab's radio `label` is selected. +fn chosen(h: &Harness<'static, PdfCraftApp>, label: &str) -> bool { + use egui_kittest::kittest::NodeT; + h.get_by_role_and_label(Role::RadioButton, label).accesskit_node().toggled() == Some(egui::accesskit::Toggled::True) +} + +/// `city`'s calculation as the document has it: the recognised one and the script of its own. +fn city_calculation(h: &Harness<'static, PdfCraftApp>) -> (pdfcraft_engine::form_scripts::Calculate, Option) { + let s = h.state(); + let f = s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == "city").unwrap().clone(); + (f.actions.calculate, f.actions.scripts.calculate) +} + +/// What each multi-line box on the tab holds. +fn script_boxes(h: &Harness<'static, PdfCraftApp>) -> Vec { + h.query_all_by_role(Role::MultilineTextInput).filter_map(|n| n.value()).collect() +} + +/// The script box's description: what a screen reader says about it besides its text. +fn script_box_description(h: &Harness<'static, PdfCraftApp>) -> Option { + use egui_kittest::kittest::NodeT; + h.get_by_role_and_label(Role::MultilineTextInput, CUSTOM).accesskit_node().description() +} + +/// Selects everything in the script box and types `text` over it. +fn retype_script(h: &mut Harness<'static, PdfCraftApp>, text: &str) { + h.get_by_role(Role::MultilineTextInput).click(); + h.run_steps(1); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.get_by_role(Role::MultilineTextInput).type_text(text); + h.run_steps(2); +} + +/// Pastes `text` into the script box where its cursor is. +fn paste_script(h: &mut Harness<'static, PdfCraftApp>, text: &str) { + h.get_by_role(Role::MultilineTextInput).click(); + h.run_steps(1); + h.event(egui::Event::Paste(text.into())); + h.run_steps(2); +} + +/// A field whose calculation is a script of its own shows it on the Calculate tab as a custom +/// calculation script, not as "Value is not calculated" (#6630); another choice and back brings +/// the script back, and OK without a change keeps it. +#[test] +fn the_calculate_tab_shows_a_custom_calculation_script() { + let js = "event.value = \"*\" + this.getField(\"name\").value + \"*\";"; + let mut h = with_calculate_script(js); + open_calculate(&mut h, "city"); + assert!(chosen(&h, CUSTOM), "the script is the field's calculation"); + assert!(!chosen(&h, "Value is not calculated")); + assert_eq!(script_boxes(&h), [js]); + assert_eq!(script_box_description(&h), None, "nothing to say about it"); + choose(&mut h, "Simplified field notation:"); + assert!(!script_boxes(&h).contains(&js.to_owned())); + choose(&mut h, CUSTOM); + assert_eq!(script_boxes(&h), [js]); + assert_eq!(h.state().field_props.as_ref().and_then(|d| d.props()), None, "an untouched tab changes nothing"); + press(&mut h, "OK"); + assert_eq!(city_calculation(&h), (pdfcraft_engine::form_scripts::Calculate::None, Some(js.to_owned()))); +} + +/// Only a script that is no sum or notation opens as a custom script: a sum stays a sum, and a +/// field with no calculation opens on "Value is not calculated", with no script box. +#[test] +fn a_sum_or_no_calculation_does_not_open_as_a_custom_script() { + let mut h = with_calculate_script("AFSimple_Calculate(\"SUM\", new Array(\"name\"));"); + open_calculate(&mut h, "city"); + assert!(chosen(&h, "Value is the"), "the sum is recognised"); + assert!(!chosen(&h, CUSTOM)); + assert!(script_boxes(&h).is_empty()); + press(&mut h, "Cancel"); + open_calculate(&mut h, "name"); + assert!(chosen(&h, "Value is not calculated")); + assert!(!chosen(&h, CUSTOM)); + assert!(script_boxes(&h).is_empty()); +} + +/// An edited custom script is written as typed, and "Value is not calculated" removes it. +#[test] +fn an_edited_custom_script_is_written_and_no_calculation_removes_it() { + use pdfcraft_engine::form_scripts::Calculate; + let mut h = with_calculate_script("event.value = 1;"); + open_calculate(&mut h, "city"); + let edited = "event.value = this.getField(\"name\").value; // città"; + retype_script(&mut h, edited); + assert_eq!(script_boxes(&h), [edited]); + press(&mut h, "OK"); + assert_eq!(city_calculation(&h), (Calculate::None, Some(edited.to_owned()))); + open_calculate(&mut h, "city"); + choose(&mut h, "Value is not calculated"); + press(&mut h, "OK"); + assert_eq!(city_calculation(&h), (Calculate::None, None)); +} + +/// An edit is taken up to exactly 256 KiB, counted in bytes as the JavaScript engine counts them; +/// one past it, by a byte or by a multi-byte paste of fewer characters, is refused, the box keeps +/// what it had, and the refusal is said beside the box and in its description until the next edit +/// is taken. +#[test] +fn an_edit_past_256_kib_is_refused_and_the_next_edit_is_taken() { + use pdfcraft_engine::form_scripts::Calculate; + let js = "event.value = 1;"; + let mut h = with_calculate_script(js); + open_calculate(&mut h, "city"); + let refused = |h: &Harness<'static, PdfCraftApp>| (h.query_by_label(REFUSAL).is_some(), script_box_description(h)); + assert_eq!(refused(&h), (false, None)); + // 100 Ki euro signs are 300 KiB: fewer characters than the limit, more bytes. + for paste in ["x".repeat(300 * 1024), "€".repeat(100 * 1024)] { + paste_script(&mut h, &paste); + assert_eq!(refused(&h), (true, Some(REFUSAL.to_owned())), "{} bytes", paste.len()); + assert_eq!(script_boxes(&h), [js], "the box keeps what it had"); + } + // Exactly at the limit is taken, and the refusal is no longer said. + let full = format!("/*{}*/", "x".repeat(MAX_EDITED_SCRIPT - 4)); + retype_script(&mut h, &full); + assert_eq!(refused(&h), (false, None)); + assert!(script_boxes(&h) == [full.as_str()], "the box holds the full 256 KiB"); + // One byte more is not. + h.key_press(egui::Key::End); + h.get_by_role(Role::MultilineTextInput).type_text(" "); + h.run_steps(2); + assert!(refused(&h).0, "256 KiB and a byte"); + assert!(script_boxes(&h) == [full.as_str()]); + press(&mut h, "OK"); + assert!(city_calculation(&h) == (Calculate::None, Some(full))); +} + +/// The refusal is forgotten when another calculation is chosen or the dialog is opened again, and +/// choosing Custom again brings back the field's own script, not an edit that was left. +#[test] +fn a_refusal_is_forgotten_on_another_choice_or_reopening() { + use pdfcraft_engine::form_scripts::Calculate; + let js = "event.value = 1;"; + let mut h = with_calculate_script(js); + open_calculate(&mut h, "city"); + retype_script(&mut h, "event.value = 2;"); + paste_script(&mut h, &"x".repeat(300 * 1024)); + assert!(h.query_by_label(REFUSAL).is_some()); + choose(&mut h, "Simplified field notation:"); + assert!(h.query_by_label(REFUSAL).is_none(), "another choice forgets it"); + choose(&mut h, CUSTOM); + assert!(h.query_by_label(REFUSAL).is_none()); + assert_eq!(script_box_description(&h), None); + assert_eq!(script_boxes(&h), [js], "the field's own script, not the edit left behind"); + paste_script(&mut h, &"x".repeat(300 * 1024)); + assert!(h.query_by_label(REFUSAL).is_some()); + press(&mut h, "Cancel"); + open_calculate(&mut h, "city"); + assert!(h.query_by_label(REFUSAL).is_none(), "opening the dialog again forgets it"); + press(&mut h, "OK"); + assert_eq!(city_calculation(&h), (Calculate::None, Some(js.to_owned()))); +} + +/// A script past 256 KiB isn't edited: the box shows its start, read-only (cut at a character, not +/// inside one), says why, and OK keeps the whole script. +#[test] +fn a_script_past_256_kib_shows_its_start_and_is_kept() { + use pdfcraft_engine::form_scripts::Calculate; + // 18 + 3 × 100 Ki + 2 bytes: 256 KiB falls inside a euro sign. + let long = format!("event.value = 1;/*{}*/", "€".repeat(100 * 1024)); + let mut h = with_calculate_script(&long); + open_calculate(&mut h, "city"); + assert!(chosen(&h, CUSTOM)); + let note = "This script is too long to edit here (300 KiB); its start is shown. It is kept as it is."; + assert!(h.query_by_label(note).is_some()); + assert_eq!(script_box_description(&h).as_deref(), Some(note)); + let start = &long[..MAX_EDITED_SCRIPT - 1]; + assert!(script_boxes(&h) == [start], "the box shows the start, up to the last whole character"); + h.get_by_role(Role::MultilineTextInput).click(); + h.run_steps(1); + h.get_by_role(Role::MultilineTextInput).type_text("x"); + h.run_steps(2); + assert!(script_boxes(&h) == [start], "and can't be typed in"); + press(&mut h, "OK"); + assert!(city_calculation(&h) == (Calculate::None, Some(long))); +} + /// Renaming a field in Field Properties renames it in the formulas that read it, in the same undo /// step, and says how many changed. #[test] diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 0bb6e07eac..7c59700dcc 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -4806,8 +4806,8 @@ milestone = "M6" status = "shipped" commands = ["form.prepare"] tools = ["form_set_script"] -evidence = ["crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats", "crates/automation/tests/it/automation.rs::javascript_through_tools", "crates/engine/src/tests.rs::a_calculate_script_setting_its_own_field_object_keeps_the_value", "crates/engine/src/tests.rs::a_calculate_script_assigning_event_value_wins_and_a_combo_keeps_its_selection", "crates/forms/src/tests.rs::a_calculated_combo_stores_the_export_value_of_the_option_it_names", "crates/forms/src/tests.rs::an_export_value_wins_over_an_earlier_options_display_text"] -notes = "Custom keystroke (commit), validate, calculate and format scripts run in Acrobat's event order; AF calls still run natively. A calculate script may set its own field through the field object (getField(\"Total\").value = …) instead of event.value; that value is kept, text included, unless the script also assigns event.value (even the value it already had). A combo box a calculation moves to another option, named by export value (first) or display text, stores the export value in /V and selects it in /I." +evidence = ["crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats", "crates/automation/tests/it/automation.rs::javascript_through_tools", "crates/engine/src/tests.rs::a_calculate_script_setting_its_own_field_object_keeps_the_value", "crates/engine/src/tests.rs::a_calculate_script_assigning_event_value_wins_and_a_combo_keeps_its_selection", "crates/forms/src/tests.rs::a_calculated_combo_stores_the_export_value_of_the_option_it_names", "crates/forms/src/tests.rs::an_export_value_wins_over_an_earlier_options_display_text", "crates/ui-egui/tests/it/prepare.rs::the_calculate_tab_shows_a_custom_calculation_script", "crates/ui-egui/tests/it/prepare.rs::an_edited_custom_script_is_written_and_no_calculation_removes_it", "crates/ui-egui/tests/it/prepare.rs::an_edit_past_256_kib_is_refused_and_the_next_edit_is_taken", "crates/ui-egui/tests/it/prepare.rs::a_script_past_256_kib_shows_its_start_and_is_kept", "crates/forms/src/tests.rs::a_custom_calculation_script_is_written_as_given_and_a_blank_one_removes_it"] +notes = "Custom keystroke (commit), validate, calculate and format scripts run in Acrobat's event order; AF calls still run natively. Field Properties ▸ Calculate ▸ Custom calculation script shows a field's calculate script and edits it (of one past 256 KiB the start is shown, read-only, and the script is kept); the Format and Validate tabs don't offer their custom scripts yet, so those are set with form_set_script. A calculate script may set its own field through the field object (getField(\"Total\").value = …) instead of event.value; that value is kept, text included, unless the script also assigns event.value (even the value it already had). A combo box a calculation moves to another option, named by export value (first) or display text, stores the export value in /V and selects it in /I." [[feature]] id = "form.calculation-order"