diff --git a/crates/automation/src/a11y/form_tools.rs b/crates/automation/src/a11y/form_tools.rs index ae9b624035..dee8eff646 100644 --- a/crates/automation/src/a11y/form_tools.rs +++ b/crates/automation/src/a11y/form_tools.rs @@ -1,7 +1,7 @@ use super::{Args, Automation, Result, ToolError, Value, failed, json, with_warnings, write_atomic}; -/// A script's request to the viewer as the JSON that running a script reports; a side panel -/// goes by its fixed id. +/// A script's request to the viewer as the JSON that running a script reports (page numbers +/// 1-based); a side panel goes by its fixed id. fn request_json(r: &pdfcraft_engine::js::Request) -> Value { use pdfcraft_engine::js::Request as R; match r { @@ -14,6 +14,7 @@ fn request_json(r: &pdfcraft_engine::js::Request) -> Value { R::Beep => json!({ "beep": true }), R::Highlight(on) => json!({ "highlight": on }), R::SaveAs => json!({ "save_as": true }), + R::OpenAttachment { name, open } => json!({ "attachment": name, "open": open }), R::ShowPanel(p) => json!({ "panel": p.id() }), } } diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index de650aea6e..54d7eeb74a 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1396,7 +1396,7 @@ pub fn tools() -> Vec { t("form_merge_data", "Merge data files into spreadsheet", "Collect the field values of form data files (FDF, XFDF) or filled-in PDF forms into one CSV file at path: a column per field name, a row per file. Returns the row and column counts.") .cmd("form.merge_data") .with(schema(json!({ "paths": { "type": "array", "items": { "type": "string" }, "minItems": 1 }, "path": { "type": "string" } }), &["paths", "path"])), - t("js_run", "Run JavaScript", "Run Acrobat JavaScript in the document, as the JavaScript console does (or as push button `field`'s Mouse Up script when field is given; on a laid-out XFA form, `field` runs that button's XFA click script instead, JavaScript or FormCalc, which can add and remove rows and show or hide subforms). The form object model is available: this/getField, event, app, util, console, display, color, and the document-level scripts (except the PDF container scripts when XFA layout is active; they remain available through js_document_scripts). Field changes and resetForm are applied as one undoable step; returns the alerts (each with its message, title, icon and buttons, in order; every question is still answered OK), console output and requests (print, page, url, submit) of the script and of the field scripts its changes ran, the script's error, the errors of document-level scripts that failed (the rest still ran), and in warnings the errors of applying the changes, such as values of an XFA form that could not be written to its data.") + t("js_run", "Run JavaScript", "Run Acrobat JavaScript in the document, as the JavaScript console does (or as push button `field`'s Mouse Up script when field is given; on a laid-out XFA form, `field` runs that button's XFA click script instead, JavaScript or FormCalc, which can add and remove rows and show or hide subforms). The form object model is available: this/getField, event, app, util, console, display, color, and the document-level scripts (except the PDF container scripts when XFA layout is active; they remain available through js_document_scripts). Field changes and resetForm are applied as one undoable step; returns the alerts (each with its message, title, icon and buttons, in order; every question is still answered OK), console output and requests (print, page, url, submit, attachment) of the script and of the field scripts its changes ran, the script's error, the errors of document-level scripts that failed (the rest still ran), and in warnings the errors of applying the changes, such as values of an XFA form that could not be written to its data.") .cmd("tools.js_console") .with(schema( json!({ "doc": doc(), "script": { "type": "string" }, "field": { "type": "string", "description": "Run as this button's Mouse Up event." } }), diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index 31f1aed19e..d2b698a370 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -81,13 +81,13 @@ impl JsOutput { } /// Adds what a field's own script produced: its alerts, console lines, errors and requests, - /// except a request to show a side panel or open Save As, which is noted on the console - /// and dropped. + /// except a request to show a side panel, open Save As, or open or save an attached file, + /// which is noted on the console and dropped. fn absorb(&mut self, o: &Outcome) { self.alert(o.alerts.iter().cloned()); self.say(o.console.iter().cloned()); // A field's own scripts (keystroke, format, validate, calculate) run unasked: only a - // button's may show or hide a side panel, or open Save As. + // button's may show or hide a side panel, open Save As, or open or save an attached file. // The warnings go through `say` like any other console line, so they count against the // line and byte caps instead of growing the console past them. let mut notes = Vec::new(); @@ -95,6 +95,9 @@ impl JsOutput { match r { Request::ShowPanel(_) => notes.push("A field script asked to show a side panel; only a button may show a side panel".to_string()), Request::SaveAs => notes.push("A field script asked to save the document as a new file; only a button may open Save As".to_string()), + Request::OpenAttachment { name, .. } => { + notes.push(format!("A field script asked to open the attached file {name}; only a button may open an attached file")) + } r => self.requests.push(r.clone()), } } @@ -510,10 +513,12 @@ impl Session { } let doc = self.get(id).ok_or(EditError::NoDocument)?; let cos = doc.editor.as_ref().map(|e| e.cos.clone()).ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?; - // A button PdfCraft generated from an XFA template runs its XFA click script. + // A button PdfCraft generated from an XFA template runs its XFA click script (the app runs + // click scripts for buttons only, so other fields' click events don't run here either). if let Some(t) = target && let Some(tpl) = doc.xfa_template.clone() && let Some(field) = doc.form.iter().find(|f| f.name == t) + && field.kind == crate::FormFieldKind::PushButton && let Some(som) = crate::xfa::clickable_som(&cos, &tpl, field) { // The click returns only the output of its own edit; output from opening or from earlier edits stays. diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 611cd1dfb5..741730453c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -11338,6 +11338,61 @@ fn exporting_office_keeps_dotted_leader_entries_as_paragraphs() { assert!(positions.windows(2).all(|pair| pair[0] < pair[1]), "reading order: {html}"); } +/// `run_javascript` runs an XFA click script only for a push button, as the app does: a text +/// field with a click event of its own, named as the target, asks to open no attached file. +#[test] +fn run_javascript_runs_xfa_click_scripts_only_for_buttons() { + let xdp = r##" + + + +"##; + let mut s = Session::new(); + let id = s.open("guide.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(xdp)), None).expect("opens"); + s.take_js_output(id); + let o = s.run_javascript(id, "", Some("guide")).unwrap(); + assert_eq!(o.requests, [pdfcraft_js::Request::OpenAttachment { name: "Guide.pdf".into(), open: true }], "the button's click: {o:?}"); + let o = s.run_javascript(id, "", Some("note")).unwrap(); + assert!(o.requests.is_empty(), "a text field's click script doesn't run here: {o:?}"); +} + +/// In an XFA form a button's click script may open an attached file; the same request from a +/// text field's exit script is noted in the console and not made. +#[test] +fn an_xfa_button_may_open_an_attached_file_and_other_events_may_not() { + let xdp = r##" + + + +"##; + let mut s = Session::new(); + let id = s.open("guide.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(xdp)), None).expect("opens"); + s.take_js_output(id); + s.apply(id, Edit::XfaEvent { som: "form[0].guide[0]".into(), activity: "click".into() }).unwrap(); + let out = s.take_js_output(id); + assert_eq!(out.requests, [pdfcraft_js::Request::OpenAttachment { name: "Guide.pdf".into(), open: true }], "{out:?}"); + s.apply(id, Edit::SetFieldValue { name: "note".into(), value: FieldValue::Text("x".into()) }).unwrap(); + let out = s.take_js_output(id); + assert!(out.requests.is_empty(), "only a click opens a file: {out:?}"); + assert!(out.console.iter().any(|c| c.contains("open an attached file")), "{:?}", out.console); +} + /// A field whose layout:ready script only reads the page count keeps the value its script gives /// it: only a field the script gives the page call's result is numbered by the layout. #[test] diff --git a/crates/engine/src/xfa.rs b/crates/engine/src/xfa.rs index 393b997af5..9b28320a55 100644 --- a/crates/engine/src/xfa.rs +++ b/crates/engine/src/xfa.rs @@ -53,6 +53,7 @@ fn click_only(e: &XfaEffect) -> Option<&'static str> { XfaEffect::Print => Some("print"), XfaEffect::SaveAs => Some("save"), XfaEffect::LaunchUrl(_) => Some("open a link"), + XfaEffect::OpenAttachment { .. } => Some("open an attached file"), XfaEffect::ShowPanel(_) => Some("show a side panel"), XfaEffect::SetFocus(_) => Some("move the focus"), _ => None, @@ -910,6 +911,7 @@ impl<'a, 'b> Runner<'a, 'b> { XfaEffect::Print => self.run.out.requests.push(Request::Print), XfaEffect::SaveAs => self.run.out.requests.push(Request::SaveAs), XfaEffect::LaunchUrl(u) => self.run.out.requests.push(Request::LaunchUrl(u.clone())), + XfaEffect::OpenAttachment { name, open } => self.run.out.requests.push(Request::OpenAttachment { name: name.clone(), open: *open }), XfaEffect::ShowPanel(p) => self.run.out.requests.push(Request::ShowPanel(*p)), XfaEffect::SetFocus(som) => { if let Some(name) = self.by_som().get(som).cloned() { diff --git a/crates/js/src/globals.rs b/crates/js/src/globals.rs index 297e7604d1..3550257b9e 100644 --- a/crates/js/src/globals.rs +++ b/crates/js/src/globals.rs @@ -4,7 +4,7 @@ use super::host::{arg, function, host, s, text, text_line}; use super::model::{DISPLAY_HIDDEN, DISPLAY_NO_PRINT, DISPLAY_NO_VIEW, DISPLAY_VISIBLE, Event, Panel, Request}; use super::{ Attribute, Context, JsArray, JsObject, JsResult, JsString, JsValue, Message, MessageButtons, MessageIcon, NativeFunction, ObjectInitializer, - js_string, + error, js_string, }; // ── app, util, console ────────────────────────────────────────────────────────────────────── @@ -120,6 +120,36 @@ fn launch_url(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult JsResult<(String, bool)> { + let first = arg(args, 0); + // Any object of named parameters is read by key, one without `cName` too, so a missing name + // is refused rather than read as the object's text. + let named = first.as_object().filter(|o| !o.is_array() && !o.is_callable()); + let name = match &named { + Some(o) => text_line(&o.get(js_string!("cName"), ctx)?, ctx)?, + None => text_line(&first, ctx)?, + }; + if name.is_empty() { + return Err(error("exportDataObject needs cName, the attached file's name")); + } + let launch = match &named { + Some(o) => o.get(js_string!("nLaunch"), ctx)?, + None => arg(args, 3), + }; + let launch = if launch.is_undefined() || launch.is_null() { 0.0 } else { launch.to_number(ctx)? }; + Ok((name, launch == 1.0 || launch == 2.0)) +} + +/// `this.exportDataObject(…)`: asks the viewer to open or save an attached file. +fn export_data_object(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let (name, open) = data_object(args, ctx)?; + host(ctx)?.borrow_mut().request(Request::OpenAttachment { name, open }); + Ok(JsValue::undefined()) +} + /// What `app.execMenuItem(name)` asks for, for the menu items forms use: the side panels, Print /// and Save As. Other items do nothing. pub(crate) fn menu_item(name: &str) -> Option { @@ -199,11 +229,11 @@ fn color_object(ctx: &mut Context) -> JsObject { const XFA_VERSION: f64 = 3.5; /// Defines the form object model's globals: the document's functions (`getField`, `resetForm`, -/// `print`, `submitForm` and others) and properties (`numFields`, `pageNum`, `numPages`, -/// `documentFileName`, `info`, the XFA version), and the `app`, `util`, `console`, `display` and -/// `color` objects. +/// `print`, `submitForm`, `exportDataObject` and others) and properties (`numFields`, `pageNum`, +/// `numPages`, `documentFileName`, `info`, the XFA version), and the `app`, `util`, `console`, +/// `display` and `color` objects. pub(crate) fn install(ctx: &mut Context) -> JsResult<()> { - let globals: [(&str, usize, NativeFunction); 9] = [ + let globals: [(&str, usize, NativeFunction); 10] = [ ("getField", 1, NativeFunction::from_fn_ptr(get_field)), ("getNthFieldName", 1, NativeFunction::from_fn_ptr(get_nth_field_name)), ("resetForm", 1, NativeFunction::from_fn_ptr(reset_form)), @@ -213,6 +243,7 @@ pub(crate) fn install(ctx: &mut Context) -> JsResult<()> { ("calculateNow", 0, NativeFunction::from_fn_ptr(noop)), ("syncAnnotScan", 0, NativeFunction::from_fn_ptr(noop)), ("dirty", 0, NativeFunction::from_fn_ptr(noop)), + ("exportDataObject", 4, NativeFunction::from_fn_ptr(export_data_object)), ]; for (name, len, f) in globals { ctx.register_global_callable(JsString::from(name), len, f)?; diff --git a/crates/js/src/host.rs b/crates/js/src/host.rs index 1419ba2a86..68d581f093 100644 --- a/crates/js/src/host.rs +++ b/crates/js/src/host.rs @@ -68,6 +68,7 @@ impl Host { let bytes = match &r { Request::Reset(names) => names.iter().fold(0usize, |n, s| n.saturating_add(s.len()).saturating_add(std::mem::size_of::())), Request::LaunchUrl(u) | Request::Submit(u) | Request::Focus(u) => u.len(), + Request::OpenAttachment { name, .. } => name.len(), _ => 0, }; let kept = self.room(bytes); diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index 0bf4032efd..7948c06120 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -8,7 +8,7 @@ //! - `event`: `value`, `change`, `willCommit`, `rc`, `name`, `type`, `target`, `targetName`. //! - the document (`this`, also the global object): `getField`, `numFields`, //! `getNthFieldName`, `numPages`, `pageNum`, `documentFileName`, `info`, `resetForm`, -//! `calculateNow`, `print`, `submitForm`, `mailDoc`. +//! `calculateNow`, `print`, `submitForm`, `mailDoc`, `exportDataObject`. //! - Field objects: `name`, `type`, `value`, `valueAsString`, `defaultValue`, `readonly`, //! `required`, `display`, `hidden`, `numItems`, `getItemAt`, `currentValueIndices`, //! `isBoxChecked`, `checkThisBox`, `exportValues`, `charLimit`, `textColor`, `fillColor`, diff --git a/crates/js/src/model.rs b/crates/js/src/model.rs index d3195e6a5a..86a6945846 100644 --- a/crates/js/src/model.rs +++ b/crates/js/src/model.rs @@ -241,6 +241,12 @@ pub enum Request { Beep, /// `app.execMenuItem("SaveAs")` (XFA forms' Save buttons). SaveAs, + /// `this.exportDataObject(cName, …, nLaunch)`: the attached file `name`, to open (`nLaunch` + /// 1 or 2) or to save (0). + OpenAttachment { + name: String, + open: bool, + }, /// `app.runtimeHighlight = on`: highlight the form's fields, or stop. Highlight(bool), /// `app.execMenuItem("ShowHide…")`: show the side panel, or hide it when it is showing. diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index 3b2bafbd34..c726f78af9 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -1948,6 +1948,61 @@ fn shared_field_snapshots_isolate_writes_errors_and_script_globals() { assert!(read.changed.is_empty()); } +mod data_objects { + use super::*; + use crate::xfa::{XfaDoc, XfaEffect, XfaEvent, run_xfa}; + + /// `exportDataObject`, as a document method or a bare global, asks to open the named attached + /// file when `nLaunch` is 1 or 2 and to save it otherwise; without `cName` it is an error. + #[test] + fn export_data_object_asks_to_open_or_save_an_attached_file() { + let o = go( + "this.exportDataObject({cName: 'Guide.pdf', nLaunch: 2}); exportDataObject('notes.txt'); \ + this.exportDataObject({cName: 'form.pdf', nLaunch: 1});", + &Event::field("Mouse Up", "total", ""), + ); + assert_eq!(o.error, None); + assert_eq!( + o.requests, + [ + Request::OpenAttachment { name: "Guide.pdf".into(), open: true }, + Request::OpenAttachment { name: "notes.txt".into(), open: false }, + Request::OpenAttachment { name: "form.pdf".into(), open: true }, + ] + ); + let o = go("this.exportDataObject({nLaunch: 2})", &Event::field("Mouse Up", "total", "")); + assert!(o.error.as_deref().is_some_and(|e| e.contains("cName")), "{:?}", o.error); + assert!(o.requests.is_empty()); + } + + /// Only `nLaunch` 1 and 2 open the file: other values (1.5, 3, a huge number) save it, as the + /// default 0 does. + #[test] + fn export_data_object_opens_only_for_n_launch_1_and_2() { + for launch in ["1.5", "3", "1e9", "-1"] { + let o = go(&format!("this.exportDataObject({{cName: 'Guide.pdf', nLaunch: {launch}}});"), &Event::field("Mouse Up", "total", "")); + assert_eq!(o.requests, [Request::OpenAttachment { name: "Guide.pdf".into(), open: false }], "nLaunch {launch}"); + } + } + + /// In an XFA script `event.target` is the document, so `exportDataObject` works on it, while + /// `xfa.event.target` is still the node the event happened on. + #[test] + fn in_xfa_scripts_event_target_is_the_document_and_xfa_event_target_the_node() { + let event = XfaEvent { activity: "click".into(), target: "form1[0].page1[0].go[0]".into(), ..Default::default() }; + let o = run_xfa( + "event.target.exportDataObject({cName: 'Guide.pdf', nLaunch: 2}); xfa.event.target.name", + &event, + &XfaDoc::default(), + &super::xfa_model::form(), + Limits::default(), + ); + assert_eq!(o.error, None, "{o:?}"); + assert_eq!(o.result.as_deref(), Some("go"), "xfa.event.target is still the button"); + assert!(o.effects.contains(&XfaEffect::OpenAttachment { name: "Guide.pdf".into(), open: true }), "{:?}", o.effects); + } +} + mod runtime_highlight { use super::*; use crate::xfa::{XfaDoc, XfaEffect, XfaEvent, XfaNode, run_xfa}; diff --git a/crates/js/src/xfa.rs b/crates/js/src/xfa.rs index 085262351e..ec8d7cc24d 100644 --- a/crates/js/src/xfa.rs +++ b/crates/js/src/xfa.rs @@ -15,6 +15,7 @@ use boa_engine::object::builtins::{JsArray, JsProxy}; use boa_engine::property::Attribute; use boa_engine::{Context, JsObject, JsResult, JsString, JsValue, NativeFunction, Source, js_string}; +use crate::globals::data_object; use crate::{Limits, Message, arg, as_number, error, function, platform, printd, printf, printx, refuse, s, scand, text}; mod globals; diff --git a/crates/js/src/xfa/globals.rs b/crates/js/src/xfa/globals.rs index 50647a3426..de57d7fd49 100644 --- a/crates/js/src/xfa/globals.rs +++ b/crates/js/src/xfa/globals.rs @@ -3,8 +3,8 @@ use super::instances::instance_manager; use super::model::{MAX_DEPTH, XfaDoc, XfaEffect, XfaEvent, clip, payload, reset_names}; use super::nodes::{node_index, node_object, noop, resolve_som, resolver, sink}; use super::{ - Attribute, Context, JsResult, JsString, JsValue, NativeFunction, ObjectInitializer, arg, function, js_string, platform, printd, printf, printx, - s, scand, text, + Attribute, Context, JsResult, JsString, JsValue, NativeFunction, ObjectInitializer, arg, data_object, function, js_string, platform, printd, + printf, printx, s, scand, text, }; // ── xfa.host, xfa.layout, xfa.event, app, console ─────────────────────────────────────────── @@ -15,6 +15,13 @@ fn effect(ctx: &mut Context, e: XfaEffect) -> JsResult<()> { Ok(()) } +/// `event.target.exportDataObject(…)`: the document asks to open or save an attached file. +fn export_data_object(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let (name, open) = data_object(args, ctx)?; + effect(ctx, XfaEffect::OpenAttachment { name, open })?; + Ok(JsValue::undefined()) +} + /// `app.runtimeHighlight`: whether the viewer highlights form fields. fn runtime_highlight(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { Ok(JsValue::from(host(ctx)?.borrow().doc.highlight)) @@ -67,13 +74,13 @@ fn print(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { Ok(JsValue::undefined()) } -/// `xfa.host.beep()`: asks to beep. +/// `xfa.host.beep()` and `app.beep()`: ask to beep. fn beep(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { effect(ctx, XfaEffect::Beep)?; Ok(JsValue::undefined()) } -/// `xfa.host.gotoURL(url)`: asks to open the URL. +/// `xfa.host.gotoURL(url)` and `app.launchURL(url)`: ask to open the URL. fn goto_url(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let u = text(&arg(args, 0), ctx)?; effect(ctx, XfaEffect::LaunchUrl(u))?; @@ -114,50 +121,63 @@ fn exec_menu_item(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult< Ok(JsValue::undefined()) } +/// `recalculate(…)` on a node or on `xfa`: records a request to recalculate the form. pub(super) fn recalculate(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { effect(ctx, XfaEffect::Recalculate)?; Ok(JsValue::undefined()) } +/// `xfa.layout.relayout()`, `relayoutPageArea()` and `remerge()`: record a request to lay the +/// form out again. pub(super) fn relayout(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { effect(ctx, XfaEffect::Relayout)?; Ok(JsValue::undefined()) } +/// `console.println(…)`: adds the line, cut to [`MAX_LINE`] characters, to the run's console +/// output. fn console_println(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let m = clip(text(&arg(args, 0), ctx)?); host(ctx)?.borrow_mut().print(m); Ok(JsValue::undefined()) } +/// `xfa.host.response(…)` and `app.response(…)`: always return `null`, as for a canceled dialog. fn response(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { Ok(JsValue::null()) } +/// `xfa.layout.page(…)`: the current page, 1-based, whatever the argument. fn layout_page(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { Ok(JsValue::from(host(ctx)?.borrow().doc.page as f64 + 1.0)) } +/// `xfa.layout.absPage(…)`: the current page, 0-based, whatever the argument. fn layout_page0(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { Ok(JsValue::from(host(ctx)?.borrow().doc.page as f64)) } +/// `xfa.layout.pageCount()` and `absPageCount()`: the document's page count. fn layout_page_count(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { Ok(JsValue::from(host(ctx)?.borrow().doc.page_count as f64)) } +/// The `xfa.layout` measures `h`, `w`, `x`, `y` and `pageSpan`: always 0. fn zero(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { Ok(JsValue::from(0.0)) } +/// `xfa.resolveNode(som)`, or `xfa.resolveNodes(som)` when `many`, resolving from the node the +/// script runs on. fn xfa_resolve(ctx: &mut Context, many: bool) -> JsResult { let current = host(ctx)?.borrow().current; resolver(ctx, current, many) } /// Defines the globals for one script on node `current`: `xfa` (`form`, `host`, `layout`, `event`, -/// `resolveNode` and more), `event` as the same object as `xfa.event`, `app`, `console`, `util`, -/// and the nodes in scope by name, with `_name` for each repeatable one's instance manager. +/// `resolveNode` and more; `xfa.event`'s target is that node), a separate global `event` whose +/// target is a document offering `exportDataObject`, `app`, `console`, `util`, and the nodes in +/// scope by name, with `_name` for each repeatable one's instance manager. pub(super) fn install(ctx: &mut Context, event: &XfaEvent, doc: &XfaDoc, current: usize) -> JsResult<()> { let rw = Attribute::CONFIGURABLE; let form = node_object(ctx, 0)?; @@ -207,24 +227,33 @@ pub(super) fn install(ctx: &mut Context, event: &XfaEvent, doc: &XfaDoc, current .function(NativeFunction::from_fn_ptr(zero), js_string!("pageSpan"), 1) .property(js_string!("ready"), JsValue::from(true), Attribute::READONLY) .build(); - let ev = ObjectInitializer::new(ctx) - .property(js_string!("name"), s(&event.activity), Attribute::all()) - .property(js_string!("newText"), s(&event.new_text), Attribute::all()) - .property(js_string!("prevText"), s(&event.prev_text), Attribute::all()) - .property(js_string!("fullText"), s(&event.new_text), Attribute::all()) - .property(js_string!("change"), s(""), Attribute::all()) - .property(js_string!("target"), this_node.clone(), Attribute::all()) - .property(js_string!("selStart"), JsValue::from(0.0), Attribute::all()) - .property(js_string!("selEnd"), JsValue::from(0.0), Attribute::all()) - .property(js_string!("cancelAction"), JsValue::from(false), Attribute::all()) - .property(js_string!("reenter"), JsValue::from(false), Attribute::all()) - .property(js_string!("shift"), JsValue::from(false), Attribute::all()) - .property(js_string!("modifier"), JsValue::from(false), Attribute::all()) - .property(js_string!("keyDown"), JsValue::from(false), Attribute::all()) - .property(js_string!("commitKey"), JsValue::from(0.0), Attribute::all()) - .function(NativeFunction::from_fn_ptr(noop), js_string!("emit"), 0) - .function(NativeFunction::from_fn_ptr(noop), js_string!("reset"), 0) - .build(); + // Two event objects, as in Acrobat's XFA scripting: `xfa.event`, whose target is the form + // node the event happened on, and Acrobat's own `event`, whose target is the document + // (`event.target.exportDataObject(…)` is how forms open their attached files). + let acrobat_doc = + ObjectInitializer::new(ctx).function(NativeFunction::from_fn_ptr(export_data_object), js_string!("exportDataObject"), 4).build(); + let event_object = |ctx: &mut Context, target: JsValue| { + ObjectInitializer::new(ctx) + .property(js_string!("name"), s(&event.activity), Attribute::all()) + .property(js_string!("newText"), s(&event.new_text), Attribute::all()) + .property(js_string!("prevText"), s(&event.prev_text), Attribute::all()) + .property(js_string!("fullText"), s(&event.new_text), Attribute::all()) + .property(js_string!("change"), s(""), Attribute::all()) + .property(js_string!("target"), target, Attribute::all()) + .property(js_string!("selStart"), JsValue::from(0.0), Attribute::all()) + .property(js_string!("selEnd"), JsValue::from(0.0), Attribute::all()) + .property(js_string!("cancelAction"), JsValue::from(false), Attribute::all()) + .property(js_string!("reenter"), JsValue::from(false), Attribute::all()) + .property(js_string!("shift"), JsValue::from(false), Attribute::all()) + .property(js_string!("modifier"), JsValue::from(false), Attribute::all()) + .property(js_string!("keyDown"), JsValue::from(false), Attribute::all()) + .property(js_string!("commitKey"), JsValue::from(0.0), Attribute::all()) + .function(NativeFunction::from_fn_ptr(noop), js_string!("emit"), 0) + .function(NativeFunction::from_fn_ptr(noop), js_string!("reset"), 0) + .build() + }; + let ev = event_object(ctx, this_node.clone()); + let acrobat_ev = event_object(ctx, acrobat_doc.into()); let datasets = ObjectInitializer::new(ctx).property(js_string!("data"), form.clone(), rw).build(); let template = sink(ctx)?; let resolve_one = xfa_resolve(ctx, false)?; @@ -243,7 +272,7 @@ pub(super) fn install(ctx: &mut Context, event: &XfaEvent, doc: &XfaDoc, current .function(NativeFunction::from_fn_ptr(relayout), js_string!("remerge"), 0) .build(); ctx.register_global_property(js_string!("xfa"), xfa, rw)?; - ctx.register_global_property(js_string!("event"), ev, rw)?; + ctx.register_global_property(js_string!("event"), acrobat_ev, rw)?; ctx.register_global_property(js_string!("__xfa_this"), this_node, rw)?; let highlight_get = function(ctx, NativeFunction::from_fn_ptr(runtime_highlight)); let highlight_set = function(ctx, NativeFunction::from_fn_ptr(set_runtime_highlight)); diff --git a/crates/js/src/xfa/model.rs b/crates/js/src/xfa/model.rs index 11468c04b3..212c2dd38c 100644 --- a/crates/js/src/xfa/model.rs +++ b/crates/js/src/xfa/model.rs @@ -104,6 +104,11 @@ pub enum XfaEffect { MessageBox(Message), /// `xfa.host.resetData()`: the named fields, or every field when empty. ResetData(Vec), + /// `event.target.exportDataObject(…)`: the attached file `name`, to open or to save. + OpenAttachment { + name: String, + open: bool, + }, /// `app.execMenuItem("ShowHide…")`: show or hide a side panel. ShowPanel(crate::Panel), Print, @@ -217,6 +222,7 @@ pub(super) fn payload(e: &XfaEffect) -> usize { som.len().saturating_add(s.len()) } XfaEffect::AddInstance { som } | XfaEffect::RemoveInstance { som } => som.len(), + XfaEffect::OpenAttachment { name, .. } => name.len(), XfaEffect::MessageBox(m) => m.bytes(), XfaEffect::LaunchUrl(s) | XfaEffect::SetFocus(s) => s.len(), XfaEffect::ResetData(names) => names.iter().fold(0usize, |n, s| n.saturating_add(s.len()).saturating_add(std::mem::size_of::())), diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index 3f53813222..20e67b756a 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -484,7 +484,9 @@ impl PdfCraftApp { } } - /// Save an attachment to disk, or open a PDF attachment in a new tab. + /// Save an attachment to disk, or open a PDF attachment in a new tab (or go to the tab it is + /// already open in). Only bytes that are a PDF open, whatever the file's name says; anything + /// else asked to open is offered to save. pub fn attachment_action(&mut self, doc: DocId, index: usize, open: bool) { let Some(d) = self.session.get(doc) else { return }; let Some(att) = d.info.attachments.get(index).cloned() else { return }; @@ -493,13 +495,30 @@ impl PdfCraftApp { let source = d.path.clone(); match (data, open) { (Err(e), _) => self.notify_fmt("Couldn't read {name}: {e}", &[("name", &att.name), ("e", &e.to_string())]), - (Ok(bytes), true) => { + (Ok(bytes), true) if looks_like_pdf(&bytes) => { // The tab name later names files written from the tab (Split, Extract pages). - if let Err(e) = self.open_bytes(&crate::files::attachment_file_name(&att.name), None, bytes) { - self.notify_fmt("Couldn't open {name}: {e}", &[("name", &att.name), ("e", &e.to_string())]); + let name = crate::files::attachment_file_name(&att.name); + // Already open: the tab opened from this attachment of this document (its bytes + // may have grown since, as a form laid out on open appends its layout). + let origin = (doc, att.name.clone()); + let open_in = self.views.iter().position(|v| v.from_attachment.as_ref() == Some(&origin) && self.session.get(v.id).is_some()); + if let Some(i) = open_in { + self.active = Some(i); + return; + } + let before = self.views.len(); + match self.open_bytes(&name, None, bytes) { + // Opening bytes adds their tab at once (unless a password is asked for first). + Ok(()) if self.views.len() > before => { + if let Some(v) = self.views.last_mut() { + v.from_attachment = Some(origin); + } + } + Ok(()) => {} + Err(e) => self.notify_fmt("Couldn't open {name}: {e}", &[("name", &att.name), ("e", &e.to_string())]), } } - (Ok(bytes), false) => { + (Ok(bytes), _) => { #[cfg(not(target_arch = "wasm32"))] self.ask_one( pickers::Ask::Save(rfd::AsyncFileDialog::new().set_file_name(crate::files::attachment_file_name(&att.name))), @@ -1028,3 +1047,8 @@ impl PdfCraftApp { } } } + +/// Whether `bytes` are a PDF: a `%PDF-` header within the first 1024 bytes, as readers accept. +fn looks_like_pdf(bytes: &[u8]) -> bool { + bytes.get(..1024).unwrap_or(bytes).windows(5).any(|w| w == b"%PDF-") +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index bca9e13016..587917cef9 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -483,6 +483,9 @@ pub struct DocView { /// views of one document apart (`crate::panes`). pub(crate) pane: crate::panes::ViewPane, pub id: DocId, + /// The document and attached file this tab was opened from, when a form or the Attachments + /// panel opened it: asking for that attachment again comes back to this tab. + pub from_attachment: Option<(DocId, String)>, pub zoom: f32, pub fit: Fit, pub layout: PageLayout, @@ -1012,6 +1015,7 @@ impl DocView { Self { pane: Default::default(), id, + from_attachment: None, zoom: defaults.zoom, fit: defaults.fit, layout: defaults.layout, diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index f156285f89..b389ca431d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -26,7 +26,7 @@ pub(super) mod word_count_ui; #[cfg(test)] mod tests; -pub(crate) use prompts::{PromptButton, save_prompt_key}; +pub(crate) use prompts::{PromptButton, save_prompt_key, shorten_middle}; use prompts::{full_screen_prompt, link_prompt, password, remote_link_prompt, save_prompt, unlock}; const INFO_KEYS: [&str; 4] = ["Title", "Author", "Subject", "Keywords"]; diff --git a/crates/ui-egui/src/dialogs/prompts.rs b/crates/ui-egui/src/dialogs/prompts.rs index 4b69afbfb6..b1aa162790 100644 --- a/crates/ui-egui/src/dialogs/prompts.rs +++ b/crates/ui-egui/src/dialogs/prompts.rs @@ -130,7 +130,7 @@ pub(super) fn save_prompt(app: &mut PdfCraftApp, ctx: &egui::Context) { /// `name` cut to at most `max` characters by replacing its middle with "…", keeping the start and /// the end, where the extension and version suffixes sit. Counts `char`s, so it never splits one. -fn shorten_middle(name: &str, max: usize) -> String { +pub(crate) fn shorten_middle(name: &str, max: usize) -> String { let count = name.chars().count(); if count <= max { return name.to_string(); diff --git a/crates/ui-egui/src/workflows/js_ui.rs b/crates/ui-egui/src/workflows/js_ui.rs index 5033fcd51c..2ac0d7b127 100644 --- a/crates/ui-egui/src/workflows/js_ui.rs +++ b/crates/ui-egui/src/workflows/js_ui.rs @@ -82,6 +82,7 @@ impl PdfCraftApp { &[("u", &u)], ), Request::SaveAs => self.run_command("file.save_as"), + Request::OpenAttachment { name, open } => self.open_script_attachment(id, &name, open), Request::Highlight(on) => self.script_highlight(id, on), Request::ShowPanel(p) => { let panel = match p { @@ -114,6 +115,23 @@ impl PdfCraftApp { } } + /// A form button's `exportDataObject`: the attached file `name` (its key in the document's + /// embedded files, else its file name), opened in a new tab when the script asks to open a + /// PDF, and otherwise offered to save. Other files aren't launched in other programs. + fn open_script_attachment(&mut self, id: DocId, name: &str, open: bool) { + let Some(doc) = self.session.get(id) else { return }; + let keyed = |a: &pdfcraft_render::Attachment| matches!(&a.source, pdfcraft_render::AttachmentSource::Document { key } if key == name); + let index = doc.info.attachments.iter().position(keyed).or_else(|| doc.info.attachments.iter().position(|a| a.name == name)); + let Some(i) = index else { + // The name is the form's own text: shortened, as file names are elsewhere. + let shown = crate::dialogs::shorten_middle(name, 80); + self.notify_fmt("The form asks for the attached file {name}, which this document doesn't have.", &[("name", &shown)]); + return; + }; + // Whether it is a PDF is decided by its bytes, not its name (`attachment_action`). + self.attachment_action(id, i, open); + } + /// A form's script turned field highlighting on or off (`app.runtimeHighlight`): as the /// Highlight fields button does, for document `id` and the next ones. pub(crate) fn script_highlight(&mut self, id: DocId, on: bool) { diff --git a/crates/ui-egui/tests/it/forms.rs b/crates/ui-egui/tests/it/forms.rs index ff302aa3c4..31595def46 100644 --- a/crates/ui-egui/tests/it/forms.rs +++ b/crates/ui-egui/tests/it/forms.rs @@ -1766,6 +1766,120 @@ fn mode_tabs_keep_the_hand_over_a_text_field_scrolled_under_them() { assert_eq!(h.state().mode, pdfcraft_ui_egui::Mode::Convert, "the tab takes the click, not the field's editor"); } +/// The one-page PDF `guide_fixture` attaches as `Guide.pdf`. +const GUIDE_INNER: &str = "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF"; + +/// A page with a push button `go`, and three attached files: a one-page PDF filed under the key +/// `Guide.pdf` (its own file name `inner.pdf`), `notes.txt`, and `fake.pdf`, which holds text. +fn guide_fixture() -> Vec { + let inner = GUIDE_INNER; + let objs = [ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] >> /Names << /EmbeddedFiles << /Names [(Guide.pdf) 5 0 R (fake.pdf) 9 0 R (notes.txt) 7 0 R] >> >> >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [4 0 R] >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (go) /Rect [20 20 120 50] /P 3 0 R >>".into(), + "<< /Type /Filespec /F (inner.pdf) /UF (inner.pdf) /EF << /F 6 0 R >> >>".into(), + format!("<< /Type /EmbeddedFile /Length {} >>\nstream\n{inner}\nendstream", inner.len()), + "<< /Type /Filespec /F (notes.txt) /UF (notes.txt) /EF << /F 8 0 R >> >>".into(), + "<< /Type /EmbeddedFile /Length 5 >>\nstream\nhello\nendstream".into(), + "<< /Type /Filespec /F (fake.pdf) /UF (fake.pdf) /EF << /F 10 0 R >> >>".into(), + "<< /Type /EmbeddedFile /Length 12 >>\nstream\nnot a PDF at\nendstream".into(), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +/// An attachment opens in a tab only when its bytes are a PDF, whatever its name says: a +/// `fake.pdf` holding text is offered to save instead. A request goes back to the tab opened from +/// that attachment, and not to another open PDF of the same name whose bytes start with it. +#[test] +fn a_form_button_opens_only_a_real_pdf_and_returns_to_the_tab_it_opened() { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("form.pdf", None, guide_fixture()).unwrap(); + // Another document named like the attachment, its bytes the attachment's and an update. + app.open_bytes("inner.pdf", None, format!("{GUIDE_INNER}\n% updated later\n").into_bytes()).unwrap(); + app.active = Some(0); + app + }); + h.run_steps(4); + let tabs = |h: &Harness<'static, PdfCraftApp>| h.state().views.len(); + let id = h.state().views[0].id; + // A text file named .pdf is offered to save, not opened. + let saved = std::env::temp_dir().join(format!("pdfcraft-fake-{}.pdf", std::process::id())); + let _ = std::fs::remove_file(&saved); + h.state_mut().pick_override = Some(vec![saved.to_string_lossy().into_owned()]); + h.state_mut().run_button_script(id, "go", "this.exportDataObject({cName: 'fake.pdf', nLaunch: 2});"); + h.run_steps(6); + assert_eq!(tabs(&h), 2, "no tab for a file that isn't a PDF: {:?}", h.state().toast); + assert_eq!(std::fs::read(&saved).ok().as_deref(), Some(&b"not a PDF at"[..]), "offered to save instead"); + let _ = std::fs::remove_file(&saved); + // The real attached PDF opens in a tab of its own, though another inner.pdf starts with it. + h.state_mut().active = Some(0); + h.state_mut().run_button_script(id, "go", "this.exportDataObject({cName: 'Guide.pdf', nLaunch: 2});"); + h.run_steps(4); + assert_eq!(tabs(&h), 3, "a tab of its own"); + assert_eq!(h.state().active, Some(2)); + // Asked again, it goes back to the tab it opened. + h.state_mut().active = Some(0); + h.state_mut().run_button_script(id, "go", "this.exportDataObject({cName: 'Guide.pdf', nLaunch: 2});"); + h.run_steps(4); + assert_eq!((tabs(&h), h.state().active), (3, Some(2)), "the tab it opened, not the other inner.pdf"); +} + +/// A button script's `exportDataObject` opens the attached PDF in a new tab, goes back to that +/// tab when asked again, and names a missing file in a toast, a long name shortened. +#[test] +fn a_form_button_opens_its_attached_instructions_in_a_new_tab() { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("form.pdf", None, guide_fixture()).unwrap(); + app + }); + h.run_steps(4); + let tabs = |h: &Harness<'static, PdfCraftApp>| { + let s = h.state(); + s.views.iter().filter_map(|v| s.session.get(v.id)).map(|d| d.name.clone()).collect::>() + }; + let id = h.state().views[0].id; + // By its key in the document's attached files, as forms name it. + h.state_mut().run_button_script(id, "go", "this.exportDataObject({cName: 'Guide.pdf', nLaunch: 2});"); + h.run_steps(4); + assert_eq!(tabs(&h), ["form.pdf", "inner.pdf"], "the attached PDF opens in its own tab"); + // Asked again from the form, it goes back to that tab rather than opening another. + h.state_mut().active = Some(0); + h.state_mut().run_button_script(id, "go", "this.exportDataObject({cName: 'Guide.pdf', nLaunch: 2});"); + h.run_steps(4); + assert_eq!(tabs(&h), ["form.pdf", "inner.pdf"], "no second copy"); + assert_eq!(h.state().active, Some(1), "its tab is shown"); + // A file the document doesn't have is said so. + h.state_mut().run_button_script(id, "go", "this.exportDataObject({cName: 'nope.pdf', nLaunch: 2});"); + h.run_steps(2); + assert!(h.state().toast.as_ref().is_some_and(|t| t.0.contains("nope.pdf")), "{:?}", h.state().toast); + assert_eq!(tabs(&h).len(), 2); + // A name of the form's own is shortened in the message, keeping its end. (Past a line's + // length, the script engine cuts it first, as it cuts every name a script passes.) + h.state_mut().run_button_script(id, "go", "this.exportDataObject({cName: new Array(3001).join('x') + '.pdf', nLaunch: 2});"); + h.run_steps(2); + let toast = h.state().toast.as_ref().map(|t| t.0.clone()).unwrap_or_default(); + assert!(toast.contains('…') && toast.contains("x.pdf"), "{toast}"); + assert!(toast.chars().count() < 200, "{} characters", toast.chars().count()); +} + /// Two combo boxes of Apple and Banana: `fruit` editable (Combo and Edit, ISO 32000-2 Table 233), /// `veg` a drop-down list only. fn combo_form() -> Vec { diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 030b05a1f2..dcc85ef62b 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5363,8 +5363,8 @@ milestone = "M6" status = "partial" commands = ["tools.js_console"] tools = ["js_run"] -evidence = ["crates/js/src/tests.rs::calculate_scripts_read_fields_and_set_the_value", "crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats", "crates/automation/tests/it/automation.rs::javascript_through_tools", "crates/js/src/tests.rs::an_xfa_form_sees_the_xfa_plugin_installed", "crates/js/src/tests.rs::a_plain_form_sees_the_xfa_version_but_not_installed", "crates/engine/src/tests.rs::scripts_in_an_xfa_form_see_the_xfa_plugin_installed", "crates/js/src/tests.rs::scand_allows_separators_between_fields_but_nothing_before_or_after_them", "crates/js/src/tests.rs::app_alert_keeps_its_title_icon_and_buttons", "crates/js/src/tests.rs::app_alert_reads_the_object_form", "crates/js/src/tests.rs::app_alert_falls_back_on_bad_icon_and_button_numbers", "crates/js/src/tests.rs::app_alert_titles_are_clipped_and_charged", "crates/ui-egui/tests/it/javascript.rs::every_message_a_script_shows_takes_the_toast_in_turn", "crates/js/src/tests.rs::widget_rect_reads_and_writes_a_four_number_array", "crates/engine/src/tests.rs::javascript_field_rect_moves_first_widget_and_undoes"] -notes = "boa (pure Rust) with the forms object model: event, getField/Field (value, readonly, display, colours, items, check boxes, rect), app (with execMenuItem for the side panels and Save As, from a button only, and Print where this.print() works), util.printf/printd/printx/scand (scand reads dates as another viewer was observed to, in AcroForm and XFA scripts), console, display, color; xfa_version (3.5) in every document and xfa_installed in XFA forms, so their version check passes. field.rect reads and writes the first widget in PDF user space. no annotations, layers or dialogs (app.response returns null) yet, and no app.findComponent. app.alert keeps its title, icon (error, warning, question, status) and buttons (OK, OK/Cancel, Yes/No, Yes/No/Cancel), given in order or as one object; js_run reports them with each message, and the app shows every message in turn in the toast with its title and icon. Still missing: answers. Every app.alert returns 1 (OK), so a script is never told No or Cancel (#4291)." +evidence = ["crates/js/src/tests.rs::calculate_scripts_read_fields_and_set_the_value", "crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats", "crates/automation/tests/it/automation.rs::javascript_through_tools", "crates/js/src/tests.rs::an_xfa_form_sees_the_xfa_plugin_installed", "crates/js/src/tests.rs::a_plain_form_sees_the_xfa_version_but_not_installed", "crates/engine/src/tests.rs::scripts_in_an_xfa_form_see_the_xfa_plugin_installed", "crates/js/src/tests.rs::scand_allows_separators_between_fields_but_nothing_before_or_after_them", "crates/ui-egui/tests/it/forms.rs::a_form_button_opens_its_attached_instructions_in_a_new_tab", "crates/js/src/tests.rs::app_alert_keeps_its_title_icon_and_buttons", "crates/js/src/tests.rs::app_alert_reads_the_object_form", "crates/js/src/tests.rs::app_alert_falls_back_on_bad_icon_and_button_numbers", "crates/js/src/tests.rs::app_alert_titles_are_clipped_and_charged", "crates/ui-egui/tests/it/javascript.rs::every_message_a_script_shows_takes_the_toast_in_turn", "crates/js/src/tests.rs::widget_rect_reads_and_writes_a_four_number_array", "crates/engine/src/tests.rs::javascript_field_rect_moves_first_widget_and_undoes"] +notes = "boa (pure Rust) with the forms object model: event, getField/Field (value, readonly, display, colours, items, check boxes, rect), app (with execMenuItem for the side panels and Save As, from a button only, and Print where this.print() works), util.printf/printd/printx/scand (scand reads dates as another viewer was observed to, in AcroForm and XFA scripts), console, display, color, exportDataObject (from a button only: an attached PDF opens in a new tab, other files are offered to save, also as event.target in XFA scripts); xfa_version (3.5) in every document and xfa_installed in XFA forms, so their version check passes. field.rect reads and writes the first widget in PDF user space. no annotations, layers or dialogs (app.response returns null) yet, and no app.findComponent. app.alert keeps its title, icon (error, warning, question, status) and buttons (OK, OK/Cancel, Yes/No, Yes/No/Cancel), given in order or as one object; js_run reports them with each message, and the app shows every message in turn in the toast with its title and icon. Still missing: answers. Every app.alert returns 1 (OK), so a script is never told No or Cancel (#4291)." [[feature]] id = "form.js-console"