M6: Field Properties ▸ Actions tab (six triggers, JavaScript/link/reset/menu/page/show-hide/submit actions); form_actions, form_set_actions tools; signing UI tests get their own temp folders
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
6ff0bf8f64
commit
620ef4fcad
|
|
@ -259,6 +259,73 @@ impl Automation {
|
|||
Ok(json!({ "path": target.to_string_lossy(), "rows": files.len(), "columns": columns }))
|
||||
}
|
||||
|
||||
pub(crate) fn form_actions(&self, a: &Args) -> Result<Value> {
|
||||
use printcraft_engine::FieldAction as A;
|
||||
let list: Vec<Value> = self
|
||||
.doc(a)?
|
||||
.field_actions(a.str("field")?)
|
||||
.iter()
|
||||
.map(|(t, act)| {
|
||||
let mut v = match act {
|
||||
A::JavaScript(j) => json!({ "javascript": j }),
|
||||
A::Uri(u) => json!({ "url": u }),
|
||||
A::Reset(f) => json!({ "reset": f }),
|
||||
A::Named(n) => json!({ "menu": n }),
|
||||
A::GoTo(p) => json!({ "page": p + 1 }),
|
||||
A::ShowHide { fields, hide: true } => json!({ "hide": fields }),
|
||||
A::ShowHide { fields, hide: false } => json!({ "show": fields }),
|
||||
A::Submit(u) => json!({ "submit": u }),
|
||||
A::Other(s) => json!({ "other": s }),
|
||||
};
|
||||
v["trigger"] = json!(t.id());
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({ "field": a.str("field")?, "actions": list }))
|
||||
}
|
||||
|
||||
pub(crate) fn form_set_actions(&mut self, a: &Args) -> Result<Value> {
|
||||
use printcraft_engine::{FieldAction as A, FieldTrigger as T};
|
||||
let items = a.get("actions").and_then(Value::as_array).ok_or_else(|| ToolError::InvalidArgs("actions must be an array".into()))?;
|
||||
let bad = |m: String| ToolError::InvalidArgs(m);
|
||||
let names = |v: &Value| -> Vec<String> {
|
||||
v.as_array().map(|x| x.iter().filter_map(|s| s.as_str().map(str::to_string)).collect()).unwrap_or_default()
|
||||
};
|
||||
let mut acts = Vec::new();
|
||||
for it in items {
|
||||
let tid = it["trigger"].as_str().unwrap_or("");
|
||||
let t = T::from_id(tid).ok_or_else(|| bad(format!("unknown trigger {tid:?}")))?;
|
||||
let act = if let Some(j) = it["javascript"].as_str() {
|
||||
A::JavaScript(j.into())
|
||||
} else if let Some(u) = it["url"].as_str() {
|
||||
A::Uri(u.into())
|
||||
} else if it.get("reset").is_some() {
|
||||
A::Reset(names(&it["reset"]))
|
||||
} else if let Some(m) = it["menu"].as_str() {
|
||||
A::Named(m.into())
|
||||
} else if let Some(p) = it["page"].as_u64() {
|
||||
A::GoTo((p.max(1) - 1) as usize)
|
||||
} else if it.get("show").is_some() {
|
||||
A::ShowHide { fields: names(&it["show"]), hide: false }
|
||||
} else if it.get("hide").is_some() {
|
||||
A::ShowHide { fields: names(&it["hide"]), hide: true }
|
||||
} else if let Some(u) = it["submit"].as_str() {
|
||||
A::Submit(u.into())
|
||||
} else {
|
||||
return Err(bad(format!("{tid}: give one action (javascript, url, reset, menu, page, show, hide or submit)")));
|
||||
};
|
||||
acts.push((t, act));
|
||||
}
|
||||
let name = a.str("field")?.to_string();
|
||||
let edit = printcraft_engine::Edit::SetFieldProps {
|
||||
name,
|
||||
props: Box::new(printcraft_engine::FieldProps { actions: Some(acts), ..Default::default() }),
|
||||
};
|
||||
let id = self.doc(a)?.id;
|
||||
self.session.apply(id, edit).map_err(failed)?;
|
||||
self.form_actions(a)
|
||||
}
|
||||
|
||||
pub(crate) fn js_enabled(&mut self, a: &Args) -> Result<Value> {
|
||||
if let Some(on) = a.opt_bool("enabled")? {
|
||||
self.session.set_javascript(on);
|
||||
|
|
|
|||
|
|
@ -386,6 +386,7 @@ impl Automation {
|
|||
out
|
||||
}
|
||||
},
|
||||
actions: None,
|
||||
};
|
||||
if props == FieldProps::default() {
|
||||
return Err(ToolError::InvalidArgs("nothing to change".into()));
|
||||
|
|
|
|||
|
|
@ -509,6 +509,8 @@ impl Automation {
|
|||
"js_enabled" => self.js_enabled(&a)?,
|
||||
"form_set_script" => self.form_set_script(&a)?,
|
||||
"form_merge_data" => self.form_merge_data(&a)?,
|
||||
"form_actions" => self.form_actions(&a)?,
|
||||
"form_set_actions" => self.form_set_actions(&a)?,
|
||||
"ocr_status" => self.ocr_status()?,
|
||||
"ocr_recognize_files" => self.ocr_recognize_files(&a)?,
|
||||
"accessibility_report" => self.a11y_report(&a)?,
|
||||
|
|
|
|||
|
|
@ -759,6 +759,20 @@ pub fn tools() -> Vec<ToolDef> {
|
|||
json!({ "doc": doc(), "field": { "type": "string" }, "event": { "type": "string", "enum": ["keystroke", "format", "validate", "calculate", "mouse_up"] }, "script": { "type": "string" } }),
|
||||
&["doc", "field", "event"],
|
||||
)),
|
||||
t("form_actions", "Field actions", "Field Properties ▸ Actions: the field's action for each trigger (mouse_up, mouse_down, mouse_enter, mouse_exit, on_focus, on_blur).")
|
||||
.ro()
|
||||
.cmd("form.prepare")
|
||||
.with(schema(json!({ "doc": doc(), "field": { "type": "string" } }), &["doc", "field"])),
|
||||
t("form_set_actions", "Set field actions", "Field Properties ▸ Actions: replace the field's actions. Each item: trigger (mouse_up, mouse_down, mouse_enter, mouse_exit, on_focus, on_blur) and one of javascript (source), url, reset (field names; [] for all), menu (Print, NextPage, PrevPage, FirstPage, LastPage), page (1-based), show / hide (field names), submit (URL). Triggers not listed lose their action. Undoable.")
|
||||
.cmd("form.prepare")
|
||||
.with(schema(
|
||||
json!({ "doc": doc(), "field": { "type": "string" }, "actions": { "type": "array", "items": { "type": "object", "properties": {
|
||||
"trigger": { "type": "string", "enum": ["mouse_up", "mouse_down", "mouse_enter", "mouse_exit", "on_focus", "on_blur"] },
|
||||
"javascript": { "type": "string" }, "url": { "type": "string" }, "reset": { "type": "array", "items": { "type": "string" } },
|
||||
"menu": { "type": "string" }, "page": { "type": "integer", "minimum": 1 }, "show": { "type": "array", "items": { "type": "string" } },
|
||||
"hide": { "type": "array", "items": { "type": "string" } }, "submit": { "type": "string" } }, "required": ["trigger"] } } }),
|
||||
&["doc", "field", "actions"],
|
||||
)),
|
||||
t("js_enabled", "JavaScript on or off", "Preferences ▸ JavaScript ▸ Enable Acrobat JavaScript: set it with `enabled`, or read it. With JavaScript off, field scripts other than Acrobat's AF calls don't run.")
|
||||
.with(schema(json!({ "enabled": { "type": "boolean" } }), &[])),
|
||||
t("ocr_recognize", "Recognize text (OCR)", "Scan & OCR ▸ Recognize text: render pages, read the words in them and add them as invisible text over the page image, so scanned pages become searchable and selectable (a searchable image; the image is not changed). Pages that already have text are skipped unless skip_text_pages is false. Returns each page's recognised text, word count or why it was skipped. Needs the OCR models (ocr_status). Undoable as one step.")
|
||||
|
|
|
|||
|
|
@ -1392,3 +1392,27 @@ fn merging_form_data_into_a_spreadsheet() {
|
|||
assert_eq!(r["rows"], 3);
|
||||
assert_eq!(std::fs::read_to_string(dir.join("report.csv")).unwrap(), "City\nParis\nOslo\nOslo\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_actions_through_tools() {
|
||||
let dir = workdir("field-actions");
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "button", "rect": [20, 20, 120, 42], "name": "Go" }));
|
||||
let r = ok(
|
||||
&mut a,
|
||||
"form_set_actions",
|
||||
json!({ "doc": doc, "field": "Go", "actions": [
|
||||
{ "trigger": "mouse_up", "javascript": "this.pageNum = 2;" },
|
||||
{ "trigger": "mouse_enter", "hide": ["Go"] },
|
||||
{ "trigger": "on_focus", "page": 3 }
|
||||
] }),
|
||||
);
|
||||
assert_eq!(r["actions"].as_array().unwrap().len(), 3, "{r}");
|
||||
assert_eq!(r["actions"][0], json!({ "trigger": "mouse_up", "javascript": "this.pageNum = 2;" }));
|
||||
assert_eq!(r["actions"][1], json!({ "trigger": "mouse_enter", "hide": ["Go"] }));
|
||||
assert_eq!(r["actions"][2], json!({ "trigger": "on_focus", "page": 3 }));
|
||||
let run = ok(&mut a, "js_run", json!({ "doc": doc, "script": "this.pageNum = 2;", "field": "Go" }));
|
||||
assert_eq!(run["requests"][0]["page"], 3);
|
||||
assert!(a.call("form_set_actions", &json!({ "doc": doc, "field": "Go", "actions": [{ "trigger": "wave" }] })).is_err());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -155,6 +155,11 @@ pub fn document_scripts(cos: &printcraft_cos::Document) -> Vec<(String, String)>
|
|||
}
|
||||
|
||||
impl crate::Document {
|
||||
/// Field Properties ▸ Actions: field `name`'s action per trigger.
|
||||
pub fn field_actions(&self, name: &str) -> Vec<(printcraft_forms::Trigger, printcraft_forms::FieldAction)> {
|
||||
self.editor.as_ref().and_then(|e| printcraft_forms::field_actions(&e.cos, name).ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Document JavaScripts (name, source), in name order.
|
||||
pub fn document_scripts(&self) -> Vec<(String, String)> {
|
||||
self.editor.as_ref().map(|e| document_scripts(&e.cos)).unwrap_or_default()
|
||||
|
|
|
|||
|
|
@ -28,8 +28,8 @@ pub use printcraft_edit::{
|
|||
Added, AddedImage, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, HeaderFooter, MarkKind, Watermark,
|
||||
};
|
||||
pub use printcraft_forms::{
|
||||
BorderStyle, Field as FormField, FieldFont, FieldKind as FormFieldKind, FieldProps, FieldValue, Look as FieldLook, NewField, TabOrder,
|
||||
Widget as FormWidget, af as form_scripts, flags as field_flags,
|
||||
BorderStyle, Field as FormField, FieldAction, FieldFont, FieldKind as FormFieldKind, FieldProps, FieldValue, Look as FieldLook, NewField,
|
||||
TabOrder, Trigger as FieldTrigger, Widget as FormWidget, af as form_scripts, flags as field_flags,
|
||||
};
|
||||
|
||||
pub use printcraft_a11y as a11y;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,255 @@
|
|||
//! Field Properties ▸ Actions: what a field does on each trigger (§12.6.3, Table 197: the
|
||||
//! widget's `/A` for Mouse Up and its `/AA` D, E, X, Fo, Bl for the others).
|
||||
|
||||
use printcraft_cos::{Dict, Document, Object, PdfString};
|
||||
|
||||
use crate::{FormError, fields};
|
||||
|
||||
/// The Actions tab's triggers.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum Trigger {
|
||||
MouseUp,
|
||||
MouseDown,
|
||||
MouseEnter,
|
||||
MouseExit,
|
||||
OnFocus,
|
||||
OnBlur,
|
||||
}
|
||||
|
||||
impl Trigger {
|
||||
pub const ALL: [Trigger; 6] = [Trigger::MouseUp, Trigger::MouseDown, Trigger::MouseEnter, Trigger::MouseExit, Trigger::OnFocus, Trigger::OnBlur];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Trigger::MouseUp => "Mouse Up",
|
||||
Trigger::MouseDown => "Mouse Down",
|
||||
Trigger::MouseEnter => "Mouse Enter",
|
||||
Trigger::MouseExit => "Mouse Exit",
|
||||
Trigger::OnFocus => "On Focus",
|
||||
Trigger::OnBlur => "On Blur",
|
||||
}
|
||||
}
|
||||
|
||||
/// Snake-case id (agents).
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
Trigger::MouseUp => "mouse_up",
|
||||
Trigger::MouseDown => "mouse_down",
|
||||
Trigger::MouseEnter => "mouse_enter",
|
||||
Trigger::MouseExit => "mouse_exit",
|
||||
Trigger::OnFocus => "on_focus",
|
||||
Trigger::OnBlur => "on_blur",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_id(id: &str) -> Option<Trigger> {
|
||||
Trigger::ALL.into_iter().find(|t| t.id() == id)
|
||||
}
|
||||
|
||||
/// The `/AA` key (`None`: Mouse Up lives in `/A`).
|
||||
fn key(self) -> Option<&'static [u8]> {
|
||||
match self {
|
||||
Trigger::MouseUp => None,
|
||||
Trigger::MouseDown => Some(b"D"),
|
||||
Trigger::MouseEnter => Some(b"E"),
|
||||
Trigger::MouseExit => Some(b"X"),
|
||||
Trigger::OnFocus => Some(b"Fo"),
|
||||
Trigger::OnBlur => Some(b"Bl"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Actions tab's "Select Action" choices.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum FieldAction {
|
||||
/// Run a JavaScript.
|
||||
JavaScript(String),
|
||||
/// Open a web link.
|
||||
Uri(String),
|
||||
/// Reset a form: the listed fields, or every field when empty.
|
||||
Reset(Vec<String>),
|
||||
/// Execute a menu item (a named action: Print, NextPage, PrevPage, FirstPage, LastPage).
|
||||
Named(String),
|
||||
/// Go to a page view (0-based page).
|
||||
GoTo(usize),
|
||||
/// Show/hide a field.
|
||||
ShowHide { fields: Vec<String>, hide: bool },
|
||||
/// Submit a form.
|
||||
Submit(String),
|
||||
/// An action of another type, kept as it is.
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl FieldAction {
|
||||
/// How the Actions tab lists it.
|
||||
pub fn describe(&self) -> String {
|
||||
match self {
|
||||
FieldAction::JavaScript(_) => "Run a JavaScript".into(),
|
||||
FieldAction::Uri(u) => format!("Open a web link: {u}"),
|
||||
FieldAction::Reset(f) if f.is_empty() => "Reset a form".into(),
|
||||
FieldAction::Reset(f) => format!("Reset a form: {}", f.join(", ")),
|
||||
FieldAction::Named(n) => format!("Execute a menu item: {n}"),
|
||||
FieldAction::GoTo(p) => format!("Go to a page view: page {}", p + 1),
|
||||
FieldAction::ShowHide { fields, hide } => format!("{} a field: {}", if *hide { "Hide" } else { "Show" }, fields.join(", ")),
|
||||
FieldAction::Submit(u) => format!("Submit a form: {u}"),
|
||||
FieldAction::Other(s) => s.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn texts(doc: &Document, o: &Object) -> Vec<String> {
|
||||
match &*doc.resolve(o) {
|
||||
Object::Array(a) => a.iter().filter_map(|x| doc.resolve(x).as_string().map(|s| s.to_text())).collect(),
|
||||
Object::String(s) => vec![s.to_text()],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn read(doc: &Document, o: &Object, pages: &[printcraft_cos::ObjRef]) -> Option<FieldAction> {
|
||||
let a = doc.resolve(o);
|
||||
let d = a.as_dict()?;
|
||||
Some(match d.name(b"S")? {
|
||||
b"JavaScript" => FieldAction::JavaScript(crate::script(doc, o).unwrap_or_default()),
|
||||
b"URI" => FieldAction::Uri(d.get(b"URI").and_then(|u| doc.resolve(u).as_string().map(|s| s.to_text())).unwrap_or_default()),
|
||||
b"ResetForm" => FieldAction::Reset(d.get(b"Fields").map(|f| texts(doc, f)).unwrap_or_default()),
|
||||
b"Named" => FieldAction::Named(d.name(b"N").map(|n| String::from_utf8_lossy(n).into_owned()).unwrap_or_default()),
|
||||
b"GoTo" => {
|
||||
let dest = d.get(b"D").map(|x| doc.resolve(x))?;
|
||||
let target = dest.as_array()?.first()?.as_ref()?;
|
||||
FieldAction::GoTo(pages.iter().position(|p| *p == target)?)
|
||||
}
|
||||
b"Hide" => FieldAction::ShowHide {
|
||||
fields: d.get(b"T").map(|t| texts(doc, t)).unwrap_or_default(),
|
||||
hide: !matches!(d.get(b"H"), Some(Object::Bool(false))),
|
||||
},
|
||||
b"SubmitForm" => FieldAction::Submit(
|
||||
d.get(b"F")
|
||||
.map(|f| doc.resolve(f))
|
||||
.and_then(|f| {
|
||||
f.as_string()
|
||||
.map(|s| s.to_text())
|
||||
.or_else(|| f.as_dict().and_then(|fd| fd.get(b"F")).and_then(|x| doc.resolve(x).as_string().map(|s| s.to_text())))
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
),
|
||||
other => FieldAction::Other(String::from_utf8_lossy(other).into_owned()),
|
||||
})
|
||||
}
|
||||
|
||||
/// The actions of field `name` (its first widget), by trigger, in the tab's order.
|
||||
pub fn field_actions(doc: &Document, name: &str) -> Result<Vec<(Trigger, FieldAction)>, FormError> {
|
||||
let all = fields(doc);
|
||||
let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?;
|
||||
let pages = crate::page_refs(doc);
|
||||
let Some(w) = f.widgets.first() else { return Ok(Vec::new()) };
|
||||
let wd = doc.get(w.obj).as_dict().cloned().unwrap_or_default();
|
||||
let aa = wd.get(b"AA").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()).unwrap_or_default();
|
||||
let mut out = Vec::new();
|
||||
for t in Trigger::ALL {
|
||||
let o = match t.key() {
|
||||
None => wd.get(b"A").cloned().or_else(|| aa.get(b"U").cloned()),
|
||||
Some(k) => aa.get(k).cloned(),
|
||||
};
|
||||
// An action chain (/Next) counts as its first action here.
|
||||
if let Some(a) = o.and_then(|o| read(doc, &o, &pages)) {
|
||||
out.push((t, a));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn write(doc: &mut Document, a: &FieldAction, pages: &[printcraft_cos::ObjRef]) -> Result<Option<Object>, FormError> {
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Type".to_vec(), Object::name("Action"));
|
||||
let list = |v: &[String]| Object::Array(v.iter().map(|s| Object::String(PdfString::text(s))).collect());
|
||||
match a {
|
||||
FieldAction::JavaScript(js) => {
|
||||
d.set(b"S".to_vec(), Object::name("JavaScript"));
|
||||
d.set(b"JS".to_vec(), PdfString::text(js));
|
||||
}
|
||||
FieldAction::Uri(u) => {
|
||||
d.set(b"S".to_vec(), Object::name("URI"));
|
||||
d.set(b"URI".to_vec(), PdfString::literal(u.as_bytes().to_vec()));
|
||||
}
|
||||
FieldAction::Reset(f) => {
|
||||
d.set(b"S".to_vec(), Object::name("ResetForm"));
|
||||
if !f.is_empty() {
|
||||
d.set(b"Fields".to_vec(), list(f));
|
||||
}
|
||||
}
|
||||
FieldAction::Named(n) => {
|
||||
d.set(b"S".to_vec(), Object::name("Named"));
|
||||
d.set(b"N".to_vec(), Object::name(n));
|
||||
}
|
||||
FieldAction::GoTo(p) => {
|
||||
let page = *pages.get(*p).ok_or_else(|| FormError::Invalid(format!("the document has no page {}", p + 1)))?;
|
||||
d.set(b"S".to_vec(), Object::name("GoTo"));
|
||||
d.set(b"D".to_vec(), Object::Array(vec![Object::Ref(page), Object::name("Fit")]));
|
||||
}
|
||||
FieldAction::ShowHide { fields, hide } => {
|
||||
d.set(b"S".to_vec(), Object::name("Hide"));
|
||||
d.set(b"T".to_vec(), list(fields));
|
||||
d.set(b"H".to_vec(), Object::Bool(*hide));
|
||||
}
|
||||
FieldAction::Submit(u) => {
|
||||
d.set(b"S".to_vec(), Object::name("SubmitForm"));
|
||||
let mut fs = Dict::new();
|
||||
fs.set(b"FS".to_vec(), Object::name("URL"));
|
||||
fs.set(b"F".to_vec(), PdfString::literal(u.as_bytes().to_vec()));
|
||||
d.set(b"F".to_vec(), Object::Dict(fs));
|
||||
// HTML form format, as Acrobat's default for web servers (ExportFormat, bit 3).
|
||||
d.set(b"Flags".to_vec(), Object::Int(4));
|
||||
}
|
||||
FieldAction::Other(_) => return Ok(None),
|
||||
}
|
||||
Ok(Some(Object::Ref(doc.add(Object::Dict(d)))))
|
||||
}
|
||||
|
||||
/// Replace field `name`'s actions (on every widget) with `actions`; triggers not listed lose
|
||||
/// theirs. `Other` actions are left as they are.
|
||||
pub fn set_field_actions(doc: &mut Document, name: &str, actions: &[(Trigger, FieldAction)]) -> Result<(), FormError> {
|
||||
let all = fields(doc);
|
||||
let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?.clone();
|
||||
let pages = crate::page_refs(doc);
|
||||
for w in &f.widgets {
|
||||
let wd = doc.get(w.obj).as_dict().cloned().unwrap_or_default();
|
||||
let mut aa = wd.get(b"AA").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()).unwrap_or_default();
|
||||
let mut a_entry = wd.get(b"A").cloned();
|
||||
for t in Trigger::ALL {
|
||||
let new = actions.iter().find(|(x, _)| *x == t).map(|(_, a)| a);
|
||||
if matches!(new, Some(FieldAction::Other(_))) {
|
||||
continue;
|
||||
}
|
||||
let obj = match new {
|
||||
Some(a) => write(doc, a, &pages)?,
|
||||
None => None,
|
||||
};
|
||||
match t.key() {
|
||||
None => {
|
||||
a_entry = obj;
|
||||
aa.remove(b"U");
|
||||
}
|
||||
Some(k) => match obj {
|
||||
Some(o) => aa.set(k.to_vec(), o),
|
||||
None => {
|
||||
aa.remove(k);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
doc.update_dict(w.obj, |d| {
|
||||
match &a_entry {
|
||||
Some(a) => d.set(b"A".to_vec(), a.clone()),
|
||||
None => {
|
||||
d.remove(b"A");
|
||||
}
|
||||
}
|
||||
if aa.is_empty() {
|
||||
d.remove(b"AA");
|
||||
} else {
|
||||
d.set(b"AA".to_vec(), Object::Dict(aa.clone()));
|
||||
}
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -87,6 +87,8 @@ pub struct FieldProps {
|
|||
pub default_value: Option<Option<String>>,
|
||||
/// Lock or unlock the field (its widgets' Locked flag).
|
||||
pub locked: Option<bool>,
|
||||
/// Actions tab: every trigger's action (replacing the field's).
|
||||
pub actions: Option<Vec<(crate::Trigger, crate::FieldAction)>>,
|
||||
}
|
||||
|
||||
fn invalid<T>(m: impl Into<String>) -> Result<T, FormError> {
|
||||
|
|
@ -633,6 +635,9 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
|
|||
return Ok(name.to_string());
|
||||
}
|
||||
}
|
||||
if let Some(acts) = &props.actions {
|
||||
crate::set_field_actions(doc, name, acts)?;
|
||||
}
|
||||
let mut new_name = name.to_string();
|
||||
if let Some(n) = props.name.as_deref().map(str::trim).filter(|n| *n != f.name) {
|
||||
if n.is_empty() || n.contains('.') {
|
||||
|
|
|
|||
|
|
@ -13,10 +13,12 @@
|
|||
|
||||
use printcraft_cos::{Dict, Document, ObjRef, Object, PdfString};
|
||||
|
||||
mod actions;
|
||||
pub mod af;
|
||||
pub mod appearance;
|
||||
mod author;
|
||||
mod scripting;
|
||||
pub use actions::{FieldAction, Trigger, field_actions, set_field_actions};
|
||||
pub use author::{
|
||||
BorderStyle, FieldFont, FieldProps, Look, NewField, add_field, delete_field, duplicate_field, look, redraw_field, set_button_icon, set_props,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -634,3 +634,28 @@ fn image_fields_ask_for_a_picture_and_show_it() {
|
|||
assert!(text.contains("/Icon Do") && text.contains("49.000000 0 0 49.000000 2.000 1.000 cm"), "{text}");
|
||||
assert!(set_button_icon(&mut doc, "nope", img, (1, 1)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_actions_round_trip_on_every_trigger() {
|
||||
let mut doc = fixture();
|
||||
let name = "go".to_string();
|
||||
let acts = vec![
|
||||
(Trigger::MouseUp, FieldAction::JavaScript("app.alert('hi')".into())),
|
||||
(Trigger::MouseEnter, FieldAction::ShowHide { fields: vec!["other".into()], hide: false }),
|
||||
(Trigger::OnFocus, FieldAction::Uri("https://example.org".into())),
|
||||
(Trigger::OnBlur, FieldAction::Reset(vec![])),
|
||||
(Trigger::MouseDown, FieldAction::GoTo(0)),
|
||||
];
|
||||
set_field_actions(&mut doc, &name, &acts).unwrap();
|
||||
let mut got = field_actions(&doc, &name).unwrap();
|
||||
let mut want = acts.clone();
|
||||
let key = |x: &(Trigger, FieldAction)| Trigger::ALL.iter().position(|t| *t == x.0);
|
||||
got.sort_by_key(key);
|
||||
want.sort_by_key(key);
|
||||
assert_eq!(got, want);
|
||||
set_field_actions(&mut doc, &name, &[(Trigger::MouseExit, FieldAction::Named("NextPage".into()))]).unwrap();
|
||||
assert_eq!(field_actions(&doc, &name).unwrap(), [(Trigger::MouseExit, FieldAction::Named("NextPage".into()))]);
|
||||
// The Mouse Up script is what the button runs.
|
||||
set_field_actions(&mut doc, &name, &[(Trigger::MouseUp, FieldAction::JavaScript("this.print();".into()))]).unwrap();
|
||||
assert_eq!(fields(&doc).iter().find(|f| f.name == "go").unwrap().button, Some(af::ButtonAction::Named("Print".into())));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -546,8 +546,10 @@ impl crate::PrintCraftApp {
|
|||
let others: Vec<String> =
|
||||
self.session.get(id).map(|doc| doc.form.iter().filter(|x| x.name != name).map(|x| x.name.clone()).collect()).unwrap_or_default();
|
||||
d.others = others.clone();
|
||||
d.actions = self.session.get(id).map(|doc| doc.field_actions(name)).unwrap_or_default();
|
||||
if let Some(o) = d.original.as_mut() {
|
||||
o.others = others;
|
||||
o.actions = d.actions.clone();
|
||||
}
|
||||
self.field_props = Some(d);
|
||||
self.dialog = Some(crate::Dialog::FieldProps);
|
||||
|
|
@ -563,6 +565,54 @@ pub enum FieldTab {
|
|||
Format,
|
||||
Validate,
|
||||
Calculate,
|
||||
Actions,
|
||||
}
|
||||
|
||||
/// Actions tab: the "Select Action" choices.
|
||||
pub const ACTION_KINDS: [&str; 8] = [
|
||||
"Run a JavaScript",
|
||||
"Open a web link",
|
||||
"Reset a form",
|
||||
"Execute a menu item",
|
||||
"Go to a page view",
|
||||
"Show a field",
|
||||
"Hide a field",
|
||||
"Submit a form",
|
||||
];
|
||||
|
||||
/// Actions tab: the action being added.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ActionDraft {
|
||||
pub trigger: printcraft_engine::FieldTrigger,
|
||||
pub kind: usize,
|
||||
/// The script, URL, field names (comma-separated), menu item or page number.
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
impl Default for ActionDraft {
|
||||
fn default() -> Self {
|
||||
ActionDraft { trigger: printcraft_engine::FieldTrigger::MouseUp, kind: 0, text: String::new() }
|
||||
}
|
||||
}
|
||||
|
||||
impl ActionDraft {
|
||||
pub fn action(&self) -> Result<printcraft_engine::FieldAction, String> {
|
||||
use printcraft_engine::FieldAction as A;
|
||||
let t = self.text.trim();
|
||||
let names = || t.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_string).collect::<Vec<_>>();
|
||||
Ok(match self.kind {
|
||||
0 => A::JavaScript(self.text.clone()),
|
||||
1 if !t.is_empty() => A::Uri(t.to_string()),
|
||||
2 => A::Reset(names()),
|
||||
3 => A::Named(if t.is_empty() { "Print".into() } else { t.to_string() }),
|
||||
4 => A::GoTo(t.parse::<usize>().ok().filter(|p| *p >= 1).ok_or("Enter a page number")? - 1),
|
||||
5 if !t.is_empty() => A::ShowHide { fields: names(), hide: false },
|
||||
6 if !t.is_empty() => A::ShowHide { fields: names(), hide: true },
|
||||
7 if !t.is_empty() => A::Submit(t.to_string()),
|
||||
1 | 7 => return Err("Enter a URL".into()),
|
||||
_ => return Err("Enter the field names".into()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The Field Properties dialog's working copy.
|
||||
|
|
@ -601,6 +651,9 @@ pub struct FieldDraft {
|
|||
pub calculate: Calculate,
|
||||
/// 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.
|
||||
pub actions: Vec<(printcraft_engine::FieldTrigger, printcraft_engine::FieldAction)>,
|
||||
pub new_action: ActionDraft,
|
||||
original: Box<Option<FieldDraft>>,
|
||||
}
|
||||
|
||||
|
|
@ -638,6 +691,8 @@ impl FieldDraft {
|
|||
validate: f.actions.validate.clone(),
|
||||
calculate: f.actions.calculate.clone(),
|
||||
others: Vec::new(),
|
||||
actions: Vec::new(),
|
||||
new_action: ActionDraft::default(),
|
||||
original: Box::new(None),
|
||||
};
|
||||
d.original = Box::new(Some(d.clone()));
|
||||
|
|
@ -664,6 +719,7 @@ impl FieldDraft {
|
|||
if matches!(self.kind, FormFieldKind::Text | FormFieldKind::Combo) {
|
||||
t.extend([(FieldTab::Format, "Format"), (FieldTab::Validate, "Validate"), (FieldTab::Calculate, "Calculate")]);
|
||||
}
|
||||
t.push((FieldTab::Actions, "Actions"));
|
||||
t
|
||||
}
|
||||
|
||||
|
|
@ -691,6 +747,7 @@ impl FieldDraft {
|
|||
flags: OPTION_FLAGS.iter().filter(|b| (self.flags ^ o.flags) & **b != 0).map(|b| (*b, self.flags & b != 0)).collect(),
|
||||
quadding: (self.quadding != o.quadding).then_some(self.quadding),
|
||||
locked: ch(self.locked, o.locked),
|
||||
actions: (self.actions != o.actions).then(|| self.actions.clone()),
|
||||
default_value: (self.default != o.default).then(|| (!self.default.is_empty()).then(|| self.default.clone())),
|
||||
};
|
||||
(p != FieldProps::default()).then_some(p)
|
||||
|
|
@ -851,6 +908,7 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Toke
|
|||
FieldTab::Format => format_tab(ui, d, t),
|
||||
FieldTab::Validate => validate_tab(ui, d),
|
||||
FieldTab::Calculate => calculate_tab(ui, d, t),
|
||||
FieldTab::Actions => actions_tab(ui, d, t),
|
||||
FieldTab::Options => match d.kind {
|
||||
FormFieldKind::Text => {
|
||||
use printcraft_engine::field_flags as ff;
|
||||
|
|
@ -1190,6 +1248,83 @@ fn validate_tab(ui: &mut egui::Ui, d: &mut FieldDraft) {
|
|||
}
|
||||
}
|
||||
|
||||
fn actions_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Tokens) {
|
||||
use printcraft_engine::FieldTrigger as T;
|
||||
ui.label(egui::RichText::new("Add an Action").font(theme::semibold(13.0)));
|
||||
let a = &mut d.new_action;
|
||||
egui::Grid::new("field-actions-add").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| {
|
||||
ui.label("Select Trigger:");
|
||||
egui::ComboBox::from_id_salt("action-trigger").selected_text(a.trigger.label()).width(220.0).show_ui(ui, |ui| {
|
||||
for tr in T::ALL {
|
||||
ui.selectable_value(&mut a.trigger, tr, tr.label());
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label("Select Action:");
|
||||
egui::ComboBox::from_id_salt("action-kind").selected_text(ACTION_KINDS[a.kind]).width(220.0).show_ui(ui, |ui| {
|
||||
for (i, k) in ACTION_KINDS.iter().enumerate() {
|
||||
ui.selectable_value(&mut a.kind, i, *k);
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
let hint = match a.kind {
|
||||
0 => "JavaScript",
|
||||
1 => "https://…",
|
||||
2 => "Fields to reset (comma-separated; empty: all)",
|
||||
3 => "Print, NextPage, PrevPage, FirstPage or LastPage",
|
||||
4 => "Page number",
|
||||
5 | 6 => "Field names (comma-separated)",
|
||||
_ => "URL to submit to",
|
||||
};
|
||||
ui.label(if a.kind == 0 { "Script:" } else { "Value:" });
|
||||
if a.kind == 0 {
|
||||
ui.add(egui::TextEdit::multiline(&mut a.text).code_editor().desired_rows(3).desired_width(340.0).hint_text(hint).id_salt("action-text"));
|
||||
} else {
|
||||
ui.add(egui::TextEdit::singleline(&mut a.text).desired_width(340.0).hint_text(hint).id_salt("action-text"));
|
||||
}
|
||||
ui.end_row();
|
||||
});
|
||||
let mut error = None;
|
||||
if ui.button("Add").clicked() {
|
||||
match d.new_action.action() {
|
||||
Ok(act) => {
|
||||
let tr = d.new_action.trigger;
|
||||
d.actions.retain(|(x, _)| *x != tr);
|
||||
d.actions.push((tr, act));
|
||||
d.actions.sort_by_key(|(x, _)| T::ALL.iter().position(|y| y == x));
|
||||
d.new_action.text.clear();
|
||||
}
|
||||
Err(e) => error = Some(e),
|
||||
}
|
||||
}
|
||||
if let Some(e) = error {
|
||||
ui.label(egui::RichText::new(e).small().color(t.text_muted));
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
ui.label(egui::RichText::new("Actions").font(theme::semibold(13.0)));
|
||||
egui::Frame::new().fill(t.hover).corner_radius(egui::CornerRadius::same(6)).inner_margin(egui::Margin::same(8)).show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
if d.actions.is_empty() {
|
||||
ui.label(egui::RichText::new("No actions").color(t.text_muted));
|
||||
}
|
||||
let mut remove = None;
|
||||
for (i, (tr, act)) in d.actions.iter().enumerate() {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(tr.label()).strong());
|
||||
ui.label(act.describe());
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.push_id(i, |ui| ui.button("Delete")).inner.clicked() {
|
||||
remove = Some(i);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
if let Some(i) = remove {
|
||||
d.actions.remove(i);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn calculate_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Tokens) {
|
||||
#[derive(PartialEq, Clone, Copy)]
|
||||
enum Mode {
|
||||
|
|
|
|||
|
|
@ -344,3 +344,47 @@ fn image_fields_take_a_picture_when_clicked() {
|
|||
let s = h.state();
|
||||
assert_eq!(s.session.get(s.views[0].id).unwrap().can_undo(), Some("Set the image of Image1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actions_tab_adds_and_removes_actions() {
|
||||
use printcraft_engine::{FieldAction, FieldTrigger};
|
||||
let mut h = harness();
|
||||
h.state_mut().execute("form.prepare");
|
||||
h.run_steps(2);
|
||||
h.state_mut().open_field_props("city", 0);
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Actions").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Select Trigger:");
|
||||
h.get_by_label("No actions");
|
||||
{
|
||||
let d = h.state_mut().field_props.as_mut().unwrap();
|
||||
d.new_action.trigger = FieldTrigger::OnBlur;
|
||||
d.new_action.kind = 1;
|
||||
d.new_action.text = "https://example.org".into();
|
||||
}
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Add").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Open a web link: https://example.org");
|
||||
{
|
||||
let d = h.state_mut().field_props.as_mut().unwrap();
|
||||
d.new_action.trigger = FieldTrigger::MouseUp;
|
||||
d.new_action.kind = 0;
|
||||
d.new_action.text = "app.alert('x')".into();
|
||||
}
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Add").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("OK").click();
|
||||
h.run_steps(3);
|
||||
let s = h.state();
|
||||
let acts = s.session.get(s.views[0].id).unwrap().field_actions("city");
|
||||
assert_eq!(
|
||||
acts,
|
||||
[
|
||||
(FieldTrigger::MouseUp, FieldAction::JavaScript("app.alert('x')".into())),
|
||||
(FieldTrigger::OnBlur, FieldAction::Uri("https://example.org".into()))
|
||||
]
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,8 +17,11 @@ endstream endobj
|
|||
trailer << /Root 1 0 R >>
|
||||
%%EOF";
|
||||
|
||||
/// A fresh folder per call: tests run in parallel and must not remove each other's files.
|
||||
fn dir() -> std::path::PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("printcraft-signing-ui-{}", std::process::id()));
|
||||
static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||||
let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let d = std::env::temp_dir().join(format!("printcraft-signing-ui-{}-{n}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
|
|
|
|||
|
|
@ -4803,11 +4803,11 @@ title = "Field properties: actions per trigger"
|
|||
area = "F"
|
||||
tier = "P1"
|
||||
milestone = "M6"
|
||||
status = "planned"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = []
|
||||
notes = ""
|
||||
status = "shipped"
|
||||
commands = ["form.prepare"]
|
||||
tools = ["form_actions", "form_set_actions"]
|
||||
evidence = ["crates/forms/src/tests.rs::field_actions_round_trip_on_every_trigger", "crates/automation/tests/automation.rs::field_actions_through_tools", "crates/ui-egui/tests/prepare.rs::actions_tab_adds_and_removes_actions"]
|
||||
notes = "Six triggers; Run a JavaScript, Open a web link, Reset a form, Execute a menu item, Go to a page view, Show/hide a field, Submit a form. Other action types are kept."
|
||||
|
||||
[[feature]]
|
||||
id = "form.props-format"
|
||||
|
|
|
|||
Loading…
Reference in New Issue