mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 13:01:29 +00:00
This commit is contained in:
11 files changed
+414
-8
No files matched your search
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.)
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user