diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs index 1d5181bf65..a6b1f0b2bf 100644 --- a/crates/engine/src/edit.rs +++ b/crates/engine/src/edit.rs @@ -452,8 +452,8 @@ pub enum Edit { RemoveStandardId { standard: Standard, }, - /// What a script (button or console) changed in form fields: values, read-only, required - /// and visibility. + /// What a script (button or console) changed in form fields: values, appearance colours, + /// read-only, required and visibility. ApplyScriptChanges { changes: Vec, }, diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index eaa4677890..f73be5ec03 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -208,9 +208,22 @@ fn display(doc: &pdfcraft_cos::Document, f: &Field) -> i32 { /// A form field as scripts see it. pub fn field_state(doc: &pdfcraft_cos::Document, f: &Field) -> pdfcraft_js::FieldState { - field_state_with_display(f, display(doc, f)) + let mut s = field_state_with_display(f, display(doc, f)); + (s.text_color, s.fill_color) = colours(doc, f); + s } +/// `field.textColor` and `field.fillColor` as Acrobat colour arrays, from the field's look +/// (`None` for a transparent fill, which scripts read as `["T"]`). +fn colours(doc: &pdfcraft_cos::Document, f: &Field) -> Colours { + let rgb = |c: [f64; 3]| std::iter::once("RGB".to_string()).chain(c.map(|v| v.to_string())).collect::>(); + let look = pdfcraft_forms::look(doc, f); + (Some(rgb(look.text)), look.fill.map(rgb)) +} + +/// A field's text and fill colours as scripts read them. +type Colours = (Option>, Option>); + fn field_state_with_display(f: &Field, display: i32) -> pdfcraft_js::FieldState { let mut s = pdfcraft_js::FieldState::new(f.name.clone(), kind(f.kind), f.value.clone()); s.default = f.default.clone(); @@ -255,9 +268,18 @@ fn changes( display: (a.display != b.display).then_some(a.display), caption: a.caption_was_set.then(|| a.caption.clone()).flatten(), rect: (a.rect != b.rect).then_some(a.rect).flatten(), + text_color: (a.text_color != b.text_color).then(|| a.text_color.clone()).flatten(), + fill_color: (a.fill_color != b.fill_color).then(|| a.fill_color.clone()).flatten(), }; - (c.value.is_some() || c.read_only.is_some() || c.required.is_some() || c.display.is_some() || c.caption.is_some() || c.rect.is_some()) - .then_some(c) + (c.value.is_some() + || c.read_only.is_some() + || c.required.is_some() + || c.display.is_some() + || c.caption.is_some() + || c.rect.is_some() + || c.text_color.is_some() + || c.fill_color.is_some()) + .then_some(c) }) .collect() } @@ -274,6 +296,14 @@ pub struct JsRunner { // otherwise reparse every widget for every calculation. Values/flags still come from // the live `fields` snapshot on each call. A new edit gets a new runner and cache. displays: std::collections::HashMap, + /// Field colours read from `cos`, cached for the same reason as `displays`. + colours: std::collections::HashMap, + /// Colours this runner's scripts wrote, by field name: they reach the document only once + /// the edit applies each script's changes, after `cos` was taken, so a later script in the + /// same edit reads them from here. + written_colours: std::collections::HashMap, + /// Whether a document-level script names a colour property (`fillColor`, `textColor`, …). + doc_scripts_use_colours: bool, calculations: Option>>, } @@ -300,7 +330,18 @@ impl JsRunner { // Packets alone are not enough: static XFA and an unlaid-out shell keep their scripts. let xfa_active = xfa && pdfcraft_xfa::existing_layout(cos).is_some(); let doc_scripts = if xfa_active { Vec::new() } else { pdfcraft_forms::document_scripts(cos) }; - JsRunner { doc, doc_scripts, output: JsOutput::default(), cos: cos.clone(), displays: std::collections::HashMap::new(), calculations: None } + let doc_scripts_use_colours = doc_scripts.iter().any(|source| source.contains("Color")); + JsRunner { + doc, + doc_scripts, + output: JsOutput::default(), + cos: cos.clone(), + displays: std::collections::HashMap::new(), + colours: std::collections::HashMap::new(), + written_colours: std::collections::HashMap::new(), + doc_scripts_use_colours, + calculations: None, + } } fn calculations(&mut self, fields: &[Field]) -> std::sync::Arc> { @@ -315,6 +356,8 @@ impl JsRunner { pub(crate) fn invalidate_calculations(&mut self, cos: &pdfcraft_cos::Document) { self.cos = cos.clone(); self.displays.clear(); + self.colours.clear(); + self.written_colours.clear(); self.calculations = None; } } @@ -399,6 +442,12 @@ impl JsRunner { /// [`Scripts::run`], keeping what the script showed and asked for only when `log`; `None` /// when the script failed. fn run_logged(&mut self, event: FieldEvent, script: &str, target: &Field, value: &str, fields: &[Field], log: bool) -> Option { + // Field colours are read (and their arrays built for every field) only for a script + // that can reach them: one that names a colour property, or any script when a document + // script does. A large form's calculations then cost what they did before colours were + // readable. A property name built at run time (`f["fill" + "Color"]`) reads as + // transparent in a script that names no colour. + let reads_colours = self.doc_scripts_use_colours || script.contains("Color"); let states: Vec<_> = fields .iter() .map(|f| { @@ -406,7 +455,14 @@ impl JsRunner { Some(w) => *self.displays.entry(w.obj).or_insert_with(|| display(&self.cos, f)), None => pdfcraft_js::DISPLAY_VISIBLE, }; - field_state_with_display(f, display) + let mut state = field_state_with_display(f, display); + if reads_colours { + (state.text_color, state.fill_color) = match self.written_colours.get(&f.name) { + Some(written) => written.clone(), + None => self.colours.entry(f.obj).or_insert_with(|| colours(&self.cos, f)).clone(), + }; + } + state }) .collect(); let states = std::sync::Arc::new(states); @@ -431,11 +487,17 @@ impl JsRunner { if o.error.is_some() { return None; } + let changes = changes(&states, &o.changed, &o.formatted); + for c in changes.iter().filter(|c| c.text_color.is_some() || c.fill_color.is_some()) { + if let Some(state) = o.changed.iter().find(|s| s.name == c.name) { + self.written_colours.insert(c.name.clone(), (state.text_color.clone(), state.fill_color.clone())); + } + } Some(ScriptResult { rc: o.rc, value: o.value.clone(), value_set: o.value_set, - changes: changes(&states, &o.changed, &o.formatted), + changes, message: (!o.rc).then(|| o.alerts.last().map(|m| m.text.clone())).flatten(), }) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index db7fca7559..5952efab88 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -6669,6 +6669,89 @@ fn form_javascript_validates_calculates_and_formats() { assert!(s.run_javascript(id, "1", None).is_err()); } +#[test] +fn javascript_field_colours_persist_and_undo_without_changing_other_appearance_properties() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("order.pdf", None, Arc::new(scripted_form()), None).unwrap(); + s.apply( + id, + Edit::SetFieldProps { + name: "price".into(), + props: Box::new(FieldProps { + appearance: Some(pdfcraft_forms::LookPatch { + border: Some(Some([0.1, 0.2, 0.3])), + font: Some(pdfcraft_forms::FieldFont::Courier), + ..Default::default() + }), + ..Default::default() + }), + }, + ) + .unwrap(); + let before = s.get(id).unwrap().form.iter().find(|f| f.name == "price").unwrap().value.clone(); + let out = s.run_javascript(id, "var f = getField('price'); f.fillColor = color.red; f.textColor = color.blue;", None).unwrap(); + assert_eq!(out.error, None); + assert_eq!(s.get(id).unwrap().can_undo(), Some("Run JavaScript")); + + let inspect = |bytes: Arc>| { + let doc = pdfcraft_cos::Document::open(bytes).unwrap(); + let f = pdfcraft_forms::fields(&doc).into_iter().find(|f| f.name == "price").unwrap(); + let look = pdfcraft_forms::look(&doc, &f); + assert_eq!(look.fill, Some([1.0, 0.0, 0.0])); + assert_eq!(look.text, [0.0, 0.0, 1.0]); + assert_eq!(look.border, Some([0.1, 0.2, 0.3])); + assert_eq!(look.font, pdfcraft_forms::FieldFont::Courier); + assert_eq!(f.value, before); + }; + inspect(s.save_bytes(id).unwrap()); + s.undo(id).unwrap(); + let undone = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap(); + let f = pdfcraft_forms::fields(&undone).into_iter().find(|f| f.name == "price").unwrap(); + assert_eq!(pdfcraft_forms::look(&undone, &f).fill, None); + s.redo(id).unwrap(); + inspect(s.save_bytes(id).unwrap()); + + let reopened = s.open("saved.pdf", None, s.save_bytes(id).unwrap(), None).unwrap(); + let reopened_doc = pdfcraft_cos::Document::open(s.save_bytes(reopened).unwrap()).unwrap(); + let f = pdfcraft_forms::fields(&reopened_doc).into_iter().find(|f| f.name == "price").unwrap(); + assert_eq!(pdfcraft_forms::look(&reopened_doc, &f).fill, Some([1.0, 0.0, 0.0])); + assert_eq!(pdfcraft_forms::look(&reopened_doc, &f).text, [0.0, 0.0, 1.0]); +} + +#[test] +fn javascript_invalid_field_colour_array_is_refused_without_partial_changes() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("order.pdf", None, Arc::new(scripted_form()), None).unwrap(); + let before = s.save_bytes(id).unwrap(); + let err = s.run_javascript(id, "var f = getField('price'); f.value = '9'; f.fillColor = ['RGB', 2, 0, 0];", None).unwrap_err(); + assert!(err.to_string().contains("finite values between 0 and 1"), "{err}"); + assert_eq!(s.save_bytes(id).unwrap(), before, "a refused script changes nothing"); +} + +#[test] +fn javascript_field_colours_respect_document_fill_permissions() { + let mut plain = pdfcraft_cos::Document::open(Arc::new(scripted_form())).unwrap(); + plain + .set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes256, + user_password: "", + owner_password: "owner", + permissions: 0, + encrypt_metadata: true, + seed: [7; 32], + }) + .unwrap(); + let bytes = Arc::new(pdfcraft_cos::write_full(&plain, &Default::default()).unwrap()); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("locked.pdf", None, bytes, None).unwrap(); + assert!(!s.get(id).unwrap().allows_form_filling()); + assert_eq!( + s.run_javascript(id, "getField('price').fillColor = color.red;", None).unwrap_err(), + EditError::NotPermitted("filling in form fields") + ); + assert!(!s.get(id).unwrap().dirty); +} + #[test] fn calculate_now_updates_live_fields_in_one_undo_step() { let mut s = Session::new().with_clock(|| 1_700_000_000); @@ -13859,6 +13942,67 @@ fn a_document_encrypting_only_its_embedded_files_opens_and_saves_without_a_passw assert!(reopened.embedded_files_locked(), "the attachment stays encrypted"); } +#[test] +fn javascript_non_rgb_field_colours_convert_and_keep_the_scripts_other_changes() { + let fill_after = |colour: &str| { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("order.pdf", None, Arc::new(scripted_form()), None).unwrap(); + s.run_javascript(id, "getField('price').fillColor = color.red;", None).unwrap(); + let out = s.run_javascript(id, &format!("var f = getField('price'); f.value = '9'; f.fillColor = {colour}; f.textColor = {colour};"), None); + assert!(out.is_ok(), "{colour}: {out:?}"); + let f = s.get(id).unwrap().form.iter().find(|f| f.name == "price").unwrap().clone(); + assert_eq!(f.value, ["9"], "{colour}: the value write is kept"); + let doc = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap(); + let f = pdfcraft_forms::fields(&doc).into_iter().find(|f| f.name == "price").unwrap(); + let look = pdfcraft_forms::look(&doc, &f); + (look.fill, look.text) + }; + assert_eq!(fill_after("color.white"), (Some([1.0, 1.0, 1.0]), [1.0, 1.0, 1.0])); + assert_eq!(fill_after("color.gray"), (Some([0.5, 0.5, 0.5]), [0.5, 0.5, 0.5])); + assert_eq!(fill_after("color.cyan"), (Some([0.0, 1.0, 1.0]), [0.0, 1.0, 1.0])); + assert_eq!(fill_after("['CMYK', 0, 0, 0, 1]"), (Some([0.0, 0.0, 0.0]), [0.0, 0.0, 0.0])); + // Transparent clears the fill; text can't be transparent, so its colour stays. + let (fill, text) = fill_after("color.transparent"); + assert_eq!(fill, None); + assert_eq!(text, [0.0, 0.0, 0.0]); + + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("order.pdf", None, Arc::new(scripted_form()), None).unwrap(); + let before = s.save_bytes(id).unwrap(); + for bad in ["['CMYK', 1]", "['G']", "['T', 0]", "['Lab', 0, 0, 0]", "['G', 'x']"] { + let err = s.run_javascript(id, &format!("var f = getField('price'); f.value = '9'; f.fillColor = {bad};"), None).unwrap_err(); + assert!(err.to_string().contains("T, G, RGB or CMYK"), "{bad}: {err}"); + assert_eq!(s.save_bytes(id).unwrap(), before, "{bad}: a malformed colour still refuses the script"); + } +} + +#[test] +fn a_later_field_script_in_the_same_edit_reads_a_colour_an_earlier_one_wrote() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("order.pdf", None, Arc::new(scripted_form()), None).unwrap(); + for (name, script) in + [("qty", "getField('price').fillColor = color.green; event.value = 1;"), ("total", "event.value = getField('price').fillColor.join(',');")] + { + s.apply(id, Edit::SetFieldScript { name: name.into(), event: "calculate".into(), script: Some(script.into()) }).unwrap(); + } + s.apply(id, Edit::SetFieldScript { name: "total".into(), event: "format".into(), script: None }).unwrap(); + s.apply(id, Edit::SetCalculationOrder { names: vec!["qty".into(), "total".into()] }).unwrap(); + s.apply(id, Edit::SetFieldValue { name: "price".into(), value: FieldValue::Text("3".into()) }).unwrap(); + let total = s.get(id).unwrap().form.iter().find(|f| f.name == "total").unwrap().value.clone(); + assert_eq!(total, ["RGB,0,1,0"]); +} + +#[test] +fn a_document_script_helper_can_set_field_colours() { + let helpers = "function mark\\(name\\) { getField\\(name\\).fillColor = color.red; }"; + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("helpers.pdf", None, Arc::new(scripted_form_with(helpers)), None).unwrap(); + s.run_javascript(id, "mark('price');", None).unwrap(); + let doc = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap(); + let f = pdfcraft_forms::fields(&doc).into_iter().find(|f| f.name == "price").unwrap(); + assert_eq!(pdfcraft_forms::look(&doc, &f).fill, Some([1.0, 0.0, 0.0])); +} + #[test] fn calculate_now_simple_calculation_reads_formatted_values_and_groups_as_the_native_path_does() { // amount (4), group b (5) with kids b.c (6) and b.d (7), total (8) = SUM(amount, b). diff --git a/crates/forms/src/scripting.rs b/crates/forms/src/scripting.rs index 4a8c28fae8..c01b1f8842 100644 --- a/crates/forms/src/scripting.rs +++ b/crates/forms/src/scripting.rs @@ -44,6 +44,10 @@ pub struct FieldChange { pub required: Option, /// `display.visible` 0, `hidden` 1, `noPrint` 2, `noView` 3. pub display: Option, + /// Acrobat `textColor` / `fillColor` colour arrays: transparent `["T"]`, gray `["G", g]`, + /// `["RGB", r, g, b]` or `["CMYK", c, m, y, k]`, components between 0 and 1. + pub text_color: Option>, + pub fill_color: Option>, pub caption: Option, /// Move/resize the first widget, in PDF user space. pub rect: Option<[f64; 4]>, @@ -95,8 +99,9 @@ impl Scripts for NoScripts { /// Run the Keystroke (commit) and Validate scripts for a new text value. Returns the value to /// store, or why it is rejected. -pub(crate) fn accept(doc: &mut Document, f: &Field, value: &str, scripts: &mut dyn Scripts) -> Result { +pub(crate) fn accept(doc: &mut Document, f: &Field, value: &str, scripts: &mut dyn Scripts) -> Result<(String, bool), FormError> { let mut v = value.to_string(); + let mut refresh_field = false; for (event, script, default) in [ (FieldEvent::Keystroke, &f.actions.scripts.keystroke, "The value entered does not match the format of the field"), (FieldEvent::Validate, &f.actions.scripts.validate, "The value entered is not valid for the field"), @@ -104,13 +109,14 @@ pub(crate) fn accept(doc: &mut Document, f: &Field, value: &str, scripts: &mut d let Some(js) = script else { continue }; let all = fields(doc); let r = scripts.run(event, js, f, &v, &all); + refresh_field |= r.changes.iter().any(|change| change.name == f.name); apply_changes(doc, &r.changes, &f.name, scripts)?; if !r.rc { return Err(FormError::Invalid(r.message.unwrap_or_else(|| format!("{default} [ {} ]", f.name)))); } v = r.value; } - Ok(v) + Ok((v, refresh_field)) } /// The text a custom Format script shows for `value`, if the field has one. @@ -134,6 +140,12 @@ pub(crate) fn apply_changes(doc: &mut Document, changes: &[FieldChange], except: if changes.is_empty() { return Ok(()); } + // Validate every colour before applying any part of the script's changes. + for c in changes { + for color in [&c.text_color, &c.fill_color].into_iter().flatten() { + js_color(color)?; + } + } let all = fields(doc); for c in changes { let Some(f) = all.iter().find(|f| f.name == c.name) else { continue }; @@ -185,6 +197,17 @@ pub(crate) fn apply_changes(doc: &mut Document, changes: &[FieldChange], except: } } } + // Text can't be transparent: `["T"]` leaves its colour as it is. A transparent fill + // clears the background. + let text = c.text_color.as_deref().map(js_color).transpose()?.flatten(); + let fill = c.fill_color.as_deref().map(js_color).transpose()?; + if text.is_some() || fill.is_some() { + crate::set_props( + doc, + &c.name, + &crate::FieldProps { appearance: Some(crate::LookPatch { text, fill, ..Default::default() }), ..Default::default() }, + )?; + } if let Some(caption) = &c.caption { if f.kind != FieldKind::PushButton { return Err(FormError::Invalid(format!("{} is not a push button", f.name))); @@ -198,6 +221,32 @@ pub(crate) fn apply_changes(doc: &mut Document, changes: &[FieldChange], except: Ok(()) } +/// An Acrobat colour array as the RGB the look model stores, `None` for transparent (`["T"]`). +/// Gray and CMYK convert as Acrobat's `color.convert` does. Anything else (an unknown colour +/// space, the wrong number of components, or a component that is not a finite number between +/// 0 and 1) is refused, and with it the script's changes. +fn js_color(values: &[String]) -> Result, FormError> { + let invalid = || FormError::Invalid("JavaScript field colours must be T, G, RGB or CMYK arrays with finite values between 0 and 1".into()); + let (space, parts) = values.split_first().ok_or_else(invalid)?; + let mut numbers = [0.0; 4]; + if parts.len() > numbers.len() { + return Err(invalid()); + } + for (slot, part) in numbers.iter_mut().zip(parts) { + *slot = part.trim().parse::().ok().filter(|v| v.is_finite() && (0.0..=1.0).contains(v)).ok_or_else(invalid)?; + } + match (space.as_str(), parts.len()) { + ("T", 0) => Ok(None), + ("G", 1) => Ok(Some([numbers[0]; 3])), + ("RGB", 3) => Ok(Some([numbers[0], numbers[1], numbers[2]])), + ("CMYK", 4) => { + let [c, m, y, k] = numbers; + Ok(Some([1.0 - (c + k).min(1.0), 1.0 - (m + k).min(1.0), 1.0 - (y + k).min(1.0)])) + } + _ => Err(invalid()), + } +} + /// Apply what a button or console script changed (values, read-only, required, visibility), /// then recalculate. pub fn apply_script_changes(doc: &mut Document, changes: &[FieldChange], scripts: &mut dyn Scripts) -> Result<(), FormError> { diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 1dd0524272..859467744e 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -3657,8 +3657,7 @@ fn recalc_chain_where_script_changes_checkbox() { read_only: None, required: None, display: None, - caption: None, - rect: None, + ..Default::default() }], message: None, } @@ -3742,6 +3741,65 @@ fn an_export_value_wins_over_an_earlier_options_display_text() { } } +#[test] +fn validation_script_colour_is_used_when_the_field_redraws() { + struct TestScripts; + impl Scripts for TestScripts { + fn run(&mut self, _event: FieldEvent, _script: &str, _target: &Field, value: &str, _fields: &[Field]) -> ScriptResult { + ScriptResult { + rc: true, + value: value.into(), + value_set: false, + changes: vec![FieldChange { + name: "name".into(), + text_color: Some(vec!["RGB".into(), "1".into(), "0".into(), "0".into()]), + ..Default::default() + }], + message: None, + } + } + } + + let mut doc = fixture(); + set_field_script(&mut doc, "name", "validate", Some("set_red")).unwrap(); + set_value_with(&mut doc, "name", &FieldValue::Text("Grace".into()), &mut TestScripts).unwrap(); + + let all = fields(&doc); + let f = field(&all, "name"); + assert!(f.da.contains("1 0 0 rg")); + assert!(ap(&doc, &f.widgets[0]).contains("1 0 0 rg"), "redraw uses the Validate script's updated appearance"); +} + +#[test] +fn calculation_script_colour_is_used_when_the_field_redraws() { + struct TestScripts; + impl Scripts for TestScripts { + fn run(&mut self, _event: FieldEvent, _script: &str, _target: &Field, _value: &str, _fields: &[Field]) -> ScriptResult { + ScriptResult { + rc: true, + value: "Grace".into(), + value_set: true, + changes: vec![FieldChange { + name: "name".into(), + text_color: Some(vec!["RGB".into(), "1".into(), "0".into(), "0".into()]), + ..Default::default() + }], + message: None, + } + } + } + + let mut doc = fixture(); + set_field_script(&mut doc, "name", "calculate", Some("set_red")).unwrap(); + recalculate_with(&mut doc, &mut TestScripts).unwrap(); + + let all = fields(&doc); + let f = field(&all, "name"); + assert_eq!(f.value, ["Grace"]); + assert!(f.da.contains("1 0 0 rg")); + assert!(ap(&doc, &f.widgets[0]).contains("1 0 0 rg"), "redraw uses the Calculate script's updated appearance"); +} + #[test] fn partial_appearance_keeps_custom_default_appearance_and_font_resources() { let mut doc = fixture(); diff --git a/crates/forms/src/values.rs b/crates/forms/src/values.rs index b1d50a87ae..bbf1467415 100644 --- a/crates/forms/src/values.rs +++ b/crates/forms/src/values.rs @@ -262,7 +262,8 @@ pub fn recalculate_with(doc: &mut Document, scripts: &mut dyn Scripts) -> Result _ => {} } })?; - redraw(doc, &f, std::slice::from_ref(&v), scripts)?; + let fresh = all.iter().find(|field| field.obj == obj).cloned().unwrap_or_else(|| f.clone()); + redraw(doc, &fresh, std::slice::from_ref(&v), scripts)?; if let Some(target) = all.iter_mut().find(|x| x.obj == obj) { target.value = vec![v]; } @@ -305,17 +306,19 @@ pub(crate) fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scr // Keystroke (on commit) and Validate, as Acrobat runs them before accepting a value. let t = &f.actions.keystroke(&f.name, t).map_err(FormError::Format)?; af::validate(&f.actions.validate, t).map_err(FormError::Invalid)?; - let t = &scripting::accept(doc, f, t, scripts)?; + let (t, refresh_field) = scripting::accept(doc, f, t, scripts)?; let shadow = t.is_empty() && crate::author::ancestor_sets(doc, f.obj, b"V"); doc.update_dict(f.obj, |d| { if t.is_empty() && !shadow { d.remove(b"V"); } else { - d.set(b"V".to_vec(), PdfString::text(t)); + d.set(b"V".to_vec(), PdfString::text(&t)); } d.remove(b"RV"); })?; - redraw(doc, f, std::slice::from_ref(t), scripts) + let current = + if refresh_field { fields(doc).into_iter().find(|current| current.obj == f.obj).unwrap_or_else(|| f.clone()) } else { f.clone() }; + redraw(doc, ¤t, std::slice::from_ref(&t), scripts) } (FieldKind::CheckBox | FieldKind::Radio, FieldValue::Widget(i)) => { // A button can be read-only on its own (its own `/Ff`), though the field isn't. diff --git a/crates/ui-egui/src/canvas/area.rs b/crates/ui-egui/src/canvas/area.rs index 5ec07a1fb1..724e5270a5 100644 --- a/crates/ui-egui/src/canvas/area.rs +++ b/crates/ui-egui/src/canvas/area.rs @@ -205,8 +205,7 @@ pub(super) fn run_button(app: &mut PdfCraftApp, index: usize, name: &str, action read_only: None, required: None, display: Some(if hide { 1 } else { 0 }), - caption: None, - rect: None, + ..Default::default() }) .collect(); if !changes.is_empty() { diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 0d70edf70c..79423e76cb 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5375,8 +5375,8 @@ milestone = "M6" status = "shipped" commands = ["tools.js_console"] tools = ["js_run"] -evidence = ["crates/ui-egui/tests/it/javascript.rs::console_runs_scripts_against_the_document", "crates/automation/tests/it/automation.rs::javascript_through_tools", "crates/engine/src/tests.rs::calculate_now_updates_live_fields_in_one_undo_step"] -notes = "\u2318J: run JavaScript against the document, with console output, results and errors. calculateNow() runs calculated fields synchronously in /CO order against writes already made by the script." +evidence = ["crates/ui-egui/tests/it/javascript.rs::console_runs_scripts_against_the_document", "crates/automation/tests/it/automation.rs::javascript_through_tools", "crates/engine/src/tests.rs::javascript_field_colours_persist_and_undo_without_changing_other_appearance_properties", "crates/engine/src/tests.rs::javascript_invalid_field_colour_array_is_refused_without_partial_changes", "crates/engine/src/tests.rs::calculate_now_updates_live_fields_in_one_undo_step"] +notes = "\u2318J: run JavaScript against the document, with console output, results and errors. `fillColor` and `textColor` changes (transparent, gray, RGB or CMYK) persist through save/reopen and participate in undo/redo; malformed or out-of-range colour arrays are refused without applying partial script changes. calculateNow() runs calculated fields synchronously in /CO order against writes already made by the script." [[feature]] id = "form.js-debugger"