mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 13:01:29 +00:00
Merge #4359: M12: XFA enter and exit scripts run whenever a field gains or loses focus
# Conflicts: # crates/engine/src/edit/run.rs # crates/engine/src/js.rs # crates/ui-egui/src/editing.rs # crates/ui-egui/src/tools/forms_ui.rs # parity/acrobat-features.toml
This commit is contained in:
commit
db5fdcf9bc
21 files changed
+806
-24
No files matched your search
@@ -48,6 +48,8 @@ impl Automation {
|
||||
Ok(json!({ "scripts": list }))
|
||||
}
|
||||
|
||||
/// `js_set_document_script`: set the document-level script `name` to `script`, or remove it
|
||||
/// when `script` is left out, as one undoable edit; returns the document's scripts.
|
||||
pub(crate) fn js_set_document_script(&mut self, a: &Args) -> Result<Value> {
|
||||
let edit = pdfcraft_engine::Edit::SetDocumentScript { name: a.str("name")?.into(), script: a.opt_str("script")?.map(str::to_string) };
|
||||
let id = self.doc(a)?.id;
|
||||
@@ -55,6 +57,8 @@ impl Automation {
|
||||
Ok(with_warnings(self.js_document_scripts(a)?, warnings))
|
||||
}
|
||||
|
||||
/// `form_set_script`: set field `field`'s JavaScript for `event` to `script`, or remove it when
|
||||
/// `script` is left out, as one undoable edit; returns the console output and errors.
|
||||
pub(crate) fn form_set_script(&mut self, a: &Args) -> Result<Value> {
|
||||
let edit = pdfcraft_engine::Edit::SetFieldScript {
|
||||
name: a.str("field")?.into(),
|
||||
@@ -66,6 +70,37 @@ impl Automation {
|
||||
Ok(json!({ "field": a.str("field")?, "event": a.str("event")?, "console": out.console, "errors": out.errors }))
|
||||
}
|
||||
|
||||
/// `form_focus`: field `field` (its 1-based `widget`) gains (`enter`) or loses (`exit`)
|
||||
/// focus, running its XFA scripts for that event; returns whether the form changed and what
|
||||
/// the scripts showed or asked for.
|
||||
pub(crate) fn form_focus(&mut self, a: &Args) -> Result<Value> {
|
||||
use pdfcraft_engine::js::FieldFocus;
|
||||
let focus = match a.str("event")? {
|
||||
"enter" => FieldFocus::Enter,
|
||||
"exit" => FieldFocus::Exit,
|
||||
other => return Err(ToolError::InvalidArgs(format!("event must be enter or exit, not {other:?}"))),
|
||||
};
|
||||
let name = a.str("field")?;
|
||||
let doc = self.doc(a)?;
|
||||
let field = doc.form.iter().find(|f| f.name == name).ok_or_else(|| failed(format!("there is no field named {name:?} (see form_fields)")))?;
|
||||
// A field with no widget (one only scripts or calculations use) has nothing to focus.
|
||||
let widgets = field.widgets.len();
|
||||
let widget = a.opt_int("widget")?.unwrap_or(1);
|
||||
let widget = usize::try_from(widget).ok().and_then(|w| w.checked_sub(1)).filter(|w| *w < widgets).ok_or_else(|| match widgets {
|
||||
0 => ToolError::InvalidArgs(format!("{name:?} has no widget to focus")),
|
||||
n => ToolError::InvalidArgs(format!("widget must be 1 to {n} for {name:?}")),
|
||||
})?;
|
||||
let id = doc.id;
|
||||
let changed = self.session.field_focus(id, name, widget, focus).map_err(failed)?;
|
||||
let out = self.session.take_js_output(id);
|
||||
let reqs: Vec<Value> = out.requests.iter().map(request_json).collect();
|
||||
Ok(
|
||||
json!({ "changed": changed, "alerts": out.alerts.iter().map(alert_json).collect::<Vec<_>>(), "console": out.console, "requests": reqs, "errors": out.errors }),
|
||||
)
|
||||
}
|
||||
|
||||
/// `form_merge_data`: merge the form data files at `paths` into one CSV at `path`, a row per
|
||||
/// file; returns the path and the row and column counts.
|
||||
pub(crate) fn form_merge_data(&mut self, a: &Args) -> Result<Value> {
|
||||
let mut files = Vec::new();
|
||||
for p in a.strs("paths")? {
|
||||
|
||||
@@ -709,6 +709,7 @@ impl Automation {
|
||||
}
|
||||
"form_fields" => self.form_fields(&a)?,
|
||||
"form_fill" => self.form_fill(&a)?,
|
||||
"form_focus" => self.form_focus(&a)?,
|
||||
"form_set_image" => {
|
||||
let path = self.resolve(a.str("path")?, false)?;
|
||||
let bytes = read_input(&path)?;
|
||||
|
||||
@@ -517,6 +517,20 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"Set several fields at once (one undo step). values maps field names (from form_fields) to: a string for text fields, combo boxes and radio groups (an option), true/false for check boxes, an array of strings for multi-select lists; {\"widget\": n} turns on button n (1-based) of a check box or radio group and never turns it off (to clear, use null for a radio group that allows it, false for a check box); of radio buttons sharing an option only that one is on unless the group has RadiosInUnison. Appearances are regenerated so every viewer shows the values. Thai the field's font can't hold is drawn from PdfCraft's built-in Thai face (a build without one refuses it). Any other value whose characters the field's font can't draw (Chinese in a Helvetica field) is still saved; `warnings` in the result names the field and character: PdfCraft draws `?` there and asks other viewers to redraw the field from its value.",
|
||||
)
|
||||
.with(schema(json!({ "doc": doc(), "values": { "type": "object", "description": "Field name → value." } }), &["doc", "values"])),
|
||||
t(
|
||||
"form_focus",
|
||||
"Move the focus into or out of a field",
|
||||
"A field gains (event enter) or loses (event exit) focus, as when someone clicks or tabs into or out of it, and the dynamic XFA form's enter or exit scripts for it run (other forms have none, and nothing happens). widget picks the radio button or copy of the field (1-based, default 1; see form_fields). What the scripts change is one undoable edit, and none when they change nothing. Changing a field's value (form_fill) runs its exit scripts already, so the next exit of that field doesn't run them again. Returns changed, and the scripts' alerts, console output, requests and errors.",
|
||||
)
|
||||
.with(schema(
|
||||
json!({
|
||||
"doc": doc(),
|
||||
"field": { "type": "string", "description": "Field name (from form_fields)." },
|
||||
"event": { "type": "string", "enum": ["enter", "exit"] },
|
||||
"widget": { "type": "integer", "minimum": 1, "description": "Widget of the field (1-based, default 1): a radio button of the group, or a copy of the field." },
|
||||
}),
|
||||
&["doc", "field", "event"],
|
||||
)),
|
||||
t("form_reset", "Clear form", "Reset fields to their default values: all of them, or only those listed. Undoable.")
|
||||
.destructive()
|
||||
.with(schema(json!({ "doc": doc(), "fields": { "type": "array", "items": { "type": "string" } } }), &["doc"])),
|
||||
|
||||
@@ -9804,6 +9804,8 @@ fn growable_xfa_text_paginates_after_fill_save_and_print() {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// A print button repeated on every page of an XFA form runs its own XFA click script (a print
|
||||
/// request) through js_run, from each copy, before and after saving and reopening.
|
||||
#[test]
|
||||
fn repeated_xfa_print_buttons_work_through_tools_and_after_reopen() {
|
||||
let dir = workdir("xfa-print-buttons");
|
||||
@@ -9831,6 +9833,93 @@ fn repeated_xfa_print_buttons_work_through_tools_and_after_reopen() {
|
||||
check(&mut a, doc);
|
||||
}
|
||||
|
||||
/// form_focus runs a field's XFA enter and exit scripts on every focus change; scripts that
|
||||
/// change nothing leave no edit, a radio button's own scripts run for its widget, and unknown
|
||||
/// fields, widgets and events are refused.
|
||||
#[test]
|
||||
fn xfa_focus_scripts_run_through_form_focus() {
|
||||
let dir = workdir("xfa-focus");
|
||||
std::fs::write(dir.join("focus.pdf"), pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::focus_template())).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "focus.pdf" }))["doc"].as_u64().unwrap();
|
||||
let value = |a: &mut Automation, n: &str| {
|
||||
let f = ok(a, "form_fields", json!({ "doc": doc }));
|
||||
f["fields"].as_array().unwrap().iter().find(|f| f["name"] == n).map(|f| f["value"].clone()).unwrap()
|
||||
};
|
||||
for _ in 0..3 {
|
||||
assert_eq!(ok(&mut a, "form_focus", json!({ "doc": doc, "field": "probe", "event": "enter" }))["changed"], true);
|
||||
assert_eq!(ok(&mut a, "form_focus", json!({ "doc": doc, "field": "probe", "event": "exit" }))["changed"], true);
|
||||
}
|
||||
assert_eq!((value(&mut a, "enters"), value(&mut a, "exits")), (json!("3"), json!("3")));
|
||||
ok(&mut a, "edit_undo", json!({ "doc": doc }));
|
||||
assert_eq!(value(&mut a, "exits"), "2", "each run is one undo step");
|
||||
// A script that only shows a message changes nothing: no edit (the document stays saved and
|
||||
// what was undone can still be redone), and the message comes back.
|
||||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "saved.pdf" }));
|
||||
let quiet = ok(&mut a, "form_focus", json!({ "doc": doc, "field": "quiet", "event": "exit" }));
|
||||
assert_eq!(
|
||||
(quiet["changed"].clone(), quiet["alerts"].clone()),
|
||||
(json!(false), json!([{ "message": "left quiet", "title": null, "icon": "error", "buttons": "ok" }])),
|
||||
"{quiet}"
|
||||
);
|
||||
let state = &ok(&mut a, "doc_info", json!({ "doc": doc }))["document"];
|
||||
assert_eq!((state["dirty"].clone(), state["redo"].clone()), (json!(false), json!("Run form script")), "{state}");
|
||||
// The second radio button has an enter script of its own.
|
||||
let fields = ok(&mut a, "form_fields", json!({ "doc": doc }));
|
||||
let group = fields["fields"].as_array().unwrap().iter().find(|f| f["type"] == "radio").unwrap()["name"].clone();
|
||||
assert_eq!(ok(&mut a, "form_focus", json!({ "doc": doc, "field": group, "event": "enter" }))["changed"], false);
|
||||
assert_eq!(ok(&mut a, "form_focus", json!({ "doc": doc, "field": group, "event": "enter", "widget": 2 }))["changed"], true);
|
||||
assert_eq!(value(&mut a, "picks"), "1");
|
||||
for bad in [
|
||||
json!({ "doc": doc, "field": "nowhere", "event": "enter" }),
|
||||
json!({ "doc": doc, "field": group, "event": "enter", "widget": 3 }),
|
||||
json!({ "doc": doc, "field": "probe", "event": "enter", "widget": 0 }),
|
||||
json!({ "doc": doc, "field": "probe", "event": "click" }),
|
||||
] {
|
||||
assert!(a.call("form_focus", &bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
/// form_focus refuses a field with no widget, which nothing can focus, instead of reporting a
|
||||
/// focus change that ran nothing.
|
||||
#[test]
|
||||
fn form_focus_refuses_a_field_with_no_widget() {
|
||||
let dir = workdir("focus-no-widget");
|
||||
let objs = [
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R] >> >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>",
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /Rect [60 60 200 80] /P 3 0 R >>",
|
||||
"<< /FT /Tx /T (total) /V (3) >>",
|
||||
];
|
||||
let mut bytes = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(bytes.len());
|
||||
bytes.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = bytes.len();
|
||||
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
std::fs::write(dir.join("total.pdf"), bytes).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "total.pdf" }))["doc"].as_u64().unwrap();
|
||||
let fields = ok(&mut a, "form_fields", json!({ "doc": doc }));
|
||||
assert!(fields["fields"].as_array().unwrap().iter().any(|f| f["name"] == "total"), "the field without a widget is listed: {fields}");
|
||||
for args in [json!({ "doc": doc, "field": "total", "event": "enter" }), json!({ "doc": doc, "field": "total", "event": "enter", "widget": 1 })] {
|
||||
let e = err(&mut a, "form_focus", args.clone()).to_string();
|
||||
assert!(e.contains("\"total\" has no widget to focus"), "{args}: {e}");
|
||||
}
|
||||
assert_eq!(
|
||||
ok(&mut a, "form_focus", json!({ "doc": doc, "field": "name", "event": "enter" }))["changed"],
|
||||
false,
|
||||
"a field with a widget still takes it"
|
||||
);
|
||||
}
|
||||
|
||||
/// A dynamic XFA form's scripts run on open, on fills and for buttons through the tools, with undo,
|
||||
/// and `js_run` reports their messages.
|
||||
#[test]
|
||||
|
||||
@@ -59,6 +59,10 @@ pub struct Document {
|
||||
pub(crate) xfa_template: Option<Arc<pdfcraft_xfa::model::Template>>,
|
||||
/// A script of the XFA form ran away, so its scripts are off (see [`Document::xfa_scripts_off`]).
|
||||
pub(crate) xfa_scripts_off: bool,
|
||||
/// SOM paths of the XFA fields whose exit scripts ran with a value change ([`xfa::on_changes`])
|
||||
/// since they last gained focus: their exit on losing focus has run already
|
||||
/// ([`Session::field_focus`]).
|
||||
pub(crate) xfa_exited: std::collections::HashSet<String>,
|
||||
/// A content-only refresh keeps `info` from before the edit; an edit may have added or
|
||||
/// embedded fonts, so the font list is read again on demand ([`Session::refresh_fonts`]).
|
||||
pub(crate) fonts_stale: bool,
|
||||
|
||||
@@ -220,6 +220,8 @@ pub(crate) struct EditCtx {
|
||||
pub(crate) xfa_datasets: Option<pdfcraft_cos::ObjRef>,
|
||||
/// A form script ran too long and was abandoned: the document's scripts go off.
|
||||
pub(crate) xfa_ran_away: bool,
|
||||
/// An XFA enter or exit event whose scripts changed nothing: no edit to keep.
|
||||
pub(crate) xfa_unchanged: bool,
|
||||
/// What the XFA scripts' time limits are multiplied by ([`Session::with_xfa_time_scale`]).
|
||||
pub(crate) xfa_time_scale: u32,
|
||||
/// The edit took the XFA out of the form: what went with it, for the document's warnings.
|
||||
@@ -280,6 +282,7 @@ impl EditCtx {
|
||||
xfa_out: Default::default(),
|
||||
xfa_datasets: None,
|
||||
xfa_ran_away: false,
|
||||
xfa_unchanged: false,
|
||||
xfa_time_scale: 1,
|
||||
xfa_removed: Vec::new(),
|
||||
pages: 0,
|
||||
@@ -699,13 +702,17 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
|
||||
highlight,
|
||||
time_scale: cx.xfa_time_scale,
|
||||
};
|
||||
let ran = xfa::on_event(doc, &tpl, som, activity, &mut run);
|
||||
let ran = if matches!(activity.as_str(), "enter" | "exit") {
|
||||
xfa::on_focus(doc, &tpl, som, activity, &mut run).map(|changes| changes.is_empty())
|
||||
} else {
|
||||
xfa::on_event(doc, &tpl, som, activity, &mut run).map(|()| false)
|
||||
};
|
||||
cx.xfa_datasets = run.datasets;
|
||||
cx.xfa_ran_away |= run.ran_away;
|
||||
// The next edit of a batch reads the highlight this one's scripts set.
|
||||
let on = run.highlight;
|
||||
cx.set_highlight(on);
|
||||
ran.map_err(EditError::Invalid)?;
|
||||
cx.xfa_unchanged = ran.map_err(EditError::Invalid)?;
|
||||
}
|
||||
Edit::SetFieldImage { name, image } => {
|
||||
let (img, _) = embed_image(doc, name, image)?;
|
||||
@@ -1024,6 +1031,11 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
|
||||
doc.remove_encryption();
|
||||
}
|
||||
Edit::Batch { edits, .. } => {
|
||||
// A form-script event runs on its own: in a batch, its scripts and those the batch's
|
||||
// value edits run afterwards (change, exit) would interleave, an exit running twice.
|
||||
if edits.iter().any(|e| matches!(e, Edit::XfaEvent { .. })) {
|
||||
return Err(EditError::Invalid("a form script event can't be part of a batch of edits".into()));
|
||||
}
|
||||
for e in edits {
|
||||
run_edit(doc, e, cx)?;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,15 @@ pub use pdfcraft_js::{Limits, Message, MessageButtons, MessageIcon, Outcome, Pan
|
||||
|
||||
use crate::{DocId, Edit, EditError, Session};
|
||||
|
||||
/// A field gaining or losing focus ([`Session::field_focus`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum FieldFocus {
|
||||
/// The field gained focus: its `enter` scripts run.
|
||||
Enter,
|
||||
/// The field lost focus: its `exit` scripts run.
|
||||
Exit,
|
||||
}
|
||||
|
||||
/// What scripts produced for the viewer since it last looked.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct JsOutput {
|
||||
@@ -412,6 +421,36 @@ impl Session {
|
||||
last
|
||||
}
|
||||
|
||||
/// Widget `widget` of field `name` in document `id` gained or lost focus: on a laid-out XFA form,
|
||||
/// run the field's `enter` or `exit` scripts, as one undoable edit when they change something
|
||||
/// (and none when they don't). An exit that the field's new value already ran (the value's
|
||||
/// change runs it) isn't run a second time. Returns whether the document changed; what the
|
||||
/// scripts showed or asked for is in [`Session::take_js_output`].
|
||||
pub fn field_focus(&mut self, id: DocId, name: &str, widget: usize, focus: FieldFocus) -> Result<bool, EditError> {
|
||||
if self.js_off {
|
||||
return Ok(false);
|
||||
}
|
||||
let doc = self.doc_mut(id)?;
|
||||
let Some(tpl) = doc.xfa_template.clone().filter(|t| pdfcraft_xfa::has_focus_scripts(t)) else { return Ok(false) };
|
||||
let Some(cos) = doc.editor.as_ref().map(|e| &e.cos) else { return Ok(false) };
|
||||
let Some(som) = doc.form.iter().find(|f| f.name == name).and_then(|f| crate::xfa::widget_som(cos, f, widget)) else { return Ok(false) };
|
||||
let activity = match focus {
|
||||
FieldFocus::Enter => "enter",
|
||||
FieldFocus::Exit => "exit",
|
||||
};
|
||||
if !crate::xfa::has_event(cos, &tpl, &som, activity) {
|
||||
doc.xfa_exited.remove(&som);
|
||||
return Ok(false);
|
||||
}
|
||||
// Gaining focus starts afresh; losing it runs the exit unless the change just did.
|
||||
if doc.xfa_exited.remove(&som) && focus == FieldFocus::Exit {
|
||||
return Ok(false);
|
||||
}
|
||||
let before = doc.generation;
|
||||
self.apply(id, Edit::XfaEvent { som, activity: activity.into() })?;
|
||||
Ok(self.get(id).is_some_and(|d| d.generation != before))
|
||||
}
|
||||
|
||||
/// Run `script` in document `id`, for a button (`target`: the field, event "Mouse Up") or
|
||||
/// the console (no target). Field changes and `resetForm` are applied as one undoable step;
|
||||
/// everything else the script asked for is returned.
|
||||
|
||||
@@ -384,6 +384,7 @@ impl Session {
|
||||
xfa,
|
||||
xfa_template: None,
|
||||
xfa_scripts_off: false,
|
||||
xfa_exited: Default::default(),
|
||||
fonts_stale: false,
|
||||
xfa_warnings: Vec::new(),
|
||||
edit_notices: Vec::new(),
|
||||
|
||||
@@ -94,6 +94,13 @@ impl Session {
|
||||
// `next` is a copy: if the edit fails or crashes, the document is unchanged.
|
||||
guard(|| run_edit(&mut next, &edit, &mut cx))
|
||||
.unwrap_or_else(|m| Err(EditError::Invalid(format!("{} failed unexpectedly ({m}); the document was not changed", edit.label()))))?;
|
||||
// A field's enter or exit scripts that changed nothing leave no edit behind (no undo step,
|
||||
// nothing unsaved); what they showed or asked for still goes out. Only an event on its own:
|
||||
// a batch keeps the other edits it holds.
|
||||
if cx.xfa_unchanged && matches!(edit, Edit::XfaEvent { .. }) {
|
||||
take_xfa_output(doc, cx.xfa_out, cx.xfa_ran_away);
|
||||
return Ok(None);
|
||||
}
|
||||
// Redaction removed what its own interpreter places under the marks. The marks were drawn
|
||||
// over the page as the renderer shows it, so the renderer has the last word.
|
||||
if let Edit::ApplyRedactions { pages } = &edit {
|
||||
@@ -132,7 +139,8 @@ impl Session {
|
||||
};
|
||||
// An XFA form's scripts answer the change: exit and validate scripts of the field, then
|
||||
// every calculation (and a reset recalculates). They read the data, so it is brought up
|
||||
// to date first.
|
||||
// to date first. The fields whose exit scripts ran are noted once the edit is kept.
|
||||
let mut exited: Vec<String> = Vec::new();
|
||||
if let Some(tpl) = cx.xfa.clone() {
|
||||
if is_xfa && matches!(scope_of(&edit), Scope::Form | Scope::Full | Scope::Content) {
|
||||
let datasets = &mut cx.xfa_datasets;
|
||||
@@ -141,6 +149,8 @@ impl Session {
|
||||
.map_err(EditError::Write)?;
|
||||
cx.xfa_out.errors.extend(notes);
|
||||
}
|
||||
/// The fields edit `e` (and the edits it batches) gives new values, and whether it
|
||||
/// resets the form.
|
||||
fn collect(e: &Edit, changed: &mut Vec<String>, reset: &mut bool) {
|
||||
match e {
|
||||
Edit::SetFieldValue { name, .. } => changed.push(name.clone()),
|
||||
@@ -164,7 +174,7 @@ impl Session {
|
||||
};
|
||||
// Every changed field's scripts, then the calculations once (after a reset,
|
||||
// only those).
|
||||
let ran = guard(|| xfa::on_changes(&mut next, &tpl, &changed, &mut run))
|
||||
let ran = guard(|| xfa::on_changes(&mut next, &tpl, &changed, &mut run, &mut exited))
|
||||
.unwrap_or_else(|m| Err(format!("the form's scripts failed unexpectedly ({m})")));
|
||||
cx.xfa_datasets = run.datasets;
|
||||
cx.xfa_ran_away |= run.ran_away;
|
||||
@@ -237,6 +247,10 @@ impl Session {
|
||||
doc.dirty = true;
|
||||
doc.generation += 1;
|
||||
doc.edit_notices = notices;
|
||||
// Only this edit's exits are skipped on the next focus loss: one an earlier change ran is
|
||||
// no reason to skip a later one.
|
||||
doc.xfa_exited.clear();
|
||||
doc.xfa_exited.extend(exited);
|
||||
if !cx.xfa_removed.is_empty() {
|
||||
let removed: Vec<String> = cx.xfa_removed.iter().map(|n| format!("{XFA_REMOVED_PREFIX}{n}")).collect();
|
||||
doc.xfa_warnings.retain(|w| w.starts_with(XFA_REMOVED_PREFIX));
|
||||
@@ -249,15 +263,11 @@ impl Session {
|
||||
if let Some(js) = cx.js {
|
||||
doc.js_output.append(js.output);
|
||||
}
|
||||
doc.js_output.append(cx.xfa_out);
|
||||
if cx.xfa_ran_away {
|
||||
doc.xfa_template = None;
|
||||
doc.xfa_scripts_off = true;
|
||||
doc.js_output.errors.push(XFA_SCRIPTS_OFF.into());
|
||||
}
|
||||
take_xfa_output(doc, cx.xfa_out, cx.xfa_ran_away);
|
||||
Ok(cx.prepared)
|
||||
}
|
||||
|
||||
/// Undo the last edit of document `id`, keeping it for [`Session::redo`]. Returns its label.
|
||||
pub fn undo(&mut self, id: DocId) -> Result<Label, EditError> {
|
||||
let doc = self.doc_mut(id)?;
|
||||
let editor = doc.editor.as_mut().ok_or(EditError::NothingToUndo)?;
|
||||
@@ -283,10 +293,13 @@ impl Session {
|
||||
}
|
||||
doc.edit_notices.clear();
|
||||
Self::sync_dirty(doc);
|
||||
// The change whose exit ran is undone (or redone without its scripts): skip no exit.
|
||||
doc.xfa_exited.clear();
|
||||
doc.generation += 1;
|
||||
Ok(label)
|
||||
}
|
||||
|
||||
/// Redo the last edit of document `id` that [`Session::undo`] took back. Returns its label.
|
||||
pub fn redo(&mut self, id: DocId) -> Result<Label, EditError> {
|
||||
let doc = self.doc_mut(id)?;
|
||||
let editor = doc.editor.as_mut().ok_or(EditError::NothingToRedo)?;
|
||||
@@ -312,6 +325,8 @@ impl Session {
|
||||
}
|
||||
doc.edit_notices.clear();
|
||||
Self::sync_dirty(doc);
|
||||
// The change whose exit ran is undone (or redone without its scripts): skip no exit.
|
||||
doc.xfa_exited.clear();
|
||||
doc.generation += 1;
|
||||
Ok(label)
|
||||
}
|
||||
@@ -581,6 +596,17 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// Hand on what an edit's XFA scripts showed or asked for (`out`), and when one of them ran away,
|
||||
/// turn the form's scripts off and say so.
|
||||
fn take_xfa_output(doc: &mut Document, out: js::JsOutput, ran_away: bool) {
|
||||
doc.js_output.append(out);
|
||||
if ran_away {
|
||||
doc.xfa_template = None;
|
||||
doc.xfa_scripts_off = true;
|
||||
doc.js_output.errors.push(XFA_SCRIPTS_OFF.into());
|
||||
}
|
||||
}
|
||||
|
||||
/// What `edit`, taking `prev` to `next`, wants said: never silent (AGENTS.md §4).
|
||||
fn edit_notices(edit: &Edit, prev: &pdfcraft_cos::Document, next: &pdfcraft_cos::Document) -> Vec<EditNotice> {
|
||||
// What it drew with `?` (a value or comment text its font can't draw, stored as typed).
|
||||
|
||||
@@ -145,6 +145,8 @@ impl Session {
|
||||
editor.redo.clear();
|
||||
Self::adopt_keys(doc);
|
||||
Self::sync_dirty(doc);
|
||||
// The changes whose exits ran are gone with the rest: skip no exit.
|
||||
doc.xfa_exited.clear();
|
||||
doc.generation += 1;
|
||||
Self::refresh(doc).inspect_err(|_| Self::show_released(doc))
|
||||
}
|
||||
|
||||
@@ -7098,6 +7098,8 @@ fn guard_turns_a_panic_into_an_error() {
|
||||
assert_eq!(guard(|| -> u8 { panic!("page {n} is bad") }), Err("page 3 is bad".to_string()));
|
||||
}
|
||||
|
||||
/// Measurement edits are single undoable steps that leave the file unchanged when they fail, and
|
||||
/// respect the document's annotation permissions.
|
||||
#[test]
|
||||
fn measurement_edits_are_transactional_and_honour_annotation_permissions() {
|
||||
let mut session = Session::default();
|
||||
@@ -7133,6 +7135,172 @@ fn measurement_edits_are_transactional_and_honour_annotation_permissions() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The synthetic focus form ([`pdfcraft_xfa::fixtures::focus_template`]).
|
||||
fn focus_form() -> Arc<Vec<u8>> {
|
||||
Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::focus_template()))
|
||||
}
|
||||
|
||||
/// The value of field `name` in document `id`.
|
||||
fn field_text(s: &Session, id: DocId, name: &str) -> String {
|
||||
s.get(id).and_then(|d| d.form.iter().find(|f| f.name == name).and_then(|f| f.value.first().cloned())).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// A field's exit scripts run every time it loses focus, changed or not, and its enter scripts
|
||||
/// every time it gains it; each run that changes the form is one undo step.
|
||||
#[test]
|
||||
fn xfa_enter_and_exit_scripts_run_on_every_focus_change() {
|
||||
use crate::js::FieldFocus::{Enter, Exit};
|
||||
let mut s = Session::new();
|
||||
let id = s.open("form.pdf", None, focus_form(), None).expect("opens");
|
||||
for _ in 0..3 {
|
||||
assert!(s.field_focus(id, "probe", 0, Enter).unwrap(), "the enter script counted");
|
||||
assert!(s.field_focus(id, "probe", 0, Exit).unwrap(), "the exit script counted");
|
||||
}
|
||||
assert_eq!((field_text(&s, id, "exits"), field_text(&s, id, "enters")), ("3".into(), "3".into()));
|
||||
assert!(s.get(id).unwrap().dirty);
|
||||
s.undo(id).unwrap();
|
||||
assert_eq!(field_text(&s, id, "exits"), "2", "each run is its own undo step");
|
||||
}
|
||||
|
||||
/// A value typed into a field runs its exit with the change; losing focus then doesn't run it
|
||||
/// again. The next visit runs it as usual.
|
||||
#[test]
|
||||
fn an_exit_the_fields_change_ran_does_not_run_twice() {
|
||||
use crate::js::FieldFocus::{Enter, Exit};
|
||||
let mut s = Session::new();
|
||||
let id = s.open("form.pdf", None, focus_form(), None).expect("opens");
|
||||
s.field_focus(id, "probe", 0, Enter).unwrap();
|
||||
s.apply(id, Edit::SetFieldValue { name: "probe".into(), value: FieldValue::Text("x".into()) }).unwrap();
|
||||
assert_eq!(field_text(&s, id, "exits"), "1", "the change ran the exit");
|
||||
assert!(!s.field_focus(id, "probe", 0, Exit).unwrap(), "not again on losing focus");
|
||||
assert_eq!(field_text(&s, id, "exits"), "1");
|
||||
s.field_focus(id, "probe", 0, Enter).unwrap();
|
||||
s.field_focus(id, "probe", 0, Exit).unwrap();
|
||||
assert_eq!(field_text(&s, id, "exits"), "2", "the next visit's exit runs");
|
||||
}
|
||||
|
||||
/// The skip lasts only until the next edit, undo or redo: a change that is undone, or followed by
|
||||
/// another edit, doesn't make a later focus loss skip the field's exit.
|
||||
#[test]
|
||||
fn an_exit_skip_does_not_outlive_its_change() {
|
||||
use crate::js::FieldFocus::{Enter, Exit};
|
||||
let mut s = Session::new();
|
||||
let id = s.open("form.pdf", None, focus_form(), None).expect("opens");
|
||||
s.field_focus(id, "probe", 0, Enter).unwrap();
|
||||
s.apply(id, Edit::SetFieldValue { name: "probe".into(), value: FieldValue::Text("x".into()) }).unwrap();
|
||||
s.undo(id).unwrap();
|
||||
let before = field_text(&s, id, "exits");
|
||||
s.field_focus(id, "probe", 0, Exit).unwrap();
|
||||
assert_eq!(field_text(&s, id, "exits"), (before.parse::<u32>().unwrap() + 1).to_string(), "after an undo, the exit runs");
|
||||
// A change, then an edit of another field: the change's skip is gone.
|
||||
s.field_focus(id, "probe", 0, Enter).unwrap();
|
||||
s.apply(id, Edit::SetFieldValue { name: "probe".into(), value: FieldValue::Text("y".into()) }).unwrap();
|
||||
s.apply(id, Edit::SetFieldValue { name: "quiet".into(), value: FieldValue::Text("z".into()) }).unwrap();
|
||||
let before = field_text(&s, id, "exits");
|
||||
s.field_focus(id, "probe", 0, Exit).unwrap();
|
||||
assert_eq!(field_text(&s, id, "exits"), (before.parse::<u32>().unwrap() + 1).to_string(), "after another edit, the exit runs");
|
||||
}
|
||||
|
||||
/// A form-script event can't be batched with other edits: its scripts and those the batch's value
|
||||
/// edits run would interleave (the field's exit running twice). The batch is refused whole, and
|
||||
/// the document is left as it was.
|
||||
#[test]
|
||||
fn a_form_script_event_in_a_batch_is_refused() {
|
||||
let mut s = Session::new();
|
||||
let id = s.open("form.pdf", None, focus_form(), None).expect("opens");
|
||||
let som_of = |s: &Session, name: &str| {
|
||||
let doc = s.get(id).unwrap();
|
||||
let f = doc.form.iter().find(|f| f.name == name).unwrap();
|
||||
crate::xfa::widget_som(&doc.editor.as_ref().unwrap().cos, f, 0).unwrap()
|
||||
};
|
||||
let batch = |name: &str, som: String| Edit::Batch {
|
||||
label: "Fill and leave".into(),
|
||||
edits: vec![Edit::SetFieldValue { name: name.into(), value: FieldValue::Text("z".into()) }, Edit::XfaEvent { som, activity: "exit".into() }],
|
||||
};
|
||||
// An exit that counts: it would run once for the event and once for the change.
|
||||
let probe = som_of(&s, "probe");
|
||||
assert!(matches!(s.apply(id, batch("probe", probe)), Err(EditError::Invalid(_))));
|
||||
assert_eq!((field_text(&s, id, "probe"), field_text(&s, id, "exits")), (String::new(), "0".into()));
|
||||
// An exit that changes nothing: the batch's value isn't kept either, nor dropped silently.
|
||||
let quiet = som_of(&s, "quiet");
|
||||
assert!(s.apply(id, batch("quiet", quiet)).is_err());
|
||||
assert_eq!(field_text(&s, id, "quiet"), "");
|
||||
assert!(!s.get(id).unwrap().dirty, "nothing was applied");
|
||||
}
|
||||
|
||||
/// A field's exit is noted as run (for the focus loss to skip) only when an exit script ran:
|
||||
/// with every script skipped, as when the event's budget is spent or a script ran away, nothing
|
||||
/// is noted and the next focus loss runs the exit.
|
||||
#[test]
|
||||
fn on_changes_notes_only_the_exits_that_ran() {
|
||||
let mut s = Session::new();
|
||||
let id = s.open("form.pdf", None, focus_form(), None).expect("opens");
|
||||
let doc = s.get(id).unwrap();
|
||||
let mut cos = doc.editor.as_ref().unwrap().cos.clone();
|
||||
let tpl = doc.xfa_template.clone().unwrap();
|
||||
let names = vec!["probe".to_string()];
|
||||
let mut exited = Vec::new();
|
||||
let mut out = crate::js::JsOutput::default();
|
||||
let mut stopped = crate::xfa::XfaRun { page_count: 1, out: &mut out, datasets: None, ran_away: true, time_scale: 1 };
|
||||
crate::xfa::on_changes(&mut cos, &tpl, &names, &mut stopped, &mut exited).unwrap();
|
||||
assert!(exited.is_empty(), "no exit ran: {exited:?}");
|
||||
let mut out = crate::js::JsOutput::default();
|
||||
let mut running = crate::xfa::XfaRun { page_count: 1, out: &mut out, datasets: None, ran_away: false, time_scale: 1 };
|
||||
crate::xfa::on_changes(&mut cos, &tpl, &names, &mut running, &mut exited).unwrap();
|
||||
assert_eq!(exited.len(), 1, "the exit ran: {exited:?}");
|
||||
}
|
||||
|
||||
/// Scripts that change nothing leave no edit: the document stays saved, with nothing to undo,
|
||||
/// and what they showed still reaches the viewer. A field without such scripts runs nothing.
|
||||
#[test]
|
||||
fn focus_scripts_that_change_nothing_leave_the_document_clean() {
|
||||
use crate::js::FieldFocus::{Enter, Exit};
|
||||
let mut s = Session::new();
|
||||
let id = s.open("form.pdf", None, focus_form(), None).expect("opens");
|
||||
s.field_focus(id, "quiet", 0, Enter).unwrap();
|
||||
assert!(!s.field_focus(id, "quiet", 0, Exit).unwrap(), "nothing changed");
|
||||
let doc = s.get(id).unwrap();
|
||||
assert!(!doc.dirty && doc.can_undo().is_none(), "no edit kept");
|
||||
let out = s.take_js_output(id);
|
||||
assert_eq!(out.alerts.iter().map(|m| m.text.as_str()).collect::<Vec<_>>(), ["left quiet"]);
|
||||
// A field without focus scripts, and a field that doesn't exist.
|
||||
assert!(!s.field_focus(id, "exits", 0, Exit).unwrap());
|
||||
assert!(!s.field_focus(id, "nowhere", 0, Enter).unwrap());
|
||||
assert!(!s.get(id).unwrap().dirty);
|
||||
}
|
||||
|
||||
/// A widget the field doesn't have gains or loses no focus: no script runs, rather than the
|
||||
/// field's own.
|
||||
#[test]
|
||||
fn a_widget_the_field_doesnt_have_runs_no_focus_scripts() {
|
||||
use crate::js::FieldFocus::{Enter, Exit};
|
||||
let mut s = Session::new();
|
||||
let id = s.open("form.pdf", None, focus_form(), None).expect("opens");
|
||||
assert!(!s.field_focus(id, "probe", 3, Enter).unwrap(), "probe has one widget");
|
||||
assert!(!s.field_focus(id, "probe", usize::MAX, Exit).unwrap());
|
||||
assert_eq!((field_text(&s, id, "enters"), field_text(&s, id, "exits")), ("0".into(), "0".into()));
|
||||
assert!(!s.get(id).unwrap().dirty);
|
||||
}
|
||||
|
||||
/// Each radio button of a group is a field of its own: reaching the second button runs its
|
||||
/// enter script, reaching the first (which has none) runs nothing. Choosing the button changes
|
||||
/// the group's value, which runs the group's scripts, not the button's: leaving the button
|
||||
/// still runs its exit.
|
||||
#[test]
|
||||
fn a_radio_buttons_own_focus_scripts_run() {
|
||||
use crate::js::FieldFocus::{Enter, Exit};
|
||||
let mut s = Session::new();
|
||||
let id = s.open("form.pdf", None, focus_form(), None).expect("opens");
|
||||
let group = s.get(id).unwrap().form.iter().find(|f| f.widgets.len() == 2).map(|f| f.name.clone()).expect("the radio group");
|
||||
assert!(!s.field_focus(id, &group, 0, Enter).unwrap(), "the first button has no enter script");
|
||||
assert!(s.field_focus(id, &group, 1, Enter).unwrap(), "the second button's ran");
|
||||
assert!(!s.field_focus(id, &group, 2, Enter).unwrap(), "there is no third button");
|
||||
assert_eq!(field_text(&s, id, "picks"), "1");
|
||||
s.apply(id, Edit::SetFieldValue { name: group.clone(), value: FieldValue::Radio(Some("L".into())) }).unwrap();
|
||||
assert!(s.field_focus(id, &group, 1, Exit).unwrap(), "the button's exit runs on leaving it");
|
||||
assert_eq!(field_text(&s, id, "drops"), "1");
|
||||
}
|
||||
|
||||
/// A laid-out XFA field that doesn't scroll (`hScrollPolicy="off"`) opens as a text field with
|
||||
/// DoNotScroll, and its text box says what fits it: what the editor takes no more than.
|
||||
#[test]
|
||||
@@ -7207,6 +7375,8 @@ fn a_dynamic_xfa_form_is_saved_without_the_usage_rights_its_layout_voided() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A dynamic XFA form is laid out from its template on opening (an appended revision, not an
|
||||
/// edit), takes values from the datasets, and saves its filled values incrementally.
|
||||
#[test]
|
||||
fn dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally() {
|
||||
let original = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::template(3)));
|
||||
|
||||
@@ -975,16 +975,34 @@ pub(crate) fn on_open(doc: &mut pdfcraft_cos::Document, tpl: &Template, run: &mu
|
||||
/// After fields `names` took new values: each one's change, exit and validate scripts, then
|
||||
/// the calculations once (or only those, after a reset). A validate script that answers false,
|
||||
/// or fails, shows its message; the value stays, as Acrobat marks the field invalid rather
|
||||
/// than reverting it.
|
||||
pub(crate) fn on_changes(doc: &mut pdfcraft_cos::Document, tpl: &Template, names: &[String], run: &mut XfaRun) -> Result<(), String> {
|
||||
/// than reverting it. The SOM paths whose exit scripts ran go into `exited`.
|
||||
pub(crate) fn on_changes(
|
||||
doc: &mut pdfcraft_cos::Document,
|
||||
tpl: &Template,
|
||||
names: &[String],
|
||||
run: &mut XfaRun,
|
||||
exited: &mut Vec<String>,
|
||||
) -> Result<(), String> {
|
||||
let mut r = Runner::new(doc, tpl, run, OPEN_BUDGET_MS);
|
||||
for name in names {
|
||||
let som = r.by_som().iter().find(|(_, f)| *f == name).map(|(s, _)| s.clone());
|
||||
let Some(som) = som else { continue };
|
||||
let value = r.fields().get(name).map(|f| f.value.join("\n")).unwrap_or_default();
|
||||
for ev in r.events_of(&som, "change").into_iter().chain(r.events_of(&som, "exit")) {
|
||||
for ev in r.events_of(&som, "change") {
|
||||
r.run(&ev, &value)?;
|
||||
}
|
||||
// Noted only when an exit script ran, whether it succeeded or failed: it has run for this
|
||||
// focus loss, and a failed script keeps what it changed before its error, so running it
|
||||
// again would repeat that and the error. One the event's budget skipped, or one never
|
||||
// reached because an earlier script stopped the run, is still to run when the field
|
||||
// loses focus.
|
||||
let mut ran_exit = false;
|
||||
for ev in r.events_of(&som, "exit") {
|
||||
ran_exit |= r.run(&ev, &value)?.is_some();
|
||||
}
|
||||
if ran_exit {
|
||||
exited.push(som.clone());
|
||||
}
|
||||
for ev in r.events_of(&som, "validate") {
|
||||
let Some(o) = r.run(&ev, &value)? else { continue };
|
||||
if (o.result_bool == Some(false) || o.error.is_some()) && !value.trim().is_empty() {
|
||||
@@ -1045,6 +1063,47 @@ pub(crate) fn on_event(doc: &mut pdfcraft_cos::Document, tpl: &Template, som: &s
|
||||
r.finish().map(|_| ())
|
||||
}
|
||||
|
||||
/// A field gaining (`enter`) or losing (`exit`) focus: its scripts for that activity, then, when
|
||||
/// they changed something, the calculations. Returns what changed; nothing (and no error) when
|
||||
/// the field has no such script, so a focus change with nothing to run leaves the form alone.
|
||||
pub(crate) fn on_focus(doc: &mut pdfcraft_cos::Document, tpl: &Template, som: &str, activity: &str, run: &mut XfaRun) -> Result<Changes, String> {
|
||||
let mut r = Runner::new(doc, tpl, run, EVENT_BUDGET_MS);
|
||||
let events = r.events_of(som, activity);
|
||||
if events.is_empty() {
|
||||
return Ok(Changes::default());
|
||||
}
|
||||
// The field's value, as the script's event sees it (`xfa.event.prevText`, `newText`).
|
||||
let value = r.by_som().get(som).cloned().and_then(|name| r.fields().get(&name).map(|f| f.value.join("\n"))).unwrap_or_default();
|
||||
for ev in events {
|
||||
r.run(&ev, &value)?;
|
||||
}
|
||||
if !r.changes.is_empty() {
|
||||
// The calculations that follow get a budget of their own.
|
||||
r.clock = Clock::start(OPEN_BUDGET_MS.saturating_mul(u64::from(r.run.time_scale)));
|
||||
r.calculates()?;
|
||||
}
|
||||
r.finish()
|
||||
}
|
||||
|
||||
/// The SOM path of widget `widget` of field `field`: each radio button of a group has its own,
|
||||
/// and a field's copies on several pages share one. `None` when the field has no widget
|
||||
/// `widget`: the field's own path is only for a widget that records none, so a widget index
|
||||
/// out of range never reaches the field (for a radio field, the group).
|
||||
pub(crate) fn widget_som(doc: &pdfcraft_cos::Document, field: &pdfcraft_forms::Field, widget: usize) -> Option<String> {
|
||||
let som_of = |obj: pdfcraft_cos::ObjRef| {
|
||||
let object = doc.get(obj);
|
||||
object.as_dict()?.get(pdfcraft_xfa::SOM_KEY).map(|s| doc.resolve(s))?.as_string().map(|s| s.to_text())
|
||||
};
|
||||
let w = field.widgets.get(widget)?;
|
||||
som_of(w.obj).or_else(|| som_of(field.obj))
|
||||
}
|
||||
|
||||
/// Does the live form give SOM path `som` an `activity` script?
|
||||
pub(crate) fn has_event(doc: &pdfcraft_cos::Document, tpl: &Template, som: &str, activity: &str) -> bool {
|
||||
let live = pdfcraft_xfa::form_tree(tpl, pdfcraft_xfa::data_of(doc).as_ref(), &pdfcraft_xfa::overrides(doc));
|
||||
live.events.iter().any(|e| e.som == som && e.activity == activity)
|
||||
}
|
||||
|
||||
/// The SOM path of a generated field whose template has a click script.
|
||||
pub(crate) fn clickable_som(doc: &pdfcraft_cos::Document, tpl: &Template, field: &pdfcraft_forms::Field) -> Option<String> {
|
||||
// Page-master copies have distinct PDF field names but share a SOM path. Reversing
|
||||
|
||||
@@ -485,6 +485,9 @@ impl PdfCraftApp {
|
||||
let (other_applied, stay) = self.apply_other_pane_edit(i);
|
||||
let modal = self.modal_open();
|
||||
let applied = self.apply_queued_edit(i) && other_applied;
|
||||
// After the queued edit: a value typed into the field the focus left has been taken (and
|
||||
// its change has run the field's exit) before the focus scripts run.
|
||||
self.run_focus_scripts(i);
|
||||
if !applied {
|
||||
// Nor may a shortcut pressed with it (⌘S right after Enter) run behind the refusal.
|
||||
self.deferred_commands.clear();
|
||||
@@ -721,9 +724,11 @@ impl PdfCraftApp {
|
||||
|
||||
/// Make the active document say what its form shows (#166): apply an edit still queued on
|
||||
/// its tab, then commit the text in a field's open editor, which otherwise commits only on
|
||||
/// Enter, Tab or clicking away (the editor closes, as clicking away would). Run before
|
||||
/// anything reads the document: Save, Print, Export, Optimize, Sign, Extract, Split, Copy
|
||||
/// pages and button scripts.
|
||||
/// Enter, Tab or clicking away (the editor closes, as clicking away would), then run the
|
||||
/// XFA enter and exit scripts of a focus move whose scripts haven't run yet (closing the
|
||||
/// editor is one; so is a move made in the same frame), so the document has what they
|
||||
/// change too. Run before anything reads the document: Save, Print, Export, Optimize, Sign,
|
||||
/// Extract, Split, Copy pages and button scripts.
|
||||
///
|
||||
/// Returns false when the field rejects the value (a validation script or format; the user
|
||||
/// is told why). The editor stays open with the text, and the caller must stop: nothing is
|
||||
@@ -733,14 +738,21 @@ impl PdfCraftApp {
|
||||
if !self.apply_queued_edit(i) {
|
||||
return false;
|
||||
}
|
||||
let Some(draft) = self.views.get(i).and_then(|v| v.forms.focus.clone()) else { return true };
|
||||
let Some(draft) = self.views.get(i).and_then(|v| v.forms.focus.clone()) else {
|
||||
self.run_focus_scripts(i);
|
||||
return true;
|
||||
};
|
||||
let Some(edit) = self.session.get(id).and_then(|d| crate::forms_ui::draft_edit(&draft, &d.form)) else {
|
||||
return true; // nothing typed that the document doesn't already have
|
||||
// Nothing typed that the document doesn't already have.
|
||||
self.run_focus_scripts(i);
|
||||
return true;
|
||||
};
|
||||
if let Some(view) = self.views.get_mut(i) {
|
||||
view.forms.focus = None;
|
||||
}
|
||||
if self.apply_edit(edit) {
|
||||
// The editor closed: the field's exit runs now, unless its change just ran it.
|
||||
self.run_focus_scripts(i);
|
||||
return true;
|
||||
}
|
||||
if let Some(view) = self.views.get_mut(i) {
|
||||
@@ -751,6 +763,51 @@ impl PdfCraftApp {
|
||||
false
|
||||
}
|
||||
|
||||
/// XFA enter and exit scripts for where the focus went on tab `i` since it was last looked at,
|
||||
/// however it moved (a click, Tab or Shift+Tab, Escape, a click on the page): the
|
||||
/// field it left runs its exit, then the field it reached runs its enter. The engine skips an
|
||||
/// exit the field's committed change already ran. A tab that closes runs nothing: its
|
||||
/// document is going away, and a script changing it then would only ask to save it. A script
|
||||
/// that changes the field being edited (an enter clearing a placeholder) shows in its editor
|
||||
/// ([`crate::forms_ui::refresh_draft`]), so leaving the field doesn't write the old value back.
|
||||
fn run_focus_scripts(&mut self, i: usize) {
|
||||
let Some(view) = self.views.get_mut(i) else { return };
|
||||
let now = view.forms.focus.as_ref().map(|f| (f.name.clone(), f.widget));
|
||||
if view.forms.focused == now {
|
||||
return;
|
||||
}
|
||||
let left = std::mem::replace(&mut view.forms.focused, now.clone());
|
||||
let id = view.id;
|
||||
let edited = now.as_ref().map(|(name, _)| name.clone());
|
||||
use pdfcraft_engine::js::FieldFocus;
|
||||
for ((name, widget), focus) in [left.map(|l| (l, FieldFocus::Exit)), now.map(|n| (n, FieldFocus::Enter))].into_iter().flatten() {
|
||||
// The edited field's value before this script, to tell its untouched draft from typing:
|
||||
// the exit of the field left may have changed it already, and the draft with it.
|
||||
let before: Option<(String, Vec<String>)> = edited
|
||||
.as_ref()
|
||||
.and_then(|e| self.session.get(id)?.form.iter().find(|f| &f.name == e).map(|f| (crate::forms_ui::draft_text(f), f.value.clone())));
|
||||
match self.session.field_focus(id, &name, widget, focus) {
|
||||
Ok(changed) => {
|
||||
if changed
|
||||
&& let Some(info) = self.session.get(id).map(|d| d.info.clone())
|
||||
&& let Some(view) = self.views.get_mut(i)
|
||||
{
|
||||
view.document_changed(&info);
|
||||
if let Some(before) = &before
|
||||
&& let Some(draft) = view.forms.focus.as_mut()
|
||||
&& let Some(f) = self.session.get(id).and_then(|d| d.form.iter().find(|f| f.name == draft.name))
|
||||
{
|
||||
crate::forms_ui::refresh_draft(draft, (&before.0, &before.1), (crate::forms_ui::draft_text(f), &f.value));
|
||||
}
|
||||
}
|
||||
let out = self.session.take_js_output(id);
|
||||
self.handle_js(id, out);
|
||||
}
|
||||
Err(e) => self.notify_error(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply the edit queued on tab `i` (the active one). Returns false only when it was a
|
||||
/// field's typing and the field refused it (#166): the editor reopens with the text, and the
|
||||
/// user has been told why, so the typing isn't lost and the caller can stop.
|
||||
@@ -1370,6 +1427,49 @@ mod revert_draft_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Revert drops the focus without running the exit of the field that had it: the document
|
||||
/// is the saved one again, and nothing changes it after.
|
||||
#[test]
|
||||
fn revert_runs_no_exit_for_the_field_that_had_the_focus() {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.open_bytes("focus.pdf", None, pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::focus_template())).unwrap();
|
||||
let id = app.views[0].id;
|
||||
let count = |app: &PdfCraftApp, name: &str| {
|
||||
app.session.get(id).unwrap().form.iter().find(|f| f.name == name).and_then(|f| f.value.first().cloned()).unwrap_or_default()
|
||||
};
|
||||
app.views[0].forms.focus = Some(Focus { name: "probe".into(), ..focus("") });
|
||||
assert!(app.process_pending_edits());
|
||||
assert_eq!(count(&app, "enters"), "1", "reaching probe ran its enter");
|
||||
app.revert_active();
|
||||
assert_eq!(count(&app, "enters"), "0");
|
||||
assert!(app.process_pending_edits());
|
||||
assert_eq!(count(&app, "exits"), "0", "no exit ran on the reverted document");
|
||||
assert!(!app.session.get(id).unwrap().dirty);
|
||||
}
|
||||
|
||||
/// A focus move whose scripts the frame hasn't run yet (Save pressed as the focus reaches a
|
||||
/// field) runs them before anything reads the document: `hint`'s enter clears its "Type here".
|
||||
#[test]
|
||||
fn committing_the_form_runs_the_focus_scripts_of_a_move_not_yet_run() {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.open_bytes("focus.pdf", None, pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::focus_template())).unwrap();
|
||||
let hint = |app: &PdfCraftApp| {
|
||||
app.session
|
||||
.get(app.views[0].id)
|
||||
.unwrap()
|
||||
.form
|
||||
.iter()
|
||||
.find(|f| f.name == "hint")
|
||||
.and_then(|f| f.value.first().cloned())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
assert_eq!(hint(&app), "Type here");
|
||||
// As the editor opens on it: its text and value.
|
||||
app.views[0].forms.focus = Some(Focus { name: "hint".into(), text: "Type here".into(), picked: vec!["Type here".into()], ..focus("") });
|
||||
assert!(app.commit_form_typing());
|
||||
assert_eq!(hint(&app), "", "hint's enter ran before the document is read");
|
||||
assert_eq!(app.views[0].forms.focus.as_ref().map(|f| f.text.as_str()), Some(""), "and its editor shows it");
|
||||
}
|
||||
#[test]
|
||||
fn successful_revert_clears_focused_or_queued_drafts_only_on_its_document() {
|
||||
for queued in [false, true] {
|
||||
|
||||
@@ -91,6 +91,9 @@ impl PdfCraftApp {
|
||||
// Revert discards this source's uncommitted typing as well as model edits.
|
||||
// Otherwise a later Save could write a rejected draft back into the file.
|
||||
view.forms.focus = None;
|
||||
// The focus goes with them, running no exit: a script changing the document
|
||||
// just reverted would only make it unsaved again.
|
||||
view.forms.focused = None;
|
||||
view.forms.committed = None;
|
||||
view.forms.unbaked.clear();
|
||||
view.pending_edit = None;
|
||||
|
||||
@@ -73,6 +73,10 @@ pub struct FormView {
|
||||
/// The widget holding egui's keyboard focus for the focused check box or radio button, and
|
||||
/// whether it takes the arrows (radio buttons). The page keys still work around it.
|
||||
pub(crate) keys: Option<(egui::Id, bool)>,
|
||||
/// The field and widget that had focus when it was last looked at: when the focus moves, the
|
||||
/// one it left runs its XFA exit scripts and the one it reached its enter scripts
|
||||
/// (`PdfCraftApp::run_focus_scripts`).
|
||||
pub(crate) focused: Option<(String, usize)>,
|
||||
}
|
||||
|
||||
/// A form message for the app to show, with document data kept separate so the visible
|
||||
@@ -145,6 +149,7 @@ pub(crate) fn draft_text(f: &FormField) -> String {
|
||||
value
|
||||
}
|
||||
|
||||
/// The editor's draft for widget `widget` of field `f`, opened on the field's value.
|
||||
fn open_focus(f: &FormField, widget: usize) -> Focus {
|
||||
Focus {
|
||||
name: f.name.clone(),
|
||||
@@ -161,6 +166,17 @@ fn open_focus(f: &FormField, widget: usize) -> Focus {
|
||||
}
|
||||
}
|
||||
|
||||
/// After a script changed the field being edited from `before` to `after`, each given as its
|
||||
/// editor text ([`draft_text`]: a combo box's shown option) and its value: the editor shows
|
||||
/// `after`, as [`open_focus`] would, unless the person has typed there already (the draft no
|
||||
/// longer holds `before`), whose typing then stays theirs to commit.
|
||||
pub(crate) fn refresh_draft(focus: &mut Focus, before: (&str, &[String]), after: (String, &[String])) {
|
||||
if focus.picked.as_slice() == before.1 && focus.text == before.0 {
|
||||
focus.text = after.0;
|
||||
focus.picked = after.1.to_vec();
|
||||
}
|
||||
}
|
||||
|
||||
/// Select radio button `wi`, if it isn't selected already. The button itself, not its on state:
|
||||
/// buttons sharing a state are exclusive unless the group has RadiosInUnison (ISO 32000-2
|
||||
/// Table 229).
|
||||
@@ -1087,9 +1103,50 @@ pub fn field_screen_rect(view: &DocView, info: &DocInfo, f: &FormField, widget:
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{MAX_SCREEN_FONT, bounded_line_width};
|
||||
use super::*;
|
||||
use std::cell::Cell;
|
||||
|
||||
/// A draft as the editor opens it on a field holding `value`.
|
||||
fn draft(value: &[&str]) -> Focus {
|
||||
Focus {
|
||||
name: "hint".into(),
|
||||
widget: 0,
|
||||
text: value.first().map(|s| s.to_string()).unwrap_or_default(),
|
||||
picked: value.iter().map(|s| s.to_string()).collect(),
|
||||
request_focus: true,
|
||||
select_all: false,
|
||||
press: None,
|
||||
calendar: None,
|
||||
calendar_rect: None,
|
||||
calendar_text: None,
|
||||
had_focus: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// An untouched draft takes the value a script gave the field; typing is kept.
|
||||
#[test]
|
||||
fn a_draft_follows_a_scripts_change_until_something_is_typed() {
|
||||
let before = vec!["Type here".to_string()];
|
||||
let mut untouched = draft(&["Type here"]);
|
||||
refresh_draft(&mut untouched, ("Type here", &before), (String::new(), &[]));
|
||||
assert_eq!((untouched.text.as_str(), untouched.picked.len()), ("", 0));
|
||||
let mut typed = draft(&["Type here"]);
|
||||
typed.text = "Ann".into();
|
||||
refresh_draft(&mut typed, ("Type here", &before), (String::new(), &[]));
|
||||
assert_eq!(typed.text, "Ann", "the person's typing stays");
|
||||
// A list's choices count as typing too.
|
||||
let mut picked = draft(&["a"]);
|
||||
picked.picked.push("b".into());
|
||||
refresh_draft(&mut picked, ("a", &["a".to_string()]), ("c".into(), &["c".to_string()]));
|
||||
assert_eq!(picked.picked, ["a", "b"]);
|
||||
// A combo box's editor shows the option's shown text, not its export value: that is
|
||||
// what an untouched draft holds, and what it takes.
|
||||
let mut combo = draft(&["a"]);
|
||||
combo.text = "Apple".into();
|
||||
refresh_draft(&mut combo, ("Apple", &["a".to_string()]), ("Banana".into(), &["b".to_string()]));
|
||||
assert_eq!((combo.text.as_str(), combo.picked.as_slice()), ("Banana", ["b".to_string()].as_slice()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_is_never_laid_out_above_the_screen_font_bound() {
|
||||
let largest = Cell::new(0.0f32);
|
||||
|
||||
@@ -127,6 +127,8 @@ fn click_field(h: &mut Harness<'static, PdfCraftApp>, name: &str) {
|
||||
h.run_steps(4);
|
||||
}
|
||||
|
||||
/// Clicking an XFA button in the app runs its click script: one adds a row, another shows its
|
||||
/// message as a notice.
|
||||
#[test]
|
||||
fn xfa_buttons_run_their_scripts_in_the_app() {
|
||||
let mut h = open(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template()));
|
||||
@@ -143,6 +145,87 @@ fn xfa_buttons_run_their_scripts_in_the_app() {
|
||||
assert!(h.query_by_label_contains("Hello 2").is_some(), "script console: {:?}", h.state().js_console.log);
|
||||
}
|
||||
|
||||
/// Every way the focus moves (a click, Tab, Shift+Tab, Escape) runs the field it left's exit
|
||||
/// script and the field it reached's enter script, changed or not; typing a change and tabbing
|
||||
/// out runs the exit once, not twice. A script's message shows as a notice.
|
||||
#[test]
|
||||
fn xfa_enter_and_exit_scripts_run_whenever_the_focus_moves() {
|
||||
use egui::{Key, Modifiers};
|
||||
let mut h = open(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::focus_template()));
|
||||
crate::support::settle(&mut h);
|
||||
let counts = |h: &Harness<'static, PdfCraftApp>| {
|
||||
let s = h.state();
|
||||
let doc = s.session.get(s.views[0].id).unwrap();
|
||||
let v = |n: &str| doc.form.iter().find(|f| f.name == n).and_then(|f| f.value.first().cloned()).unwrap_or_default();
|
||||
(v("enters"), v("exits"))
|
||||
};
|
||||
let focus = |h: &Harness<'static, PdfCraftApp>| h.state().views[0].forms.focus.as_ref().map(|f| f.name.clone());
|
||||
let press = |h: &mut Harness<'static, PdfCraftApp>, modifiers, key| {
|
||||
h.key_press_modifiers(modifiers, key);
|
||||
h.run_steps(4);
|
||||
};
|
||||
click_field(&mut h, "probe");
|
||||
assert_eq!(counts(&h), ("1".into(), "0".into()), "a click in runs the enter");
|
||||
for _ in 0..2 {
|
||||
press(&mut h, Modifiers::NONE, Key::Tab);
|
||||
assert_eq!(focus(&h).as_deref(), Some("quiet"));
|
||||
press(&mut h, Modifiers::SHIFT, Key::Tab);
|
||||
assert_eq!(focus(&h).as_deref(), Some("probe"));
|
||||
}
|
||||
press(&mut h, Modifiers::NONE, Key::Escape);
|
||||
assert_eq!(focus(&h), None);
|
||||
assert_eq!(counts(&h), ("3".into(), "3".into()), "three unchanged visits, three exits; console: {:?}", h.state().js_console.log);
|
||||
// A change typed in: tabbing out runs the exit once.
|
||||
click_field(&mut h, "probe");
|
||||
h.event(egui::Event::Text("typed".into()));
|
||||
h.run_steps(2);
|
||||
press(&mut h, Modifiers::NONE, Key::Tab);
|
||||
assert_eq!(counts(&h), ("4".into(), "4".into()));
|
||||
// Leaving `quiet` shows its message.
|
||||
assert_eq!(focus(&h).as_deref(), Some("quiet"));
|
||||
press(&mut h, Modifiers::NONE, Key::Escape);
|
||||
assert!(h.query_by_label_contains("left quiet").is_some(), "console: {:?}", h.state().js_console.log);
|
||||
}
|
||||
|
||||
/// An enter script that changes the field it enters (clearing a "Type here") shows in the editor,
|
||||
/// so leaving the field keeps the script's value rather than writing the old one back; typing
|
||||
/// after it is kept as usual. When the exit of the field left changed it first (`relay` puts the
|
||||
/// "Type here" back), the editor follows both scripts and ends on the enter script's value.
|
||||
#[test]
|
||||
fn an_enter_script_that_changes_its_field_shows_in_the_editor() {
|
||||
use egui::{Key, Modifiers};
|
||||
let mut h = open(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::focus_template()));
|
||||
crate::support::settle(&mut h);
|
||||
let value = |h: &Harness<'static, PdfCraftApp>| {
|
||||
let s = h.state();
|
||||
s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == "hint").and_then(|f| f.value.first().cloned()).unwrap_or_default()
|
||||
};
|
||||
assert_eq!(value(&h), "Type here");
|
||||
click_field(&mut h, "hint");
|
||||
assert_eq!(value(&h), "", "the enter script cleared it");
|
||||
assert_eq!(h.state().views[0].forms.focus.as_ref().map(|f| f.text.clone()).as_deref(), Some(""), "and the editor shows that");
|
||||
h.key_press_modifiers(Modifiers::NONE, Key::Tab);
|
||||
h.run_steps(4);
|
||||
assert_eq!(value(&h), "", "leaving it doesn't write \"Type here\" back");
|
||||
click_field(&mut h, "hint");
|
||||
h.event(egui::Event::Text("Ann".into()));
|
||||
h.run_steps(2);
|
||||
h.key_press_modifiers(Modifiers::NONE, Key::Tab);
|
||||
h.run_steps(4);
|
||||
assert_eq!(value(&h), "Ann");
|
||||
// From `relay` back to `hint`: relay's exit puts "Type here" back over "Ann", then hint's enter
|
||||
// clears it. The editor follows both, so it holds the last value, and Tab keeps it.
|
||||
click_field(&mut h, "relay");
|
||||
click_field(&mut h, "hint");
|
||||
assert_eq!(value(&h), "", "relay's exit, then hint's enter");
|
||||
assert_eq!(h.state().views[0].forms.focus.as_ref().map(|f| f.text.clone()).as_deref(), Some(""), "the editor holds the enter script's value");
|
||||
h.key_press_modifiers(Modifiers::NONE, Key::Tab);
|
||||
h.run_steps(4);
|
||||
assert_eq!(value(&h), "");
|
||||
}
|
||||
|
||||
/// A message an initialize script shows on opening appears at once, and a print request from it
|
||||
/// is refused (only a click may print).
|
||||
#[test]
|
||||
fn messages_from_scripts_run_on_open_show_right_away() {
|
||||
let tpl = pdfcraft_xfa::fixtures::scripted_template()
|
||||
|
||||
@@ -782,3 +782,43 @@ if (this.rawValue == "1") {
|
||||
"##
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// A form whose text field `probe` counts its enters in `enters` and its exits in `exits`, whose
|
||||
/// text field `quiet` only shows a message when it is left, and whose radio button `size.large`
|
||||
/// counts its enters in `picks` and its exits in `drops` (each button of a radio group is a field
|
||||
/// with scripts of its own), whose text field `hint` clears its "Type here" when it gains focus, and
|
||||
/// whose text field `relay` puts that "Type here" back in `hint` when it loses focus.
|
||||
pub fn focus_template() -> String {
|
||||
let js = "application/x-javascript";
|
||||
let count = |name: &str| format!(r#"<script contentType="{js}">{name}.rawValue = Number({name}.rawValue) + 1;</script>"#);
|
||||
let counter = |name: &str| {
|
||||
format!(r#"<field name="{name}" w="2in" h="0.3in" access="protected"><ui><textEdit/></ui><value><integer>0</integer></value></field>"#)
|
||||
};
|
||||
format!(
|
||||
r##"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/">
|
||||
<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
|
||||
<subform name="form" layout="tb">
|
||||
<field name="probe" w="2in" h="0.3in"><ui><textEdit/></ui><event activity="enter">{}</event><event activity="exit">{}</event></field>
|
||||
<field name="quiet" w="2in" h="0.3in"><ui><textEdit/></ui><event activity="exit"><script contentType="{js}">xfa.host.messageBox("left quiet");</script></event></field>
|
||||
<exclGroup name="size" layout="lr-tb">
|
||||
<field name="small" w="0.5in" h="0.25in"><ui><checkButton/></ui><items><text>S</text></items></field>
|
||||
<field name="large" w="0.5in" h="0.25in"><ui><checkButton/></ui><items><text>L</text></items><event activity="enter">{}</event><event activity="exit">{}</event></field>
|
||||
</exclGroup>
|
||||
{}{}{}{}
|
||||
<field name="hint" w="2in" h="0.3in"><ui><textEdit/></ui><value><text>Type here</text></value><event activity="enter"><script contentType="{js}">if (this.rawValue == "Type here") this.rawValue = "";</script></event></field>
|
||||
<field name="relay" w="2in" h="0.3in"><ui><textEdit/></ui><event activity="exit"><script contentType="{js}">hint.rawValue = "Type here";</script></event></field>
|
||||
</subform>
|
||||
</template>
|
||||
</xdp:xdp>
|
||||
"##,
|
||||
count("enters"),
|
||||
count("exits"),
|
||||
count("picks"),
|
||||
count("drops"),
|
||||
counter("exits"),
|
||||
counter("enters"),
|
||||
counter("picks"),
|
||||
counter("drops")
|
||||
)
|
||||
}
|
||||
@@ -104,8 +104,8 @@ pub use pdf::{CLICK_KEY, DATE_KEY, LAYOUT_KEY, NODE_KEY, SOM_KEY, Stripped, XfaS
|
||||
pub use picture::typed_date;
|
||||
pub use script::{
|
||||
FormNode, LiveForm, MAX_FORM_NODES, NodeKind, OVERRIDES_KEY, Overrides, ScriptEvent, apply_overrides, data_of, embedded_fields,
|
||||
field_grows_with_text, field_is_embedded, fields_by_som, form_tree, has_scripts, overrides, path_is_embedded, refresh_value, rerender,
|
||||
set_overrides, template_of,
|
||||
field_grows_with_text, field_is_embedded, fields_by_som, form_tree, has_focus_scripts, has_scripts, overrides, path_is_embedded, refresh_value,
|
||||
rerender, set_overrides, template_of,
|
||||
};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
|
||||
@@ -440,12 +440,15 @@ pub fn form_tree(tpl: &Template, data: Option<&DataNode>, ov: &Overrides) -> Liv
|
||||
/// Does the template declare any script (an event, calculation or validation)? Forms without
|
||||
/// one need no live form and run nothing.
|
||||
pub fn has_scripts(tpl: &Template) -> bool {
|
||||
/// Is any of `s` a non-blank script?
|
||||
fn any(s: &[Script]) -> bool {
|
||||
s.iter().any(|s| !s.text.trim().is_empty())
|
||||
}
|
||||
/// Has field `f` an event, calculate or validate script?
|
||||
fn field(f: &Field) -> bool {
|
||||
any(&f.scripts) || [&f.calculate, &f.validate].into_iter().flatten().any(|s| !s.text.trim().is_empty())
|
||||
}
|
||||
/// Has any object among `nodes` (and their containers, `depth` levels down) a script?
|
||||
fn walk(nodes: &[Node], depth: usize) -> bool {
|
||||
depth <= MAX_DEPTH
|
||||
&& nodes.iter().any(|n| match n {
|
||||
@@ -459,6 +462,27 @@ pub fn has_scripts(tpl: &Template) -> bool {
|
||||
any(&tpl.root.scripts) || walk(&tpl.root.children, 1) || tpl.root.page_set.as_ref().is_some_and(|ps| ps.areas.iter().any(|a| walk(&a.items, 1)))
|
||||
}
|
||||
|
||||
/// Does any field of the template (in the body, an exclusion group or a master page) have an
|
||||
/// `enter` or `exit` script? Forms without one have nothing to run when the focus moves.
|
||||
pub fn has_focus_scripts(tpl: &Template) -> bool {
|
||||
/// Has field `f` a non-blank enter or exit script?
|
||||
fn field(f: &Field) -> bool {
|
||||
f.scripts.iter().any(|s| matches!(s.activity.as_str(), "enter" | "exit") && !s.text.trim().is_empty())
|
||||
}
|
||||
/// Has any field among `nodes` (and their containers, `depth` levels down) one?
|
||||
fn walk(nodes: &[Node], depth: usize) -> bool {
|
||||
depth <= MAX_DEPTH
|
||||
&& nodes.iter().any(|n| match n {
|
||||
Node::Subform(sf) => walk(&sf.children, depth + 1),
|
||||
Node::Area(a) => walk(&a.children, depth + 1),
|
||||
Node::Field(f) => field(f),
|
||||
Node::ExclGroup(g) => g.fields.iter().any(field),
|
||||
Node::Draw(_) => false,
|
||||
})
|
||||
}
|
||||
walk(&tpl.root.children, 1) || tpl.root.page_set.as_ref().is_some_and(|ps| ps.areas.iter().any(|a| walk(&a.items, 1)))
|
||||
}
|
||||
|
||||
/// The template with `ov` applied to its objects (instance 0 of each; scripts that hide one
|
||||
/// instance of a repeating subform hide them all).
|
||||
pub fn apply_overrides(tpl: &Template, ov: &Overrides) -> Template {
|
||||
|
||||
@@ -4769,6 +4769,29 @@ fn long_names_repeated_by_an_index_are_capped() {
|
||||
assert!(data.len() < 2_000_000, "{} bytes of data built", data.len());
|
||||
}
|
||||
|
||||
/// Forms with an `enter` or `exit` script, in the body, a radio group or a page master, have
|
||||
/// something to run when the focus moves; forms with other scripts only, or blank ones, don't.
|
||||
#[test]
|
||||
fn focus_scripts_are_found_wherever_the_template_has_them() {
|
||||
let has = |xml: &str| has_focus_scripts(&parse(xml).unwrap().0);
|
||||
assert!(has(&crate::fixtures::focus_template()));
|
||||
let clicks =
|
||||
crate::fixtures::focus_template().replace(r#"activity="enter""#, r#"activity="click""#).replace(r#"activity="exit""#, r#"activity="click""#);
|
||||
assert!(!has(&clicks), "click scripts only");
|
||||
let large = r#"<items><text>L</text></items><event activity="#;
|
||||
assert!(has(&clicks.replace(&format!(r#"{large}"click""#), &format!(r#"{large}"enter""#))), "a radio button's enter script");
|
||||
assert!(!has(&template(2)), "click and ready scripts only");
|
||||
let print = crate::fixtures::repeated_print_button_template();
|
||||
assert!(!has(&print));
|
||||
assert!(has(&print.replace(r#"activity="click""#, r#"activity="exit""#)), "a page master's exit script");
|
||||
assert!(
|
||||
!has(&print
|
||||
.replace(r#"activity="click""#, r#"activity="exit""#)
|
||||
.replace("xfa.host.print(1, \"0\", (xfa.host.numPages -1).toString(), 0, 0, 0, 0, 0);", " ")),
|
||||
"a blank script"
|
||||
);
|
||||
}
|
||||
|
||||
/// #3441: a font the form already holds as an object stays that object when the XFA layout
|
||||
/// writes its fields' fonts into `/DR`.
|
||||
#[test]
|
||||
|
||||
@@ -5446,9 +5446,9 @@ tier = "P1"
|
||||
milestone = "M12"
|
||||
status = "partial"
|
||||
commands = ["file.open"]
|
||||
tools = ["doc_open", "doc_info", "form_fields", "form_fill", "js_run"]
|
||||
evidence = ["crates/xfa/src/tests.rs::a_draw_turns_counter_clockwise_about_its_anchor", "crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/it/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/it/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/engine/src/tests.rs::xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it", "crates/automation/tests/it/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/it/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/it/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/it/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/it/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/it/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/it/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/it/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/it/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions", "crates/engine/src/tests.rs::filling_growable_xfa_text_reflows_and_undo_restores_the_layout", "crates/engine/src/tests.rs::xfa_text_growth_refreshes_widget_geometry_but_fixed_height_fields_keep_their_widgets", "crates/automation/tests/it/automation.rs::growable_xfa_text_paginates_after_fill_save_and_print", "crates/engine/src/tests.rs::an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error", "crates/engine/src/tests.rs::repeated_xfa_print_buttons_run_their_click_script", "crates/automation/tests/it/automation.rs::repeated_xfa_print_buttons_work_through_tools_and_after_reopen", "crates/xfa/src/tests.rs::rich_text_max_length_reaches_the_field_and_layout", "crates/automation/tests/it/automation.rs::xfa_rich_text_max_length_is_reported_and_enforced_through_tools", "crates/xfa/src/tests.rs::bmp_and_tiff_pictures_are_decoded_and_drawn", "crates/engine/src/tests.rs::active_xfa_does_not_run_pdf_container_scripts_but_keeps_requested_alerts", "crates/engine/src/tests.rs::active_xfa_without_template_scripts_does_not_initialize_pdf_fallback_scripts", "crates/engine/src/tests.rs::inactive_xfa_and_ordinary_pdfs_keep_document_helpers_and_alerts", "crates/automation/tests/it/automation.rs::active_xfa_skips_container_fallback_scripts_after_save_and_reopen", "crates/xfa/src/tests.rs::laying_a_form_out_takes_off_the_usage_rights_it_voids", "crates/engine/src/tests.rs::a_dynamic_xfa_form_is_saved_without_the_usage_rights_its_layout_voided", "crates/xfa/src/tests.rs::faces_measure_text_at_their_standard_14_widths", "crates/xfa/src/tests.rs::rich_text_links_keep_their_address", "crates/xfa/src/tests.rs::links_in_rich_text_become_link_annotations_one_per_line", "crates/xfa/src/tests.rs::a_links_pieces_share_one_address", "crates/xfa/src/tests.rs::a_rule_between_two_pieces_of_a_link_keeps_them_apart", "crates/xfa/src/tests.rs::links_to_anything_but_web_and_email_addresses_stay_plain_text", "crates/automation/tests/it/automation.rs::links_in_xfa_rich_text_are_listed_through_tools", "crates/xfa/src/testsLine truncated
|
||||
notes = "A draw placed by position turns about its anchor (rotate); fields, subforms and draws in a flow don't. Active XFA layout skips PDF-container document-script initialization for the Acrobat console/field context, so fallback-viewer scripts do not run there. Scripts remain in the file/listing; explicit console/field and XFA alerts still run. Ordinary PDF, static XFA and unlaid-out shells keep document scripts; static-conversion undo restores the XFA policy. Changed growable multiline text (no fixed h) reflows from final datasets after edits, with or without scripts; pages/widgets refresh and undo/redo, save/reopen and print retain all text. Oversized text still uses the existing read-only page-text fallback with a warning; fixed-height fields do not reflow. Repeated page-master buttons resolve their own PDF field to the shared XFA click script; the numPages-based print script requests printing from every copy, including after save/reopen (#2572). Rich-text exData/maxLength is carried into AcroForm MaxLen, reported by form_fields and enforced by form_fill (Unicode characters); limits and accepted values survive save/reopen (#2570). laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro does on a synthetic form), breaks, repeated header rows, borders and fills of flowed subforms (a page break between their children gives each page's part a closed border, the margins repeated at the cut, as measured on a synthetic form in another viewer, and a child's own page break breaks them the same way; one holding an item that doesn't fit on its page, cut across pages or placed past the content area's bottom, is drawn without its border, with a layout warning), captions, objects shown only on screen or only in print (relevant -print and +print, inside containers too: fields by their annotation flags, draws in optional content groups whose view and print usage say so; PdfCraft's own printing doesn't yet apply a layer's print usage, so a screen-only or print-only draw prints as it shows on screen, and a hidden object relevant prints isn't laid out), rounded and inverted border corners, rich text (its http, https and mailto links clickable), JPEG/PNG/GIF/BMP/TIFF images) into real pages and AcroForm fields (text, password, date with AFDate formats (a date one of the edit picture's patterns reads is kept in its first, and in the data as an ISO date; text none of them reads, ISO text included, is kept and shown as typed rather than refused, as another viewer keeps it on a synthetic form; a display picture that differs from the edit picture isn't used to show the date yet), check boxes and radio groups (each box placed by its field's margins, caption and paragraph alignment as measured on two synthetic forms in another viewer: with no caption at the right of the content area, beside a reserved caption where the alignment puts it, and a caption without a reserve taking the rest of the field; a check box's own margin larger than the box draws a bigger box there, which isn't copied), drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields (each named by its SOM path, under a generated field for each step above it, so that another viewer, which lays the form out again, finds a signature made here, as seen on a synthetic form, and a box signed here keeps its signature, and that name, when a script lays the form out again; a box whose path, or a part of it, the file already gives another field keeps its own name, and its signature isn't found there yet), image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; usage rights the form carried (/Perms /UR3) are taken off with a note, as the layout voids them and a form with void usage rights opens with every field locked; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, formattedValue in a date display picture's pattern (number and text display pictures still give the raw value), presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and reLine truncated
|
||||
tools = ["doc_open", "doc_info", "form_fields", "form_fill", "js_run", "form_focus"]
|
||||
evidence = ["crates/xfa/src/tests.rs::a_draw_turns_counter_clockwise_about_its_anchor", "crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/it/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/it/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/engine/src/tests.rs::xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it", "crates/automation/tests/it/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/it/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/it/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/it/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/it/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/it/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/it/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/it/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/it/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions", "crates/engine/src/tests.rs::filling_growable_xfa_text_reflows_and_undo_restores_the_layout", "crates/engine/src/tests.rs::xfa_text_growth_refreshes_widget_geometry_but_fixed_height_fields_keep_their_widgets", "crates/automation/tests/it/automation.rs::growable_xfa_text_paginates_after_fill_save_and_print", "crates/engine/src/tests.rs::an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error", "crates/engine/src/tests.rs::repeated_xfa_print_buttons_run_their_click_script", "crates/automation/tests/it/automation.rs::repeated_xfa_print_buttons_work_through_tools_and_after_reopen", "crates/xfa/src/tests.rs::rich_text_max_length_reaches_the_field_and_layout", "crates/automation/tests/it/automation.rs::xfa_rich_text_max_length_is_reported_and_enforced_through_tools", "crates/xfa/src/tests.rs::bmp_and_tiff_pictures_are_decoded_and_drawn", "crates/engine/src/tests.rs::active_xfa_does_not_run_pdf_container_scripts_but_keeps_requested_alerts", "crates/engine/src/tests.rs::active_xfa_without_template_scripts_does_not_initialize_pdf_fallback_scripts", "crates/engine/src/tests.rs::inactive_xfa_and_ordinary_pdfs_keep_document_helpers_and_alerts", "crates/automation/tests/it/automation.rs::active_xfa_skips_container_fallback_scripts_after_save_and_reopen", "crates/xfa/src/tests.rs::laying_a_form_out_takes_off_the_usage_rights_it_voids", "crates/engine/src/tests.rs::a_dynamic_xfa_form_is_saved_without_the_usage_rights_its_layout_voided", "crates/xfa/src/tests.rs::faces_measure_text_at_their_standard_14_widths", "crates/xfa/src/tests.rs::rich_text_links_keep_their_address", "crates/xfa/src/tests.rs::links_in_rich_text_become_link_annotations_one_per_line", "crates/xfa/src/tests.rs::a_links_pieces_share_one_address", "crates/xfa/src/tests.rs::a_rule_between_two_pieces_of_a_link_keeps_them_apart", "crates/xfa/src/tests.rs::links_to_anything_but_web_and_email_addresses_stay_plain_text", "crates/automation/tests/it/automation.rs::links_in_xfa_rich_text_are_listed_through_tools", "crates/xfa/src/testsLine truncated
|
||||
notes = "A draw placed by position turns about its anchor (rotate); fields, subforms and draws in a flow don't. Active XFA layout skips PDF-container document-script initialization for the Acrobat console/field context, so fallback-viewer scripts do not run there. Scripts remain in the file/listing; explicit console/field and XFA alerts still run. Ordinary PDF, static XFA and unlaid-out shells keep document scripts; static-conversion undo restores the XFA policy. Changed growable multiline text (no fixed h) reflows from final datasets after edits, with or without scripts; pages/widgets refresh and undo/redo, save/reopen and print retain all text. Oversized text still uses the existing read-only page-text fallback with a warning; fixed-height fields do not reflow. Repeated page-master buttons resolve their own PDF field to the shared XFA click script; the numPages-based print script requests printing from every copy, including after save/reopen (#2572). Rich-text exData/maxLength is carried into AcroForm MaxLen, reported by form_fields and enforced by form_fill (Unicode characters); limits and accepted values survive save/reopen (#2570). laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro does on a synthetic form), breaks, repeated header rows, borders and fills of flowed subforms (a page break between their children gives each page's part a closed border, the margins repeated at the cut, as measured on a synthetic form in another viewer, and a child's own page break breaks them the same way; one holding an item that doesn't fit on its page, cut across pages or placed past the content area's bottom, is drawn without its border, with a layout warning), captions, objects shown only on screen or only in print (relevant -print and +print, inside containers too: fields by their annotation flags, draws in optional content groups whose view and print usage say so; PdfCraft's own printing doesn't yet apply a layer's print usage, so a screen-only or print-only draw prints as it shows on screen, and a hidden object relevant prints isn't laid out), rounded and inverted border corners, rich text (its http, https and mailto links clickable), JPEG/PNG/GIF/BMP/TIFF images) into real pages and AcroForm fields (text, password, date with AFDate formats (a date one of the edit picture's patterns reads is kept in its first, and in the data as an ISO date; text none of them reads, ISO text included, is kept and shown as typed rather than refused, as another viewer keeps it on a synthetic form; a display picture that differs from the edit picture isn't used to show the date yet), check boxes and radio groups (each box placed by its field's margins, caption and paragraph alignment as measured on two synthetic forms in another viewer: with no caption at the right of the content area, beside a reserved caption where the alignment puts it, and a caption without a reserve taking the rest of the field; a check box's own margin larger than the box draws a bigger box there, which isn't copied), drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields (each named by its SOM path, under a generated field for each step above it, so that another viewer, which lays the form out again, finds a signature made here, as seen on a synthetic form, and a box signed here keeps its signature, and that name, when a script lays the form out again; a box whose path, or a part of it, the file already gives another field keeps its own name, and its signature isn't found there yet), image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; usage rights the form carried (/Perms /UR3) are taken off with a note, as the layout voids them and a form with void usage rights opens with every field locked; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, formattedValue in a date display picture's pattern (number and text display pictures still give the raw value), presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), enter and exit each tiLine truncated
|
||||
|
||||
[[feature]]
|
||||
id = "form.xfa-formcalc"
|
||||
|
||||
Reference in new issue
Block a user