M6: the Calculate tab shows and edits a field's custom calculation script (#6630) (#6717)

This commit is contained in:
ljkx authored and GitHub committed 2026-11-10 10:00:52 +07:00
1 parent 5b67f8d021
commit 0d853dace7
11 files changed
+414 -8

No files matched your search

+1 -1
View File
@@ -176,7 +176,7 @@ fn calculate_json(f: &FormField) -> Option<Value> {
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,
}
}
+6
View File
@@ -98,6 +98,12 @@ pub enum Calculate {
Simple { op: CalcOp, fields: Vec<String> },
/// 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.
+3 -1
View File
@@ -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<String>) -> Option<String> {
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.)
+3
View File
@@ -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<String> {
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()),
+14
View File
@@ -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];
+20 -3
View File
@@ -94,6 +94,21 @@ fn button_export_options(f: &Field, index: usize, value: &str) -> Result<Vec<Obj
Ok(out)
}
/// The `/AA /C` action that stores `c`; `None` when it stores no calculation. A custom script is
/// the author's own text, so it is written as a text string, as `set_field_script` writes it, and
/// a letter outside ASCII reads the same in every viewer. The AF calls are written as they are.
fn calculate_action(c: &crate::af::Calculate) -> Option<Object> {
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<String, FormError> {
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<S
}
if let Some(c) = &props.calculate {
aa.remove(b"C");
if let Some(js) = crate::af::calculate_js(c) {
aa.set(b"C".to_vec(), action(&js));
let action = calculate_action(c);
let calculated = action.is_some();
if let Some(action) = action {
aa.set(b"C".to_vec(), action);
}
crate::calc_order::keep_listed(doc, f.obj, *c != crate::af::Calculate::None)?;
crate::calc_order::keep_listed(doc, f.obj, calculated)?;
}
doc.update_dict(f.obj, |d| {
if aa.is_empty() {
+64
View File
@@ -1726,6 +1726,70 @@ fn a_script_that_spells_the_markers_in_a_string_runs_as_a_script() {
assert_eq!(field(&fields(&doc), "total").value, ["ran"]);
}
/// The `/JS` string of field `name`'s `/AA /C` action, as stored.
fn calculate_action_js(doc: &Document, name: &str) -> Option<PdfString> {
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::<Vec<_>>(), [("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() {
+10 -1
View File
@@ -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<String>,
/// 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(),
+68
View File
@@ -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, &note);
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);
}
}
}
}
+223
View File
@@ -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<String>) {
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<String> {
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<String> {
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]
+2 -2
View File
@@ -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"