mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-11 00:41:21 +00:00
* Fix #5211: persist JavaScript field colours * Read current document state in field scripts * Redraw form fields after validation changes * Format validation redraw regression test * Keep field list alive in redraw test * Refresh fields only after same-field scripts * Format conditional field refresh * Borrow accepted field value for storage * Redraw calculated fields from current appearance * Format JavaScript field state merge * Restore JavaScript runner document state * Group JavaScript runner form inputs * Read current field display state in scripts --------- Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
This commit is contained in:
1 parent
d0b17ef95f
commit
832a51e6d7
8 files changed
+335
-20
No files matched your search
@@ -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<pdfcraft_forms::FieldChange>,
|
||||
},
|
||||
|
||||
+68
-6
@@ -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::<Vec<_>>();
|
||||
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<Vec<String>>, Option<Vec<String>>);
|
||||
|
||||
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<pdfcraft_cos::ObjRef, i32>,
|
||||
/// Field colours read from `cos`, cached for the same reason as `displays`.
|
||||
colours: std::collections::HashMap<pdfcraft_cos::ObjRef, Colours>,
|
||||
/// 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<String, Colours>,
|
||||
/// Whether a document-level script names a colour property (`fillColor`, `textColor`, …).
|
||||
doc_scripts_use_colours: bool,
|
||||
calculations: Option<std::sync::Arc<Vec<(String, String)>>>,
|
||||
}
|
||||
|
||||
@@ -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<Vec<(String, String)>> {
|
||||
@@ -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<ScriptResult> {
|
||||
// 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(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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<Vec<u8>>| {
|
||||
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).
|
||||
|
||||
@@ -44,6 +44,10 @@ pub struct FieldChange {
|
||||
pub required: Option<bool>,
|
||||
/// `display.visible` 0, `hidden` 1, `noPrint` 2, `noView` 3.
|
||||
pub display: Option<i32>,
|
||||
/// 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<Vec<String>>,
|
||||
pub fill_color: Option<Vec<String>>,
|
||||
pub caption: Option<String>,
|
||||
/// 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<String, FormError> {
|
||||
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<Option<[f64; 3]>, 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::<f64>().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> {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user