diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index c5692ff9a7..b17192aab0 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -9,7 +9,7 @@ use std::collections::BTreeSet; use crate::{Args, Automation, Result, ToolError, child, failed, write_atomic}; mod documents; -mod form_tools; +pub(crate) mod form_tools; mod ocr; fn status_id(s: Status) -> &'static str { diff --git a/crates/automation/src/a11y/form_tools.rs b/crates/automation/src/a11y/form_tools.rs index 5f8a7021c4..ae9b624035 100644 --- a/crates/automation/src/a11y/form_tools.rs +++ b/crates/automation/src/a11y/form_tools.rs @@ -12,6 +12,7 @@ fn request_json(r: &pdfcraft_engine::js::Request) -> Value { R::Submit(u) => json!({ "submit": u }), R::Focus(f) => json!({ "focus": f }), R::Beep => json!({ "beep": true }), + R::Highlight(on) => json!({ "highlight": on }), R::SaveAs => json!({ "save_as": true }), R::ShowPanel(p) => json!({ "panel": p.id() }), } @@ -19,7 +20,7 @@ fn request_json(r: &pdfcraft_engine::js::Request) -> Value { /// A message a script showed, as `js_run` reports it: its text, title (null when it gave none), /// icon and buttons. -fn alert_json(m: &pdfcraft_engine::js::Message) -> Value { +pub(crate) fn alert_json(m: &pdfcraft_engine::js::Message) -> Value { json!({ "message": m.text, "title": m.title, "icon": m.icon.name(), "buttons": m.buttons.name() }) } @@ -47,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 { 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; @@ -54,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 { let edit = pdfcraft_engine::Edit::SetFieldScript { name: a.str("field")?.into(), @@ -65,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 { + 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 = out.requests.iter().map(request_json).collect(); + Ok( + json!({ "changed": changed, "alerts": out.alerts.iter().map(alert_json).collect::>(), "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 { let mut files = Vec::new(); for p in a.strs("paths")? { diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs index 2781eb2848..d3850d1d8f 100644 --- a/crates/automation/src/dispatch.rs +++ b/crates/automation/src/dispatch.rs @@ -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)?; diff --git a/crates/automation/src/forms.rs b/crates/automation/src/forms.rs index 98a9985a2a..2fd51038de 100644 --- a/crates/automation/src/forms.rs +++ b/crates/automation/src/forms.rs @@ -208,6 +208,24 @@ fn kind_name(k: FormFieldKind) -> &'static str { } } +/// A JSON number as the radio option it names: its integer spelling when it is a whole number +/// (`1`, `1.0` and `1e0` are all `1`; `-0` is `0`), otherwise JSON's own spelling (`1.5`), which +/// names only an option exported as exactly that text. A whole number at or past 2^53, where a +/// float no longer holds every integer, keeps JSON's spelling too (`1e300`), so it names no option +/// by accident. +fn number_option(n: &serde_json::Number) -> String { + // Integers as parsed (u64, i64) print exactly. + if n.is_u64() || n.is_i64() { + return n.to_string(); + } + const EXACT: f64 = 9_007_199_254_740_992.0; // 2^53 + match n.as_f64() { + // Finite, whole and below 2^53 in magnitude, so the cast is exact and in range. + Some(x) if x.is_finite() && x.fract() == 0.0 && x.abs() < EXACT => (x as i64).to_string(), + _ => n.to_string(), + } +} + /// A JSON value → the value for this field (booleans for check boxes, arrays for lists…). fn value_for(f: &FormField, v: &Value) -> Result { let wrong = |what: &str| ToolError::InvalidArgs(format!("{}: {what}", f.name)); @@ -224,7 +242,24 @@ fn value_for(f: &FormField, v: &Value) -> Result { FieldValue::Widget(i) } (FormFieldKind::Radio, Value::Null) => FieldValue::Radio(None), - (FormFieldKind::Radio, Value::String(s)) => FieldValue::Radio(Some(s.clone())), + // An option as `form_fields` lists it (an export value) names its button's on state. The + // export values are matched first and the on-state names after them + // (`Field::state_for_export`): with `/Opt [(1) (2)]` and states `/0` `/1`, `1` is the first + // button, not the second one's state. A value matching neither is refused when written, + // with the options listed. + (FormFieldKind::Radio, Value::String(s)) => FieldValue::Radio(Some(f.state_for_export(s).unwrap_or(s).to_owned())), + // A number is the same option written without quotes (`1` for the export value `1`). + // Below 2^53 it is compared as a number with the export values first, so `1.0` names the button + // exported as `1.0` (`/Opt [(1.0) (2.0)]`, states `/0 /1`) and not the one whose state is + // `/1`; then as text, as a string would be. + (FormFieldKind::Radio, Value::Number(n)) => { + if let Some(state) = n.as_f64().and_then(|x| f.state_for_number(x)) { + FieldValue::Radio(Some(state.to_owned())) + } else { + let s = number_option(n); + FieldValue::Radio(Some(f.state_for_export(&s).map_or_else(|| s.clone(), str::to_owned))) + } + } (FormFieldKind::Combo | FormFieldKind::List, Value::Array(a)) => FieldValue::Choice( a.iter().map(|x| x.as_str().map(str::to_owned).ok_or_else(|| wrong("list values must be strings"))).collect::>()?, ), diff --git a/crates/automation/src/signing.rs b/crates/automation/src/signing.rs index c78baf25e9..f2b583c070 100644 --- a/crates/automation/src/signing.rs +++ b/crates/automation/src/signing.rs @@ -2,6 +2,7 @@ //! document, and manage trusted certificates. Rectangles are points from the top-left of the //! displayed page. +use pdfcraft_engine::js::SignEvent; use pdfcraft_engine::sign::{self, Certificate, DigitalId, Modification, Name, PrivateKey}; use pdfcraft_engine::{SignOptions, SignatureInfo, SignatureStatus}; use serde_json::{Value, json}; @@ -175,6 +176,8 @@ impl Automation { Ok(json!({ "path": path.to_string_lossy(), "certificate": cert_json(&id.certificate) })) } + /// The digital ID `id` names: a .p12/.pfx file opened with `password`, or an OS key store + /// identity (`keychain:` on macOS, `windows:` on Windows). fn open_id(&self, a: &Args) -> Result { // A macOS Keychain identity: "keychain:" or "keychain:". if let Some(r) = a.str("id")?.strip_prefix("keychain:") { @@ -197,6 +200,8 @@ impl Automation { }) } + /// `sign_document`: sign the document with a digital ID into `out`, visibly (in a field or a + /// rectangle) or not, running a dynamic form's signing scripts around it. pub(crate) fn sign_document(&mut self, a: &Args) -> Result { let id = self.open_id(a)?; let doc = self.doc(a)?; @@ -246,30 +251,94 @@ impl Automation { Some(v) => labels_arg(v)?, }; let out = self.resolve(a.str("out")?, true)?; - let opts = SignOptions { - field, - page, - rect, - reason: a.opt_str("reason")?.map(str::to_string), - location: a.opt_str("location")?.map(str::to_string), - contact: a.opt_str("contact")?.map(str::to_string), - certify, - appearance, - appearance_labels, - graphic, - ..SignOptions::default() + // A dynamic form's signing scripts run only when asked: what preSign changes is signed, + // and the caller hasn't seen it. What they changed is listed. + let run_scripts = a.opt_bool("run_form_scripts")?.unwrap_or(false); + // Every argument is read before the preSign scripts change anything, so a bad one is + // refused with the document as it was. + let reason = a.opt_str("reason")?.map(str::to_string); + let location = a.opt_str("location")?.map(str::to_string); + let contact = a.opt_str("contact")?.map(str::to_string); + let signing = field.clone(); + let scripted = signing.as_deref().is_some_and(|f| { + self.session.sign_event_scripted(doc_id, f, SignEvent::Before) || self.session.sign_event_scripted(doc_id, f, SignEvent::After) + }); + // Each field's value and whether it is read-only, to tell what the preSign scripts changed. + let values = |me: &Automation| -> std::collections::BTreeMap, bool)> { + me.session.get(doc_id).map(|d| d.form.iter().map(|f| (f.name.clone(), (f.value.clone(), f.read_only()))).collect()).unwrap_or_default() }; + let was = values(self); + let before = match &signing { + Some(f) if run_scripts => self.session.run_sign_event(doc_id, f, SignEvent::Before).map_err(failed)?, + _ => false, + }; + let changed: Vec = if before { + values(self) + .into_iter() + .filter(|(name, now)| was.get(name) != Some(now)) + .map(|(name, (value, read_only))| { + let (old, old_ro) = was.get(&name).cloned().unwrap_or_default(); + json!({ "field": name, "before": old, "after": value, "read_only_before": old_ro, "read_only": read_only }) + }) + .collect() + } else { + Vec::new() + }; + let opts = + SignOptions { field, page, rect, reason, location, contact, certify, appearance, appearance_labels, graphic, ..SignOptions::default() }; let signed = self.session.sign(doc_id, &id, opts).map_err(|e| match e { // A graphic the signer refuses is the caller's to fix. pdfcraft_engine::EditError::Sign(m) if m.starts_with("the signature graphic") => bad(m), e => failed(e), - })?; - write_atomic(&out, &signed)?; + }); + let signed = match signed { + Ok(s) => s, + Err(e) => { + // Not signed: what the preSign scripts did is taken back. + if before { + let _ = self.session.undo(doc_id); + let _ = self.session.take_js_output(doc_id); + } + return Err(e); + } + }; + if let Err(e) = write_atomic(&out, &signed) { + // Not saved, so not signed: what the preSign scripts did is taken back too. + if before { + let _ = self.session.undo(doc_id); + let _ = self.session.take_js_output(doc_id); + } + return Err(e); + } let path = out.to_string_lossy().into_owned(); self.session.mark_signed(doc_id, signed.clone(), Some(path.clone())).map_err(failed)?; + // Then its postSign scripts; what they change is a change after signing, not saved yet. + // The file is signed and saved by now: a postSign step that fails is reported, not an error. + let (after, after_error) = match &signing { + Some(f) if run_scripts => match self.session.run_sign_event(doc_id, f, SignEvent::After) { + Ok(ran) => (ran, None), + Err(e) => (true, Some(format!("the postSign step couldn't finish: {e}"))), + }, + _ => (false, None), + }; + let mut scripts = self.session.take_js_output(doc_id); + scripts.errors.extend(after_error); let doc = self.doc(a)?; let newest = doc.signatures.iter().filter(|s| s.signed).max_by_key(|s| s.revision).cloned(); - Ok(json!({ "path": path, "bytes": signed.len(), "signature": newest.map(|s| self.sig_json(a, &s)).transpose()? })) + let mut result = json!({ "path": path, "bytes": signed.len(), "signature": newest.map(|s| self.sig_json(a, &s)).transpose()? }); + if before || after { + result["form_scripts"] = json!({ + "before_signing": before, + "after_signing": after, + "changed": changed, + "alerts": scripts.alerts.iter().map(crate::a11y::form_tools::alert_json).collect::>(), + "errors": scripts.errors, + "unsaved_changes": self.doc(a)?.dirty, + }); + } else if scripted { + result["form_scripts"] = json!({ "skipped": true, "why": "the field has signing scripts; pass run_form_scripts: true to run them" }); + } + Ok(result) } /// `{"image": path}` (PNG or JPEG), `{"text": name}` (typed in the script font) or diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index ec8e1be41d..e1320972b8 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -526,9 +526,23 @@ pub fn tools() -> Vec { t( "form_fill", "Fill in form fields", - "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 (or one of a check box's options, to check that widget and clear the others), 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.", + "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 (or one of a check box's options, to check that widget and clear the others), 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. A radio option is an export value as form_fields lists it in options; where one button's on-state name equals another button's export value, the export value wins, and an on-state name that no export value matches still selects its button. A radio option may also be a JSON number: a whole number names the option spelled as that integer (1, 1.0 and 1e0 all name \"1\"), any other number only an option spelled exactly as JSON prints it (1.5). A value that names no option is refused with the options listed. 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"])), @@ -971,7 +985,7 @@ pub fn tools() -> Vec { .ro() .cmd("sign.digital") .with(schema(json!({}), &[])), - t("sign_document", "Sign a document", "Sign with a digital ID (a .p12/.pfx path, or on macOS a Keychain identity: \"keychain:\" from sign_keychain_ids, or on Windows a store identity: \"windows:\" from sign_windows_ids) and save the signed file to `out` (signing always saves, as in Acrobat; the document then shows the signed file). Sign an existing empty signature field (`field`), or a new one on `page` at `rect` (omit rect for an invisible signature). certify: no_changes, form_fill or comments makes a certification signature. graphic shows a picture where the signer's name would be large (Acrobat: Configure Signature Appearance): {\"image\": path} (PNG or JPEG), {\"text\": name} (typed in the Fill & Sign script font) or {\"strokes\": [[[x, y], …], …]} (y up, the drawing pad 1 wide); it needs a visible signature. appearance chooses what a visible signature shows (default: name, date and labels; reason, location and distinguished_name are off unless turned on). labels gives the words of its labels, e.g. in another language (default English: \"Digitally signed by\", \"DN: \", \"Reason: \", \"Location: \", \"Date: \"; include the separator); each must be in Western European characters (Windows-1252), the only ones the signature's font draws, or the call is refused naming the label and character. PAdES B-B, SHA-256 (SHA-384 for P-384 keys).") + t("sign_document", "Sign a document", "Sign with a digital ID (a .p12/.pfx path, or on macOS a Keychain identity: \"keychain:\" from sign_keychain_ids, or on Windows a store identity: \"windows:\" from sign_windows_ids) and save the signed file to `out` (signing always saves, as in Acrobat; the document then shows the signed file). Sign an existing empty signature field (`field`), or a new one on `page` at `rect` (omit rect for an invisible signature). certify: no_changes, form_fill or comments makes a certification signature. graphic shows a picture where the signer's name would be large (Acrobat: Configure Signature Appearance): {\"image\": path} (PNG or JPEG), {\"text\": name} (typed in the Fill & Sign script font) or {\"strokes\": [[[x, y], …], …]} (y up, the drawing pad 1 wide); it needs a visible signature. appearance chooses what a visible signature shows (default: name, date and labels; reason, location and distinguished_name are off unless turned on). labels gives the words of its labels, e.g. in another language (default English: \"Digitally signed by\", \"DN: \", \"Reason: \", \"Location: \", \"Date: \"; include the separator); each must be in Western European characters (Windows-1252), the only ones the signature's font draws, or the call is refused naming the label and character. PAdES B-B, SHA-256 (SHA-384 for P-384 keys). On a dynamic (XFA) form, run_form_scripts: true runs the field's own signing scripts: preSign just before signing, so what it changes is signed too (form_scripts.changed lists each field it changed, with the values before and after), and postSign after, left unsaved. Without it they don't run, and form_scripts.skipped says the field has them.") .cmd("sign.digital") .with(schema( json!({ @@ -1022,6 +1036,7 @@ pub fn tools() -> Vec { "additionalProperties": false, }, "out": { "type": "string", "description": "Where to save the signed document." }, + "run_form_scripts": { "type": "boolean", "description": "Run a dynamic form's preSign and postSign scripts for the field (default false)." }, }), &["doc", "id", "out"], )), diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 6abfbd5ebf..bc9d2e9ee5 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -3340,6 +3340,166 @@ fn form_fill_warns_when_a_value_does_not_fit() { assert!(r.get("warnings").is_none(), "{r}"); } +/// `form_fill` takes a radio option as `form_fields` lists it: the export value. With `/Opt [(1) +/// (2) (3)]` and on states named by position (`/0`, `/1`, `/2`; ISO 32000-2 Table 230), `1` is the +/// first button. It used to be read as the state `/1` and turn the second one on. +#[test] +fn form_fill_picks_a_radio_option_by_its_export_value() { + let objs = [ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 300] >>", + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R 8 0 R] >>", + "<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) >>", + "<< /FT /Btn /T (title) /Ff 49152 /Opt [(1) (2) (3)] /V /Off /Kids [6 0 R 7 0 R 8 0 R] >>", + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [10 250 22 262] /P 3 0 R /AP << /N << /0 9 0 R /Off 9 0 R >> >> >>", + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [40 250 52 262] /P 3 0 R /AP << /N << /1 9 0 R /Off 9 0 R >> >> >>", + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [70 250 82 262] /P 3 0 R /AP << /N << /2 9 0 R /Off 9 0 R >> >> >>", + "<< /Type /XObject /Subtype /Form /BBox [0 0 12 12] /Length 0 >>\nstream\n\nendstream", + ]; + let dir = workdir("radio-export"); + std::fs::write(dir.join("form.pdf"), pdf_of(&objs)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(ok(&mut a, "form_fields", json!({ "doc": doc }))["fields"][0]["options"], json!(["1", "2", "3"])); + for pick in ["1", "2", "3"] { + ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "title": pick } })); + assert_eq!(ok(&mut a, "form_fields", json!({ "doc": doc }))["fields"][0]["value"], pick); + // The button drawn on is the one picked. + let f = a.session().get(pdfcraft_engine::DocId(doc)).unwrap().form[0].clone(); + let on: Vec = f.widgets.iter().enumerate().filter(|(_, w)| w.state.as_deref().is_some_and(|s| s != "Off")).map(|(i, _)| i).collect(); + assert_eq!(on, [pick.parse::().unwrap() - 1], "picked {pick}"); + } + // A number is the same option: `1` is the first button, not the state `/1`. + for pick in [1, 2, 3] { + ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "title": pick } })); + assert_eq!(ok(&mut a, "form_fields", json!({ "doc": doc }))["fields"][0]["value"], pick.to_string(), "picked {pick}"); + } + // An on-state name the options do not list still selects its button. + ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "title": "0" } })); + assert_eq!(ok(&mut a, "form_fields", json!({ "doc": doc }))["fields"][0]["value"], "1"); +} + +/// The ambiguous pair: the second button's on state is named `1` and the first button exports +/// `1`. The export value wins, however the number is written in JSON (`1`, `1.0`, `1e0`, `0.1e1`); +/// `-0` is `0`, the first button's state. A string is matched as written, so `"1.0"` names no +/// option, and neither does a number that is not whole (`1.5`) or past 2^53 (`1e300`): each is +/// refused with the options listed and the selection kept. +#[test] +fn form_fill_reads_a_radio_number_by_its_integer_spelling() { + let objs = [ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 300] >>", + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R 8 0 R] >>", + "<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) >>", + "<< /FT /Btn /T (title) /Ff 49152 /Opt [(1) (2) (3)] /V /Off /Kids [6 0 R 7 0 R 8 0 R] >>", + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [10 250 22 262] /P 3 0 R /AP << /N << /0 9 0 R /Off 9 0 R >> >> >>", + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [40 250 52 262] /P 3 0 R /AP << /N << /1 9 0 R /Off 9 0 R >> >> >>", + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [70 250 82 262] /P 3 0 R /AP << /N << /2 9 0 R /Off 9 0 R >> >> >>", + "<< /Type /XObject /Subtype /Form /BBox [0 0 12 12] /Length 0 >>\nstream\n\nendstream", + ]; + let dir = workdir("radio-number-spelling"); + std::fs::write(dir.join("form.pdf"), pdf_of(&objs)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap(); + // The values as JSON text, so each number keeps the spelling a client sends. + let fill = |lit: &str| -> Value { serde_json::from_str(&format!(r#"{{"doc": {doc}, "values": {{"title": {lit}}}}}"#)).unwrap() }; + let on = |a: &Automation| -> Vec { + let f = a.session().get(pdfcraft_engine::DocId(doc)).unwrap().form[0].clone(); + f.widgets.iter().enumerate().filter(|(_, w)| w.state.as_deref().is_some_and(|s| s != "Off")).map(|(i, _)| i).collect() + }; + for (lit, button) in [("2", 1), ("1", 0), ("3.0", 2), ("1.0", 0), ("2e0", 1), ("1e0", 0), ("0.1e1", 0), ("-0", 0), ("\"1\"", 0), ("2.000", 1)] { + // From another button, so a fill that does nothing would show. + ok(&mut a, "form_fill", fill(if button == 2 { "1" } else { "3" })); + ok(&mut a, "form_fill", fill(lit)); + assert_eq!(on(&a), [button], "{lit} selects button {}", button + 1); + } + ok(&mut a, "form_fill", fill("2")); + for lit in ["\"1.0\"", "1.5", "-1", "1e300", "18446744073709551615", "9007199254740993.0"] { + let e = err(&mut a, "form_fill", fill(lit)).to_string(); + assert!(e.contains("has no option") && e.contains("options: 1, 2, 3"), "{lit}: {e}"); + assert_eq!(on(&a), [1], "{lit} keeps the selection"); + } +} + +/// Export values written as decimals (`/Opt [(1.0) (2.0)]`, states `/0 /1`): a number is compared +/// with them as a number first, so `1`, `1.0` and `1e0` all pick the first button and `2.0` the +/// second, where `1.0` had become `"1"`, matched no export value and picked the button whose state +/// is `/1`. A string is still matched as written, and a number no export value equals is refused. +#[test] +fn form_fill_matches_a_radio_number_to_decimal_export_values() { + let objs = [ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 300] >>", + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R] >>", + "<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) >>", + "<< /FT /Btn /T (rate) /Ff 49152 /Opt [(1.0) (2.0)] /V /Off /Kids [6 0 R 7 0 R] >>", + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [10 250 22 262] /P 3 0 R /AP << /N << /0 8 0 R /Off 8 0 R >> >> >>", + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [40 250 52 262] /P 3 0 R /AP << /N << /1 8 0 R /Off 8 0 R >> >> >>", + "<< /Type /XObject /Subtype /Form /BBox [0 0 12 12] /Length 0 >> +stream + +endstream", + ]; + let dir = workdir("radio-decimal-exports"); + std::fs::write(dir.join("form.pdf"), pdf_of(&objs)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap(); + let fill = |lit: &str| -> Value { serde_json::from_str(&format!(r#"{{"doc": {doc}, "values": {{"rate": {lit}}}}}"#)).unwrap() }; + let on = |a: &Automation| -> Vec { + let f = a.session().get(pdfcraft_engine::DocId(doc)).unwrap().form[0].clone(); + f.widgets.iter().enumerate().filter(|(_, w)| w.state.as_deref().is_some_and(|s| s != "Off")).map(|(i, _)| i).collect() + }; + for (lit, button) in [("1.0", 0), ("2.0", 1), ("1", 0), ("2", 1), ("1e0", 0), ("\"1.0\"", 0), ("\"2.0\"", 1)] { + // From the other button, so a fill that does nothing would show. + ok(&mut a, "form_fill", fill(if button == 0 { "2.0" } else { "1.0" })); + ok(&mut a, "form_fill", fill(lit)); + assert_eq!(on(&a), [button], "{lit} selects button {}", button + 1); + } + let e = err(&mut a, "form_fill", fill("3")).to_string(); + assert!(e.contains("has no option"), "{e}"); +} + +/// Past 2^53 a float no longer holds every integer, so `9007199254740992` and `9007199254740993` +/// are the same `f64`. A number there is matched only by its exact text, never compared as a +/// number with the export values: with `/Opt [(9007199254740992) (2)]` the number +/// `9007199254740993` names no option, nor does `9007199254740992` with +/// `/Opt [(9007199254740993) (2)]`. Each is refused and the selection kept, while the number +/// spelled as its export value still picks that button. +#[test] +fn form_fill_matches_a_radio_number_past_2_53_only_by_its_text() { + for (export, other) in [("9007199254740992", "9007199254740993"), ("9007199254740993", "9007199254740992")] { + let field = format!("<< /FT /Btn /T (big) /Ff 49152 /Opt [({export}) (2)] /V /Off /Kids [6 0 R 7 0 R] >>"); + let objs = [ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 300] >>", + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R] >>", + "<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) >>", + field.as_str(), + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [10 250 22 262] /P 3 0 R /AP << /N << /0 8 0 R /Off 8 0 R >> >> >>", + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [40 250 52 262] /P 3 0 R /AP << /N << /1 8 0 R /Off 8 0 R >> >> >>", + "<< /Type /XObject /Subtype /Form /BBox [0 0 12 12] /Length 0 >> +stream + +endstream", + ]; + let dir = workdir(&format!("radio-past-2-53-{export}")); + std::fs::write(dir.join("form.pdf"), pdf_of(&objs)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap(); + let fill = |lit: &str| -> Value { serde_json::from_str(&format!(r#"{{"doc": {doc}, "values": {{"big": {lit}}}}}"#)).unwrap() }; + let on = |a: &Automation| -> Vec { + let f = a.session().get(pdfcraft_engine::DocId(doc)).unwrap().form[0].clone(); + f.widgets.iter().enumerate().filter(|(_, w)| w.state.as_deref().is_some_and(|s| s != "Off")).map(|(i, _)| i).collect() + }; + ok(&mut a, "form_fill", fill(export)); + assert_eq!(on(&a), [0], "{export} selects the button exported as {export}"); + ok(&mut a, "form_fill", fill("2")); + let e = err(&mut a, "form_fill", fill(other)).to_string(); + assert!(e.contains("has no option"), "{other} with /Opt [({export}) (2)]: {e}"); + assert_eq!(on(&a), [1], "{other} keeps the selection"); + } +} + /// `form_fill` with {"widget": n} turns on that radio button alone when others share its on /// state (ISO 32000-2 Table 229, RadiosInUnison clear), and `form_fields` says which is on. #[test] @@ -7706,6 +7866,7 @@ fn object_has_name(obj: &pdfcraft_cos::Object, name: &[u8]) -> bool { } } +/// Polygons, polylines and other drawing comments are added and listed through the tools. #[test] fn drawing_comments_through_tools() { let dir = workdir("drawing"); @@ -7812,6 +7973,8 @@ fn lines_and_corners_are_held_level_or_plumb_through_the_tool() { assert!(matches!(refused, Err(ToolError::InvalidArgs(_))), "ortho is for lines and corners: {refused:?}"); } +/// A visible signature drawn from an image, typed text or strokes through sign_document; bad +/// graphics are refused before anything is written. #[test] fn signing_with_an_image_typed_or_drawn_graphic_through_tools() { let dir = workdir("signing-graphic"); @@ -7881,6 +8044,111 @@ fn signing_with_an_image_typed_or_drawn_graphic_through_tools() { assert!(inked(60, 110), "the strokes are drawn"); } +/// Signing a dynamic form's signature box through sign_document runs its XFA signing scripts only +/// when asked (run_form_scripts): preSign first, so what it changes is signed, listed field by field, +/// and postSign after, left as unsaved changes; the result carries their messages. Not asked, they +/// don't run and the result says the box has them; a box without them reports nothing. +#[test] +fn signing_an_xfa_box_runs_its_signing_scripts_through_tools() { + let dir = workdir("xfa-sign-events"); + std::fs::write(dir.join("form.pdf"), pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::signing_events_template())).unwrap(); + let mut a = auto(&dir); + ok(&mut a, "sign_id_create", json!({ "name": "Ada Lovelace", "key": "p256", "password": "secret12", "path": "ada.p12" })); + let doc = ok(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap(); + let fields = ok(&mut a, "form_fields", json!({ "doc": doc })); + let name = |n: &str| { + fields["fields"] + .as_array() + .unwrap() + .iter() + .filter_map(|f| f["name"].as_str()) + .find(|f| *f == n || f.ends_with(&format!(".{n}[0]"))) + .unwrap() + .to_string() + }; + // Not asked: the scripts don't run, and the result says the box has them. + let r = ok(&mut a, "sign_document", json!({ "doc": doc, "id": "ada.p12", "password": "secret12", "field": name("sign"), "out": "unasked.pdf" })); + assert_eq!(r["form_scripts"]["skipped"], json!(true), "{r}"); + let unasked = ok(&mut a, "doc_open", json!({ "path": "unasked.pdf" }))["doc"].as_u64().unwrap(); + let unasked = ok(&mut a, "form_fields", json!({ "doc": unasked })); + assert!( + unasked["fields"] + .as_array() + .unwrap() + .iter() + .any(|f| f["name"].as_str().is_some_and(|n| n == "when" || n.ends_with(".when[0]")) && f["value"].is_null()), + "{unasked}" + ); + let doc = ok(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap(); + // A bad argument is refused before the preSign scripts run. + let bad = a.call( + "sign_document", + &json!({ "doc": doc, "id": "ada.p12", "password": "secret12", "field": name("sign"), "out": "bad.pdf", "run_form_scripts": true, "reason": 5 }), + ); + assert!(matches!(bad, Err(ToolError::InvalidArgs(_))), "{bad:?}"); + let now = ok(&mut a, "form_fields", json!({ "doc": doc })); + assert!( + now["fields"] + .as_array() + .unwrap() + .iter() + .any(|f| f["name"].as_str().is_some_and(|n| n == "when" || n.ends_with(".when[0]")) && f["value"].is_null()), + "{now}" + ); + // A signed file that can't be written (`out` is a folder): not signed, and what preSign did + // is taken back. + std::fs::create_dir_all(dir.join("taken")).unwrap(); + let failed = a.call( + "sign_document", + &json!({ "doc": doc, "id": "ada.p12", "password": "secret12", "field": name("sign"), "out": "taken", "run_form_scripts": true }), + ); + assert!(failed.is_err(), "{failed:?}"); + let now = ok(&mut a, "form_fields", json!({ "doc": doc })); + assert!( + now["fields"] + .as_array() + .unwrap() + .iter() + .any(|f| f["name"].as_str().is_some_and(|n| n == "when" || n.ends_with(".when[0]")) && f["value"].is_null()), + "{now}" + ); + let r = ok( + &mut a, + "sign_document", + json!({ "doc": doc, "id": "ada.p12", "password": "secret12", "field": name("sign"), "out": "signed.pdf", "run_form_scripts": true }), + ); + let scripts = &r["form_scripts"]; + let changed = scripts["changed"].as_array().unwrap(); + assert_eq!(changed.len(), 1, "only what preSign changed: {r}"); + assert_eq!((&changed[0]["after"], &changed[0]["read_only"]), (&json!(["signed"]), &json!(true)), "{r}"); + assert_eq!((scripts["before_signing"].as_bool(), scripts["after_signing"].as_bool()), (Some(true), Some(true)), "{r}"); + assert_eq!(scripts["alerts"], json!([{ "message": "Signing notice", "title": null, "icon": "error", "buttons": "ok" }]), "{r}"); + assert_eq!(scripts["unsaved_changes"], json!(true), "the postSign change isn't in the signed file: {r}"); + // The signed file holds what the preSign script did, not what the postSign one did. + let signed = ok(&mut a, "doc_open", json!({ "path": "signed.pdf" }))["doc"].as_u64().unwrap(); + let values = ok(&mut a, "form_fields", json!({ "doc": signed })); + let value = |n: &str| { + values["fields"] + .as_array() + .unwrap() + .iter() + .find(|f| f["name"].as_str().is_some_and(|f| f == n || f.ends_with(&format!(".{n}[0]")))) + .map(|f| f["value"].clone()) + }; + assert_eq!(value("when"), Some(json!("signed")), "{values}"); + assert_eq!(value("after"), Some(json!(null)), "{values}"); + let list = ok(&mut a, "sign_list", json!({ "doc": signed })); + assert!(list["signatures"].as_array().unwrap().iter().any(|s| s["signed"] == true && s["status"] == "unknown"), "intact: {list}"); + // A box without signing scripts: nothing reported. + let r = ok( + &mut a, + "sign_document", + json!({ "doc": signed, "id": "ada.p12", "password": "secret12", "field": name("plain"), "out": "both.pdf", "run_form_scripts": true }), + ); + assert!(r.get("form_scripts").is_none(), "{r}"); + let _ = std::fs::remove_dir_all(&dir); +} + /// Signing a laid-out XFA form's signature box through the tools signs the field named by its /// SOM path, which is how another viewer, laying the form out again, finds the signature; the /// signed file reopens with the signature intact and on that field. @@ -7903,6 +8171,8 @@ fn a_dynamic_xfa_forms_signature_box_is_signed_under_its_som_path() { let _ = std::fs::remove_dir_all(&dir); } +/// Digital IDs are created and opened, documents signed with them and their signatures validated, +/// all through the tools. #[test] fn named_layer_verdicts_are_reported_and_rendered_through_tools() { use pdfcraft_cos::{Dict, Document, Lexer, ObjRef, Object, SaveOptions, Stream}; @@ -10309,6 +10579,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"); @@ -10336,6 +10608,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] diff --git a/crates/engine/src/document.rs b/crates/engine/src/document.rs index 7ee24c8c06..530f8787af 100644 --- a/crates/engine/src/document.rs +++ b/crates/engine/src/document.rs @@ -59,6 +59,10 @@ pub struct Document { pub(crate) xfa_template: Option>, /// 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, /// 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, @@ -495,10 +499,16 @@ impl Document { self.undo_label().map(Label::as_str) } + /// What Redo would redo, in English, when there is something to redo. pub fn can_redo(&self) -> Option<&str> { self.redo_label().map(Label::as_str) } + /// How many steps Undo can take back: a step still at this depth is still the last one. + pub fn undo_depth(&self) -> usize { + self.editor.as_ref().map_or(0, |e| e.undo.len()) + } + /// What Undo would undo, in parts, for an interface that says it in another language. pub fn undo_label(&self) -> Option<&Label> { self.editor.as_ref().and_then(|e| e.undo.last()).map(|(l, ..)| l) diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index 0d058f3e5c..eb213f3f64 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -229,12 +229,16 @@ pub(crate) struct EditCtx { pub(crate) xfa_datasets: Option, /// 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. pub(crate) xfa_removed: Vec, /// Pages in the document, for `xfa.layout.pageCount()`. pub(crate) pages: usize, + /// Whether the viewer highlights form fields, for `app.runtimeHighlight`. + pub(crate) highlight: bool, pub(crate) date: Option, /// Today in local time, for date tokens. pub(crate) today: (i64, u32, u32), @@ -259,6 +263,23 @@ pub(crate) struct EditCtx { } impl EditCtx { + /// The field highlight (`app.runtimeHighlight`) the edit's next script reads: the field + /// scripts' runner holds it while there is one (its scripts change it there), else the edit. + pub(crate) fn highlight(&self) -> bool { + self.js.as_ref().map_or(self.highlight, |js| js.doc.highlight) + } + + /// A script changed the field highlight: both script runtimes of the edit read the new one, + /// so a later edit of a batch sees it whichever kind of script it runs. + pub(crate) fn set_highlight(&mut self, on: bool) { + self.highlight = on; + if let Some(js) = self.js.as_mut() { + js.doc.highlight = on; + } + } + + /// The context for one edit: `now` (the session's clock) dates it, and with `salt` seeds the + /// ids it makes. pub(crate) fn new(now: Option, salt: u64) -> Self { let mut seed = 0x9E37_79B9_7F4A_7C15u64 ^ salt; if let Some(t) = now { @@ -275,9 +296,11 @@ 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, + highlight: false, date: now.map(pdfcraft_cos::pdf_date), today: (1970, 1, 1), seed, @@ -707,12 +730,26 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E }, Edit::XfaEvent { som, activity } => { let tpl = cx.xfa.clone().ok_or_else(|| EditError::Invalid("this is not a laid-out XFA form, or JavaScript is off".into()))?; - let mut run = - xfa::XfaRun { page_count: cx.pages, out: &mut cx.xfa_out, datasets: cx.xfa_datasets, ran_away: false, time_scale: cx.xfa_time_scale }; - let ran = xfa::on_event(doc, &tpl, som, activity, &mut run); + let highlight = cx.highlight(); + let mut run = xfa::XfaRun { + page_count: cx.pages, + out: &mut cx.xfa_out, + datasets: cx.xfa_datasets, + ran_away: false, + highlight, + time_scale: cx.xfa_time_scale, + }; + 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; - ran.map_err(EditError::Invalid)?; + // The next edit of a batch reads the highlight this one's scripts set. + let on = run.highlight; + cx.set_highlight(on); + cx.xfa_unchanged = ran.map_err(EditError::Invalid)?; } Edit::SetFieldImage { name, image } => { let (img, _) = embed_image(doc, name, image)?; @@ -1037,6 +1074,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)?; } diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index 0dd14a42b4..31f1aed19e 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -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 { @@ -255,11 +264,18 @@ pub struct JsRunner { impl JsRunner { /// A runner for `cos`: what scripts read about the document (pages, info, whether it is - /// an XFA form) and its document-level scripts, read once. - pub fn new(cos: &pdfcraft_cos::Document, file_name: &str) -> JsRunner { + /// an XFA form) and its document-level scripts, read once. `highlight`: whether the viewer + /// highlights form fields (`app.runtimeHighlight`). + pub fn new(cos: &pdfcraft_cos::Document, file_name: &str, highlight: bool) -> JsRunner { let xfa = pdfcraft_xfa::xfa_shape(cos).is_some(); - let doc = - pdfcraft_js::DocInfo { file_name: file_name.to_string(), num_pages: pdfcraft_model::pages(cos).len(), page: 0, info: info(cos), xfa }; + let doc = pdfcraft_js::DocInfo { + file_name: file_name.to_string(), + num_pages: pdfcraft_model::pages(cos).len(), + page: 0, + info: info(cos), + highlight, + xfa, + }; // Once XFA owns the form, the PDF container's scripts belong to its fallback view. // Keep them in the file and in Document JavaScripts, but do not initialize an // AcroForm context with them: a viewer-compatibility alert would be misleading. @@ -270,6 +286,14 @@ impl JsRunner { } } +/// The field highlight the last of `requests` that sets it asks for (`app.runtimeHighlight`). +fn last_highlight(requests: &[Request]) -> Option { + requests.iter().rev().find_map(|r| match r { + Request::Highlight(on) => Some(*on), + _ => None, + }) +} + /// The Info dictionary as `this.info` keys (lower-case). fn info(cos: &pdfcraft_cos::Document) -> Vec<(String, String)> { let Some(d) = cos.trailer().get(b"Info").map(|i| cos.resolve(i)).and_then(|i| i.as_dict().cloned()) else { return Vec::new() }; @@ -320,6 +344,10 @@ impl JsRunner { if log { self.output.absorb(&o); } + // The edit's later scripts read the field highlight this one set. + if let Some(on) = last_highlight(&o.requests) { + self.doc.highlight = on; + } if o.error.is_some() { return None; } @@ -355,21 +383,124 @@ impl crate::Document { } } +/// When a signature field's XFA signing scripts run ([`Session::run_sign_event`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SignEvent { + /// Just before the field is signed (`preSign`): what it changes is signed too. + Before, + /// Once the field is signed (`postSign`). + After, +} + +impl SignEvent { + /// The template's name for the event. + pub fn activity(self) -> &'static str { + match self { + SignEvent::Before => "preSign", + SignEvent::After => "postSign", + } + } +} + impl Session { /// Preferences ▸ JavaScript ▸ Enable Acrobat JavaScript (on by default). pub fn set_javascript(&mut self, on: bool) { self.js_off = !on; } + /// Whether JavaScript is on (Preferences ▸ JavaScript ▸ Enable Acrobat JavaScript). pub fn javascript(&self) -> bool { !self.js_off } + /// Whether the viewer highlights form fields (Highlight fields), which forms' scripts read as + /// `app.runtimeHighlight`. A script that changes it asks for [`Request::Highlight`]; the + /// viewer then calls this with the new setting. + pub fn set_field_highlight(&mut self, on: bool) { + self.field_highlight = on; + } + /// What scripts printed or asked for since the last call (alerts, console, print, …). pub fn take_js_output(&mut self, id: DocId) -> JsOutput { self.docs.iter_mut().find(|d| d.id == id).map(|d| std::mem::take(&mut d.js_output)).unwrap_or_default() } + /// The last field-highlight change (`app.runtimeHighlight`) scripts asked for in document + /// `id`, taken out of its waiting output; the rest stays for [`Session::take_js_output`]. + pub fn take_js_highlight(&mut self, id: DocId) -> Option { + let doc = self.docs.iter_mut().find(|d| d.id == id)?; + let mut last = None; + doc.js_output.requests.retain(|r| match r { + Request::Highlight(on) => { + last = Some(*on); + false + } + _ => true, + }); + 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 { + 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 signature field `field`'s XFA signing scripts for `event` in document `id`: `preSign` + /// before it is signed, `postSign` after, as one undoable step, as a form's signing events run + /// (DA 31's dates the signature and locks the date). `Ok(false)`, with nothing changed, when + /// there are none: not a laid-out XFA form, JavaScript off, no such field, or no such script. + /// What the scripts said or asked for is in [`Session::take_js_output`]. + pub fn run_sign_event(&mut self, id: DocId, field: &str, event: SignEvent) -> Result { + if self.get(id).is_none() { + return Err(EditError::NoDocument); + } + let Some(som) = self.sign_event_som(id, field, event) else { return Ok(false) }; + self.apply(id, Edit::XfaEvent { som, activity: event.activity().into() })?; + Ok(true) + } + + /// Whether signature field `field` of document `id` has XFA signing scripts for `event` that + /// [`Session::run_sign_event`] would run, without running them. + pub fn sign_event_scripted(&self, id: DocId, field: &str, event: SignEvent) -> bool { + self.sign_event_som(id, field, event).is_some() + } + + /// The SOM path of signature field `field` when it has scripts for `event` to run. + fn sign_event_som(&self, id: DocId, field: &str, event: SignEvent) -> Option { + if self.js_off { + return None; + } + let doc = self.get(id)?; + let (tpl, editor) = (doc.xfa_template.as_ref()?, doc.editor.as_ref()?); + let f = doc.form.iter().find(|f| f.name == field)?; + crate::xfa::scripted_som(&editor.cos, tpl, f, event.activity()) + } + /// 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. @@ -396,7 +527,7 @@ impl Session { ..Default::default() }); } - let runner = JsRunner::new(&cos, &doc.name); + let runner = JsRunner::new(&cos, &doc.name, self.field_highlight); let fields = pdfcraft_forms::fields(&cos); let states: Vec<_> = fields.iter().map(|f| field_state(&cos, f)).collect(); let event = match target { @@ -414,6 +545,10 @@ impl Session { }) .collect(); o.requests.retain(|r| !matches!(r, Request::Reset(_))); + // The calculations the script's changes set off read the field highlight it asked for. + if let Some(on) = last_highlight(&o.requests) { + self.field_highlight = on; + } let mut edits = Vec::new(); if !changes.is_empty() { edits.push(Edit::ApplyScriptChanges { changes }); diff --git a/crates/engine/src/open.rs b/crates/engine/src/open.rs index a09acc9360..28dd235928 100644 --- a/crates/engine/src/open.rs +++ b/crates/engine/src/open.rs @@ -18,6 +18,7 @@ pub struct OpenContext { now: Option, js_off: bool, trust: Arc, + highlight: bool, captions: Arc, xfa_time_scale: u32, } @@ -39,6 +40,14 @@ struct DocumentOpenData<'a> { } impl OpenContext { + /// This context with Highlight fields `on`, which the document's scripts read as + /// `app.runtimeHighlight` while it opens. A new document opens with the remembered setting + /// while the session keeps the one of the document in front, which its scripts read. + pub fn with_highlight(mut self, on: bool) -> Self { + self.highlight = on; + self + } + /// Prepare a document without mutating the session that supplied this context. pub fn prepare_open( &self, @@ -60,6 +69,7 @@ impl Session { now: self.now(), js_off: self.js_off, trust: self.trust.clone(), + highlight: self.field_highlight, captions: self.captions.clone(), xfa_time_scale: self.xfa_time_scale.unwrap_or(1), } @@ -100,6 +110,8 @@ impl Session { Ok(self.commit_open(prepared)) } + /// What [`OpenContext::prepare_open`] runs inside its panic guard: read, decrypt and lay out + /// the document, and run an XFA form's opening scripts. fn prepare_open_unguarded( context: &OpenContext, name: String, @@ -210,6 +222,7 @@ impl Session { out: &mut script_output, datasets: None, ran_away: false, + highlight: context.highlight, time_scale: context.xfa_time_scale, }; let changes = xfa::on_open(&mut work, &tpl, &mut run); @@ -371,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(), diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index 4f7951b236..bd83242682 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -45,6 +45,9 @@ pub struct Session { pub(crate) date_format: Option, /// Preferences ▸ Date format ▸ Language, when not following the interface language. pub(crate) date_language: Option, + /// Highlight fields, as the viewer shows it: what forms' scripts read as + /// `app.runtimeHighlight` (off by default, as in a session without a viewer). + pub(crate) field_highlight: bool, /// What the XFA scripts' time limits are multiplied by (`None`: 1); see /// [`Session::with_xfa_time_scale`]. pub(crate) xfa_time_scale: Option, diff --git a/crates/engine/src/session/editing.rs b/crates/engine/src/session/editing.rs index 3eacd3f162..1584fb5209 100644 --- a/crates/engine/src/session/editing.rs +++ b/crates/engine/src/session/editing.rs @@ -36,6 +36,7 @@ impl Session { let now = self.now(); let today = self.today(); let js_off = self.js_off; + let highlight = self.field_highlight; let xfa_time_scale = self.xfa_time_scale.unwrap_or(1); let doc = self.doc_mut(id)?; doc.edit_notices.clear(); @@ -48,6 +49,7 @@ impl Session { } cx.xfa_time_scale = xfa_time_scale; cx.pages = doc.info.pages.len(); + cx.highlight = highlight; cx.today = today; let reason = doc.read_only_reason.clone().unwrap_or_default(); let signed = doc.is_signed(); @@ -85,13 +87,20 @@ impl Session { } let mut next = editor.cos.clone(); if !js_off && uses_scripts(&edit) { - cx.js = Some(js::JsRunner::new(&next, &name)); + cx.js = Some(js::JsRunner::new(&next, &name, highlight)); } cx.renamed_fields.clear(); cx.redaction_overlays.clear(); // `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 { @@ -130,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 = 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; @@ -139,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, reset: &mut bool) { match e { Edit::SetFieldValue { name, .. } => changed.push(name.clone()), @@ -151,19 +163,24 @@ impl Session { let mut reset = false; collect(&edit, &mut changed, &mut reset); if !changed.is_empty() || reset { + let highlight = cx.highlight(); let mut run = xfa::XfaRun { page_count: cx.pages, out: &mut cx.xfa_out, datasets: cx.xfa_datasets, ran_away: false, + highlight, time_scale: cx.xfa_time_scale, }; // 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; + // The next edit of a batch reads the highlight this one's scripts set. + let on = run.highlight; + cx.set_highlight(on); if let Err(e) = ran { cx.xfa_out.errors.push(e); } @@ -236,6 +253,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 = cx.xfa_removed.iter().map(|n| format!("{XFA_REMOVED_PREFIX}{n}")).collect(); doc.xfa_warnings.retain(|w| w.starts_with(XFA_REMOVED_PREFIX)); @@ -248,15 +269,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 { let doc = self.doc_mut(id)?; let editor = doc.editor.as_mut().ok_or(EditError::NothingToUndo)?; @@ -282,10 +299,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 { let doc = self.doc_mut(id)?; let editor = doc.editor.as_mut().ok_or(EditError::NothingToRedo)?; @@ -311,6 +331,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) } @@ -587,6 +609,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 { // What it drew with `?` (a value or comment text its font can't draw, stored as typed). diff --git a/crates/engine/src/session/save.rs b/crates/engine/src/session/save.rs index a727ce6329..dd32cdbc5c 100644 --- a/crates/engine/src/session/save.rs +++ b/crates/engine/src/session/save.rs @@ -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)) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 8a9ea105c6..3912bfd5ec 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3854,6 +3854,45 @@ fn printing_lays_out_sheets_that_render() { assert_eq!(s4.print_pdf(id4, &print::Settings { pages: vec![0], ..Default::default() }), Err(EditError::NotPermitted("printing"))); } +/// A dynamic XFA form's screen-only and print-only objects (`relevant`): on screen, the +/// screen-only field and draw show and the print-only ones don't; printed, the screen-only field +/// is left off and the print-only one is on paper. (Draws follow the layers' state on screen +/// when printed until printing applies the layers' print usage; other viewers read it from the +/// file.) +#[test] +fn xfa_screen_only_and_print_only_objects_show_and_print_where_the_form_says() { + let field = |name: &str, attrs: &str| { + format!( + r#"{name} value"# + ) + }; + let draw = |name: &str, attrs: &str| { + format!(r#"{name} text"#) + }; + let xdp = format!( + r##" +"##, + field("screenField", r#"relevant="-print""#), + field("paperField", r#"presence="invisible" relevant="+print""#), + draw("screenDraw", r#"relevant="-print""#), + draw("paperDraw", r#"presence="invisible" relevant="+print""#), + ); + let mut s = Session::new(); + let id = s.open("form.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(&xdp)), None).expect("opens"); + let screen = page_texts(&s, id).join("\n"); + assert!(screen.contains("screenField value") && screen.contains("screenDraw text"), "on screen: {screen}"); + assert!(!screen.contains("paperField value") && !screen.contains("paperDraw text"), "on screen: {screen}"); + let sheet = s.print_pdf(id, &print::Settings { pages: vec![0], ..Default::default() }).unwrap(); + let mut out = Session::new(); + let printed = out.open("print.pdf", None, Arc::new(sheet), None).unwrap(); + let paper = page_texts(&out, printed).join("\n"); + assert!(!paper.contains("screenField value"), "printed: {paper}"); + assert!(paper.contains("paperField value"), "printed: {paper}"); +} + /// A one-page document whose red square is in a layer; `off` switches the layer off by default. fn red_layer_pdf(off: bool) -> Vec { let content = "/OC /L1 BDC 1 0 0 rg 0 0 100 100 re f EMC"; @@ -6706,7 +6745,7 @@ fn inactive_xfa_and_ordinary_pdfs_keep_document_helpers_and_alerts() { let mut cos = pdfcraft_cos::Document::open(Arc::new(bytes)).unwrap(); pdfcraft_forms::set_document_script(&mut cos, "helper", Some("function twice(n) { return n * 2; } app.alert('PDF message');")).unwrap(); // The raw dynamic shell has not been laid out: XFA's presence alone is not enough. - let runner = js::JsRunner::new(&cos, "control.pdf"); + let runner = js::JsRunner::new(&cos, "control.pdf", false); let out = pdfcraft_js::run("twice(3)", &pdfcraft_js::Event::doc("Console"), &runner.doc, &[], &runner.doc_scripts, Default::default()); assert!(out.error.is_none(), "{out:?}"); assert_eq!(out.result.as_deref(), Some("6")); @@ -7202,6 +7241,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(); @@ -7237,6 +7278,172 @@ fn measurement_edits_are_transactional_and_honour_annotation_permissions() { ); } +/// The synthetic focus form ([`pdfcraft_xfa::fixtures::focus_template`]). +fn focus_form() -> Arc> { + 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::().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::().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, highlight: false, 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, highlight: 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::>(), ["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] @@ -7285,6 +7492,8 @@ fn round_xfa_check_buttons_open_drawn_round() { } } +/// A dynamic XFA form with usage rights is saved without them once laid out, as the layout voids +/// them, with a note saying so. #[test] fn a_dynamic_xfa_form_is_saved_without_the_usage_rights_its_layout_voided() { let xdp = pdfcraft_xfa::fixtures::template(3); @@ -7311,6 +7520,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))); @@ -7350,6 +7561,40 @@ fn dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally() { assert_eq!(d2.form.iter().find(|f| f.name == "answer").unwrap().value, vec!["N".to_string()]); } +/// A signature box's XFA preSign script runs as one undoable step, showing its notice and +/// changing the form; its postSign script runs too; a box without them runs nothing and changes +/// nothing. +#[test] +fn xfa_signing_scripts_run_before_and_after_signing() { + use crate::js::SignEvent; + let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::signing_events_template())); + let mut s = Session::new(); + let id = s.open("signing.pdf", None, bytes, None).expect("opens"); + let field = + |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n || f.name.ends_with(&format!(".{n}[0]"))).cloned().unwrap(); + let sign = field(&s, "sign").name; + let plain = field(&s, "plain").name; + assert!(s.run_sign_event(id, &sign, SignEvent::Before).unwrap(), "it has a preSign script"); + assert_eq!(field(&s, "when").value, ["signed"]); + assert!(field(&s, "when").read_only(), "and it locks the field"); + assert_eq!(s.take_js_output(id).alerts, [crate::js::Message::plain("Signing notice")]); + assert!(s.run_sign_event(id, &sign, SignEvent::After).unwrap()); + assert_eq!(field(&s, "after").value, ["done"]); + // Each one is an undo step of its own. + s.undo(id).unwrap(); + assert!(field(&s, "after").value.is_empty()); + assert_eq!(field(&s, "when").value, ["signed"]); + // No signing scripts: nothing runs, nothing to undo. + let label = s.get(id).unwrap().undo_label().cloned(); + assert!(!s.run_sign_event(id, &plain, SignEvent::Before).unwrap()); + assert!(!s.run_sign_event(id, "no such field", SignEvent::Before).unwrap()); + assert_eq!(s.get(id).unwrap().undo_label().cloned(), label); + // Nor with JavaScript off. + s.set_javascript(false); + assert!(!s.run_sign_event(id, &sign, SignEvent::Before).unwrap()); +} + +/// A signature box the file already holds a signature for is drawn with that signature's picture. #[test] fn a_signed_xfa_signature_box_shows_the_picture_the_file_holds() { let mut s = Session::new().with_clock(|| 1_700_000_000); @@ -7461,6 +7706,160 @@ fn static_xfa_forms_take_their_values_from_the_datasets_and_write_them_back() { assert!(!d2.info.warnings.iter().any(|w| w.contains("taken from this form's XFA data")), "nothing was rewritten"); } +/// A static XFA exclusion group laid out as a radio group with `/Opt [(1) (2)]` and on states +/// named by position (`/0`, `/1`; ISO 32000-2 Table 230): the data's item `1` is the first +/// button, not the second one's state `/1`, and a click stores the clicked button's item. +/// Public benefit forms are built this way; their "English" came back as "French". +#[test] +fn a_static_xfa_radio_group_with_numeric_items_keeps_the_picked_button() { + let lang = "form1[0].page1[0].lang[0]"; + let data_of = |bytes: Arc>| { + let cos = pdfcraft_cos::Document::open(bytes).unwrap(); + let data = pdfcraft_xfa::parse_datasets(&pdfcraft_xfa::read_packets(&cos).unwrap().unwrap().xdp).unwrap(); + data.text_at(&pdfcraft_xfa::som_to_path(lang)).map(str::to_owned) + }; + let states = |s: &Session, id: DocId| { + let f = s.get(id).unwrap().form.iter().find(|f| f.name == lang).unwrap().clone(); + (f.value.clone(), f.widgets.iter().map(|w| w.state.clone().unwrap_or_default()).collect::>()) + }; + let form = |data: &str| Arc::new(pdfcraft_xfa::fixtures::static_radio_shell(&format!("{data}"))); + let mut s = Session::new().with_clock(|| 1_700_000_000); + // Data filled elsewhere: item 1 (English) turns the first button on, item 2 the second. + let id = s.open("one.pdf", None, form("1"), None).unwrap(); + assert_eq!(states(&s, id), (vec!["0".to_string()], vec!["0".to_string(), "Off".to_string()])); + let id = s.open("two.pdf", None, form("2"), None).unwrap(); + assert_eq!(states(&s, id), (vec!["1".to_string()], vec!["Off".to_string(), "1".to_string()])); + // A click on a button (its on state, as the page sends it) stores that button's item. + let id = s.open("blank.pdf", None, form(""), None).unwrap(); + for (state, item) in [("0", "1"), ("1", "2")] { + s.apply(id, Edit::SetFieldValue { name: lang.into(), value: FieldValue::Radio(Some(state.into())) }).unwrap(); + let saved = s.save_bytes(id).unwrap(); + assert_eq!(data_of(saved.clone()).as_deref(), Some(item), "clicked {state}"); + // Reopened, the same button is on and nothing is rewritten. + let again = s.open("again.pdf", None, saved, None).unwrap(); + assert_eq!(states(&s, again).0, vec![state.to_string()], "clicked {state}"); + assert!(!s.get(again).unwrap().info.warnings.iter().any(|w| w.contains("taken from this form's XFA data")), "clicked {state}"); + } +} + +/// The XFA data layer keeps its own copy of the forms crate's name spelling (it can't depend on +/// forms), and a radio group's value only finds its button while the two agree: UTF-8 as +/// itself, `#` and bytes that aren't UTF-8 as `#XX`. +#[test] +fn xfa_and_forms_spell_names_alike() { + for bytes in [&b"Yes"[..], b"", b"a b", b"A#1", "はい".as_bytes(), b"\x82\xcd\x82\xa2", b"\xff#\x00", b"\x7f\x20~"] { + assert_eq!(pdfcraft_xfa::data::name_text(bytes), pdfcraft_forms::name_text(bytes), "{bytes:?}"); + assert_eq!(pdfcraft_forms::name_bytes(&pdfcraft_xfa::data::name_text(bytes)), bytes, "{bytes:?}"); + } +} + +/// Taking the XFA out of a radio group whose buttons carry `/PCOn` as well as `/Opt` (the item +/// saving writes ahead of the `/Opt` entry) notes no lost data when the data holds that item: +/// one equal to the on state, and one equal to neither the state nor the `/Opt` entry. +#[test] +fn stripping_xfa_takes_a_radio_items_pcon_as_the_fields_value() { + let lang = "form1[0].page1[0].lang[0]"; + let lost = |s: &Session, id: DocId| { + s.get(id).unwrap().info.warnings.iter().filter(|w| w.contains("held a value other than the form's XFA data")).cloned().collect::>() + }; + let mut s = Session::new().with_clock(|| 1_700_000_000); + // `/PCOn` equals the state: the data's `0` is the first button, whose `/Opt` entry is `1`. + let form = pdfcraft_xfa::fixtures::static_radio_shell_with_pcon("0", ["0", "1"]); + let id = s.open("pcon-state.pdf", None, Arc::new(form), None).unwrap(); + assert_eq!(s.get(id).unwrap().form.iter().find(|f| f.name == lang).unwrap().value, ["0"]); + s.apply(id, Edit::XfaToStatic).unwrap(); + assert_eq!(lost(&s, id), Vec::::new()); + // `/PCOn` is neither: a click writes `en` to the data for the first button. + let form = pdfcraft_xfa::fixtures::static_radio_shell_with_pcon("", ["en", "fr"]); + let id = s.open("pcon-item.pdf", None, Arc::new(form), None).unwrap(); + s.apply(id, Edit::SetFieldValue { name: lang.into(), value: FieldValue::Radio(Some("0".into())) }).unwrap(); + s.apply(id, Edit::XfaToStatic).unwrap(); + assert_eq!(lost(&s, id), Vec::::new()); + // The data names a button by its `/Opt` entry (`2`, as data from elsewhere does), while + // saving would write that button's `/PCOn` (`fr`). Opening turned that button on, so the + // field shows what the data selects and nothing is lost, although the spellings differ. + let form = pdfcraft_xfa::fixtures::static_radio_shell_with_pcon("2", ["en", "fr"]); + let id = s.open("opt-item.pdf", None, Arc::new(form), None).unwrap(); + assert_eq!(s.get(id).unwrap().form.iter().find(|f| f.name == lang).unwrap().value, ["1"]); + s.apply(id, Edit::XfaToStatic).unwrap(); + assert_eq!(lost(&s, id), Vec::::new()); +} + +/// Opening reads a radio group's data as saving writes it: with `/PCOn` items `4` `1` next to +/// `/Opt [(1) (2)]` and states `/0` `/1`, a click on the second button saves its item `1`, and +/// reopening turns the second button on again, not the first one whose `/Opt` entry is `1`; the +/// first button saves `4` and comes back too. Taking the XFA out then notes no lost data. +#[test] +fn a_radio_item_that_is_another_buttons_export_reopens_on_its_own_button() { + let lang = "form1[0].page1[0].lang[0]"; + let data_of = |bytes: Arc>| { + let cos = pdfcraft_cos::Document::open(bytes).unwrap(); + let data = pdfcraft_xfa::parse_datasets(&pdfcraft_xfa::read_packets(&cos).unwrap().unwrap().xdp).unwrap(); + data.text_at(&pdfcraft_xfa::som_to_path(lang)).map(str::to_owned) + }; + let value = |s: &Session, id: DocId| s.get(id).unwrap().form.iter().find(|f| f.name == lang).unwrap().value.clone(); + let form = Arc::new(pdfcraft_xfa::fixtures::static_radio_shell_with_pcon("", ["4", "1"])); + let mut s = Session::new().with_clock(|| 1_700_000_000); + for (state, item) in [("1", "1"), ("0", "4")] { + let id = s.open("blank.pdf", None, form.clone(), None).unwrap(); + s.apply(id, Edit::SetFieldValue { name: lang.into(), value: FieldValue::Radio(Some(state.into())) }).unwrap(); + let saved = s.save_bytes(id).unwrap(); + assert_eq!(data_of(saved.clone()).as_deref(), Some(item), "clicked {state}"); + let again = s.open("again.pdf", None, saved, None).unwrap(); + assert_eq!(value(&s, again), [state], "clicked {state}"); + s.apply(again, Edit::XfaToStatic).unwrap(); + assert!(!s.get(again).unwrap().info.warnings.iter().any(|w| w.contains("held a value other than the form's XFA data")), "clicked {state}"); + } + // Data from elsewhere that no button saves is still read as an export value, then a state. + let id = s + .open( + "two.pdf", + None, + Arc::new(pdfcraft_xfa::fixtures::static_radio_shell_with_pcon("2", ["4", "1"])), + None, + ) + .unwrap(); + assert_eq!(value(&s, id), ["1"]); + let id = s + .open( + "zero.pdf", + None, + Arc::new(pdfcraft_xfa::fixtures::static_radio_shell_with_pcon("0", ["4", "1"])), + None, + ) + .unwrap(); + assert_eq!(value(&s, id), ["0"]); +} + +/// A radio group whose buttons carry `/PCOn` items `1` `2` next to `/Opt [(1) (2)]` and on +/// states named by position (`/0`, `/1`): a click on either button saves that button's own item +/// to the datasets, reopening turns the same button on with nothing rewritten, and taking the XFA +/// out notes no lost data. The state `/1` used to match the first button's item `1`, so the +/// second button saved `1` and came back as the first. +#[test] +fn a_radio_group_with_pcon_items_saves_and_reopens_the_clicked_button() { + let lang = "form1[0].page1[0].lang[0]"; + let data_of = |bytes: Arc>| { + let cos = pdfcraft_cos::Document::open(bytes).unwrap(); + let data = pdfcraft_xfa::parse_datasets(&pdfcraft_xfa::read_packets(&cos).unwrap().unwrap().xdp).unwrap(); + data.text_at(&pdfcraft_xfa::som_to_path(lang)).map(str::to_owned) + }; + let value = |s: &Session, id: DocId| s.get(id).unwrap().form.iter().find(|f| f.name == lang).unwrap().value.clone(); + let form = Arc::new(pdfcraft_xfa::fixtures::static_radio_shell_with_pcon("", ["1", "2"])); + let mut s = Session::new().with_clock(|| 1_700_000_000); + for (state, item) in [("0", "1"), ("1", "2")] { + let id = s.open("blank.pdf", None, form.clone(), None).unwrap(); + s.apply(id, Edit::SetFieldValue { name: lang.into(), value: FieldValue::Radio(Some(state.into())) }).unwrap(); + let saved = s.save_bytes(id).unwrap(); + assert_eq!(data_of(saved.clone()).as_deref(), Some(item), "clicked {state}"); + let again = s.open("again.pdf", None, saved, None).unwrap(); + assert_eq!(value(&s, again), [state], "clicked {state}"); + assert!(!s.get(again).unwrap().info.warnings.iter().any(|w| w.contains("taken from this form's XFA data")), "clicked {state}"); + s.apply(again, Edit::XfaToStatic).unwrap(); + assert!(!s.get(again).unwrap().info.warnings.iter().any(|w| w.contains("held a value other than the form's XFA data")), "clicked {state}"); + } +} + /// A static XFA form whose values are only in its datasets takes every one on opening, as /// filling the fields one at a time would leave it: values, appearances and the note; a /// read-only field keeps its own value. @@ -7806,12 +8205,12 @@ fn xfa_data_import_adds_and_removes_rows_of_a_dynamic_form() { assert_eq!(names(&s).iter().filter(|n| n.starts_with("amount")).count(), 3, "{:?}", names(&s)); assert_eq!(value(&s, "total"), vec!["15".to_string()], "qty 3 × price 5, calculated after the import: {:?}", s.take_js_output(id)); assert_eq!(value(&s, "grand"), vec!["42.5".to_string()]); - // More rows than the layout shows: the document says so. - let rows: String = (0..60).map(|i| format!("r{i}1")).collect(); + // More rows than the layout shows (1 000 for one repeating subform): the document says so. + let rows: String = (0..1010).map(|i| format!("r{i}1")).collect(); let file = format!("
1{rows}
"); s.apply(id, Edit::ImportData { name: "many.xml".into(), bytes: Arc::new(file.into_bytes()) }).unwrap(); let out = s.take_js_output(id); - assert!(out.errors.iter().chain(out.console.iter()).any(|l| l.contains("60 rows")), "{out:?}"); + assert!(out.errors.iter().chain(out.console.iter()).any(|l| l.contains("1010 rows") && l.contains("first 1000")), "{out:?}"); // A file with one row takes the others away. let file = "
1only7
"; s.apply(id, Edit::ImportData { name: "one.xml".into(), bytes: Arc::new(file.as_bytes().to_vec()) }).unwrap(); @@ -7883,6 +8282,119 @@ fn the_xfa_open_budget_stops_the_calculations_it_has_no_time_for() { assert_eq!(pdfcraft_js::xfa_session::engines_started(), engines, "no script ran, so no engine started"); } +/// Opening lays the form out once after its initialize scripts, not after each one that +/// hides a subform (XFA 3.3 pp. 388, 390: initialize fires once data binding is complete; +/// the layout process has not run yet). Laying it out after every script spent the opening's +/// time on the first few, and a form whose initialize scripts hide its optional pages was +/// saved half initialized: the next opening hid more of it, and reset the data typed into +/// what it hid. +#[test] +fn xfa_initialize_scripts_that_hide_subforms_lay_the_form_out_once() { + // Shaped like a form with optional extra pages: a check box, then more sections than one + // event may lay the form out again for, each hiding itself while the box is clear. + let sections = 60; + let mut body = String::from( + r#"100"#, + ); + for n in 0..sections { + body.push_str(&format!( + r#""# + )); + } + let tpl = format!( + r#" + + + +"# + ); + let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&tpl)); + let mut s = Session::new().with_clock(|| 1_700_000_000).with_xfa_time_scale(100); + let id = s.open("extras.pdf", None, bytes, None).expect("opens"); + let out = s.take_js_output(id); + assert!(out.errors.is_empty(), "{:?}", out.errors); + let names = |s: &Session, id| s.get(id).unwrap().form.iter().map(|f| f.name.clone()).collect::>(); + let opened = names(&s, id); + assert!(opened.iter().all(|n| !n.contains("note")), "every extra section is hidden on opening: {opened:?}"); + assert!(opened.iter().any(|n| n.contains("wanted")), "{opened:?}"); + // User input survives repeated saves and openings, with stable field and page counts + // (strengthened by wireless89 in the review of #4791). + let wanted = opened.iter().find(|n| n.contains("wanted")).unwrap().clone(); + s.apply(id, Edit::SetFieldValue { name: wanted.clone(), value: FieldValue::Check(true) }).unwrap(); + let pages = s.get(id).unwrap().info.pages.len(); + let mut saved = s.save_bytes(id).unwrap(); + for cycle in 0..3 { + let again = s.open("again.pdf", None, saved, None).expect("reopens"); + let out = s.take_js_output(again); + assert!(out.errors.is_empty(), "cycle {cycle}: {out:?}"); + assert_eq!(names(&s, again), opened, "cycle {cycle}: stable fields"); + let doc = s.get(again).unwrap(); + assert_eq!(doc.info.pages.len(), pages, "cycle {cycle}: stable pages"); + assert_eq!(doc.form.iter().find(|f| f.name == wanted).unwrap().value, ["1"], "cycle {cycle}: input survives"); + saved = s.save_bytes(again).unwrap(); + } +} + +/// A script that resets several subforms one after another (`xfa.host.resetData` once for +/// each, as forms with optional pages do) resets every one of them, and nothing else; resets +/// in a row are applied together. +#[test] +fn xfa_resets_in_a_row_reset_every_named_subform_and_nothing_else() { + let sections = 5; + let mut body = String::from( + r#"100"#, + ); + let resets: String = (0..sections).map(|n| format!("xfa.host.resetData('form.extra{n}');")).collect(); + body.push_str(&format!( + r#""# + )); + body.push_str(r#""#); + // A field no script touches: the resets must leave what was typed in it. + body.push_str(r#"default"#); + for n in 0..sections { + body.push_str(&format!( + r#"default"# + )); + } + let notes: String = (0..sections).map(|n| format!("typed {n}")).collect(); + let tpl = format!( + r#" + + +
typedtyped other{notes}
+
+"# + ); + let mut s = Session::new().with_clock(|| 1_700_000_000).with_xfa_time_scale(100); + // Without the resets the sections show what was typed. + let untouched = Arc::new(pdfcraft_xfa::fixtures::shell(&tpl.replace(&resets, ""))); + let plain = s.open("plain.pdf", None, untouched, None).expect("opens"); + let typed: Vec<_> = s.get(plain).unwrap().form.iter().filter(|f| f.name.contains("note")).map(|f| f.value.clone()).collect(); + assert_eq!(typed, (0..sections).map(|n| vec![format!("typed {n}")]).collect::>()); + let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&tpl)); + let id = s.open("resets.pdf", None, bytes, None).expect("opens"); + let out = s.take_js_output(id); + assert!(out.errors.is_empty(), "{:?}", out.errors); + let form = &s.get(id).unwrap().form; + let notes: Vec<_> = form.iter().filter(|f| f.name.contains("note")).map(|f| f.value.clone()).collect(); + assert_eq!(notes.len(), sections, "{form:?}"); + assert!(notes.iter().all(|v| v == &vec!["default".to_string()]), "every section is reset: {notes:?}"); + let keep = form.iter().find(|f| f.name.contains("keep")).map(|f| f.value.clone()); + assert_eq!(keep, Some(vec!["kept".to_string()]), "the script's later write stands"); + let other = form.iter().find(|f| f.name.contains("other")).map(|f| f.value.clone()); + assert_eq!(other, Some(vec!["typed other".to_string()]), "a field outside the named subforms keeps its value"); +} + /// The scripts of one event share the form as the engine holds it: a failed script's /// half-done writes reach nobody, and a script reads the saved value a choice list was given /// by its shown text. @@ -8054,6 +8566,57 @@ fn repeated_xfa_print_buttons_run_their_click_script() { } } +/// A button on the page master runs its click script as the copy on the page that was clicked: +/// the field it writes next to it changes on that page only. +#[test] +fn a_page_master_button_writes_the_field_on_its_own_page() { + let template = pdfcraft_xfa::fixtures::repeated_print_button_template().replace( + "", + r#" +