Merge PR #3580: M6: form buttons show the side panels (app.execMenuItem)

crates/js/src/lib.rs: module doc app/util list has execMenuItem and scand; install() doc comment from main.
crates/js/src/tests.rs, crates/engine/src/tests.rs: both sides' appended tests kept.
parity/acrobat-features.toml: js notes: execMenuItem wording plus scand wording.
This commit is contained in:
aiongg
2026-10-24 06:23:47 +00:00
10 changed files with 261 additions and 9 deletions
+68 -1
View File
@@ -13,7 +13,7 @@
//! `required`, `display`, `hidden`, `numItems`, `getItemAt`, `currentValueIndices`,
//! `isBoxChecked`, `checkThisBox`, `exportValues`, `charLimit`, `textColor`, `fillColor`,
//! `setFocus`.
//! - `app` (`alert`, `beep`, `response`, `launchURL`, `viewerType`, `viewerVersion`,
//! - `app` (`alert`, `beep`, `response`, `launchURL`, `execMenuItem`, `viewerType`, `viewerVersion`,
//! `platform`, `setTimeOut`), `util` (`printf`, `printd`, `printx`, `scand`), `console` (`println`,
//! `show`, `clear`), `display` and `color`.
//!
@@ -164,6 +164,36 @@ pub enum Request {
Beep,
/// `app.execMenuItem("SaveAs")` (XFA forms' Save buttons).
SaveAs,
/// `app.execMenuItem("ShowHide…")`: show the side panel, or hide it when it is showing.
ShowPanel(Panel),
}
/// A side panel a script can show or hide (`app.execMenuItem("ShowHideFileAttachment")`, …).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Panel {
/// The document's bookmarks (outline).
Bookmarks,
/// Page thumbnails.
Pages,
/// The files attached to the document.
Attachments,
/// The document's signatures and their validity.
Signatures,
/// The document's layers (optional content).
Layers,
}
impl Panel {
/// The panel's name in automation output, fixed whatever the variant is called.
pub fn id(self) -> &'static str {
match self {
Panel::Bookmarks => "bookmarks",
Panel::Pages => "pages",
Panel::Attachments => "attachments",
Panel::Signatures => "signatures",
Panel::Layers => "layers",
}
}
}
/// What running a script did.
@@ -629,6 +659,8 @@ fn message(v: &JsValue, key: &str, ctx: &mut Context) -> JsResult<String> {
}
}
/// `app.alert(cMsg)`: records the message, given as a string or as an object with `cMsg`, and
/// returns 1 (the OK button).
fn alert(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
let m = message(&arg(args, 0), "cMsg", ctx)?;
host(ctx)?.borrow_mut().alert(m);
@@ -636,17 +668,49 @@ fn alert(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue>
Ok(JsValue::from(1))
}
/// `app.beep()`: queues a request to beep.
fn beep(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
host(ctx)?.borrow_mut().request(Request::Beep);
Ok(JsValue::undefined())
}
/// `app.launchURL(cURL)`: queues a request to open the URL, given as a string or as an object
/// with `cURL`.
fn launch_url(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
let u = message(&arg(args, 0), "cURL", ctx)?;
host(ctx)?.borrow_mut().request(Request::LaunchUrl(u));
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<Request> {
Some(match name {
"ShowHideFileAttachment" => Request::ShowPanel(Panel::Attachments),
"ShowHideBookmarks" => Request::ShowPanel(Panel::Bookmarks),
"ShowHideThumbnails" => Request::ShowPanel(Panel::Pages),
"ShowHideSignatures" => Request::ShowPanel(Panel::Signatures),
"ShowHideOptCont" => Request::ShowPanel(Panel::Layers),
"Print" => Request::Print,
// Save opens Save As too, as it always has for XFA forms' buttons: a click on a form
// never overwrites the file unasked.
"SaveAs" | "Save" => Request::SaveAs,
_ => return None,
})
}
/// `app.execMenuItem(name)`: queues the request [`menu_item`] gives for the name (Save opens
/// Save As too); an unknown name does nothing.
fn exec_menu_item(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
let item = text(&arg(args, 0), ctx)?;
if let Some(r) = menu_item(&item) {
host(ctx)?.borrow_mut().requests.push(r);
}
Ok(JsValue::undefined())
}
/// `app.response(…)`: always `null`, the answer for a canceled dialog, since no one is there to
/// type one.
fn response(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult<JsValue> {
// No one to answer: as if the dialog was cancelled.
Ok(JsValue::null())
@@ -1271,6 +1335,8 @@ fn printx(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue>
Ok(s(&out))
}
/// The `color` object: each named color (`transparent`, `black`, `red`, `cyan`, `gray`, …) as
/// an array of its color space (`T`, `G`, `RGB` or `CMYK`) followed by its components.
fn color_object(ctx: &mut Context) -> JsObject {
let mut entries: Vec<(&str, Vec<JsValue>)> = vec![
("transparent", vec![s("T")]),
@@ -1348,6 +1414,7 @@ fn install(ctx: &mut Context) -> JsResult<()> {
.function(NativeFunction::from_fn_ptr(beep), js_string!("beep"), 0)
.function(NativeFunction::from_fn_ptr(response), js_string!("response"), 1)
.function(NativeFunction::from_fn_ptr(launch_url), js_string!("launchURL"), 2)
.function(NativeFunction::from_fn_ptr(exec_menu_item), js_string!("execMenuItem"), 1)
.function(NativeFunction::from_fn_ptr(timer), js_string!("setTimeOut"), 2)
.function(NativeFunction::from_fn_ptr(timer), js_string!("setInterval"), 2)
.function(NativeFunction::from_fn_ptr(noop), js_string!("clearTimeOut"), 1)
+56
View File
@@ -1393,6 +1393,8 @@ mod session {
use crate::xfa::*;
use crate::xfa_session::{Script, XfaSession};
/// A JavaScript or FormCalc script for `activity` on `target`, against the test form with
/// default limits and a 3 s timeout.
fn script(source: &str, formcalc: bool, target: &str, activity: &str) -> Script {
Script {
source: source.into(),
@@ -1404,6 +1406,9 @@ mod session {
}
}
/// JavaScript and FormCalc scripts run in turn in one session, each on the form the caller
/// sends: nothing an earlier script changed or defined leaks into the next, a failing script
/// doesn't end the session, and hundreds of scripts run on its one thread.
#[test]
fn scripts_of_one_event_run_on_one_thread_each_on_the_form_the_caller_sends() {
let mut s = XfaSession::new(XfaDoc { page_count: 3, ..Default::default() });
@@ -1443,6 +1448,8 @@ mod session {
assert!(started.elapsed() < std::time::Duration::from_secs(10), "{:?}", started.elapsed());
}
/// A JavaScript or FormCalc script whose engine takes longer to be ready than the script's
/// time limit still runs and returns its result, and the session stays alive.
#[test]
fn a_scripts_time_starts_when_its_context_is_ready() {
// The engine made slow to be ready (300 ms) under a 50 ms script limit: the script is
@@ -1463,6 +1470,9 @@ mod session {
assert_eq!(s.dead(), None);
}
/// An endless JavaScript loop past its 200 ms limit is abandoned within 2 s and leaves the
/// session dead, so nothing more runs in it; an endless FormCalc loop is abandoned or
/// reported as running too long.
#[test]
fn a_script_past_its_time_is_abandoned_and_the_session_is_dead_after_it() {
let mut s = XfaSession::new(XfaDoc::default());
@@ -1717,6 +1727,52 @@ mod scand {
}
}
mod menu_items {
use super::*;
use crate::xfa::{XfaDoc, XfaEffect, XfaEvent, run_xfa};
/// Every side panel has a fixed id in automation output.
#[test]
fn each_panel_has_a_fixed_id_for_automation() {
let ids = [Panel::Bookmarks, Panel::Pages, Panel::Attachments, Panel::Signatures, Panel::Layers].map(Panel::id);
assert_eq!(ids, ["bookmarks", "pages", "attachments", "signatures", "layers"]);
}
/// Each side panel item, Print and SaveAs make their request, in order; an unknown item
/// makes none and is not an error.
#[test]
fn exec_menu_item_shows_side_panels_prints_and_saves() {
let o = go(
"app.execMenuItem('ShowHideFileAttachment'); app.execMenuItem('ShowHideBookmarks'); app.execMenuItem('ShowHideThumbnails'); \
app.execMenuItem('ShowHideSignatures'); app.execMenuItem('ShowHideOptCont'); app.execMenuItem('Print'); \
app.execMenuItem('SaveAs'); app.execMenuItem('NoSuchItem');",
&Event::field("Mouse Up", "total", ""),
);
assert_eq!(o.error, None);
assert_eq!(
o.requests,
[
Request::ShowPanel(Panel::Attachments),
Request::ShowPanel(Panel::Bookmarks),
Request::ShowPanel(Panel::Pages),
Request::ShowPanel(Panel::Signatures),
Request::ShowPanel(Panel::Layers),
Request::Print,
Request::SaveAs,
]
);
}
/// `app.execMenuItem` works in XFA scripts too: a click shows the attachments panel.
#[test]
fn xfa_scripts_show_the_attachments_panel() {
let event = XfaEvent { activity: "click".into(), target: "form1[0].page1[0].go[0]".into(), ..Default::default() };
let o = run_xfa("app.execMenuItem('ShowHideFileAttachment')", &event, &XfaDoc::default(), &super::xfa_model::form(), Limits::default());
assert_eq!(o.error, None, "{o:?}");
assert!(o.effects.contains(&XfaEffect::ShowPanel(Panel::Attachments)), "{:?}", o.effects);
}
}
/// The check XFA forms run on opening: without both globals, or with a version below 2.8, it
/// alerts that the viewer can't show the form.
const XFA_CHECK: &str = "if (typeof(xfa_installed) == 'undefined' || typeof(xfa_version) == 'undefined' || xfa_version < 2.8) app.alert('old viewer'); \
+20 -4
View File
@@ -115,6 +115,8 @@ pub enum XfaEffect {
MessageBox(String),
/// `xfa.host.resetData()`: the named fields, or every field when empty.
ResetData(Vec<String>),
/// `app.execMenuItem("ShowHide…")`: show or hide a side panel.
ShowPanel(crate::Panel),
Print,
SaveAs,
LaunchUrl(String),
@@ -219,7 +221,7 @@ fn payload(e: &XfaEffect) -> usize {
XfaEffect::AddInstance { som } | XfaEffect::RemoveInstance { som } => som.len(),
XfaEffect::MessageBox(s) | 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::<String>())),
XfaEffect::Print | XfaEffect::SaveAs | XfaEffect::Beep | XfaEffect::Recalculate | XfaEffect::Relayout => 0,
XfaEffect::Print | XfaEffect::SaveAs | XfaEffect::Beep | XfaEffect::Recalculate | XfaEffect::Relayout | XfaEffect::ShowPanel(_) => 0,
}
}
@@ -1131,39 +1133,50 @@ fn resolver(ctx: &mut Context, from: usize, many: bool) -> JsResult<JsValue> {
// ── xfa.host, xfa.layout, xfa.event, app, console ───────────────────────────────────────────
/// Records effect `e` on the host, as [`XHost::push`] does.
fn effect(ctx: &mut Context, e: XfaEffect) -> JsResult<()> {
host(ctx)?.borrow_mut().push(e);
Ok(())
}
/// `xfa.host.messageBox(message)`: records the message, cut to [`MAX_LINE`] characters, and
/// returns 1.
fn message_box(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
let m = clip(text(&arg(args, 0), ctx)?);
effect(ctx, XfaEffect::MessageBox(m))?;
Ok(JsValue::from(1.0))
}
/// `xfa.host.resetData(names)`: asks to reset the comma-separated fields, or every field when
/// the list is empty.
fn reset_data(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
let list = text(&arg(args, 0), ctx)?;
effect(ctx, XfaEffect::ResetData(reset_names(&list)))?;
Ok(JsValue::undefined())
}
/// `xfa.host.print(…)`: asks to print; its arguments are ignored.
fn print(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
effect(ctx, XfaEffect::Print)?;
Ok(JsValue::undefined())
}
/// `xfa.host.beep()`: asks to beep.
fn beep(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
effect(ctx, XfaEffect::Beep)?;
Ok(JsValue::undefined())
}
/// `xfa.host.gotoURL(url)`: asks to open the URL.
fn goto_url(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
let u = text(&arg(args, 0), ctx)?;
effect(ctx, XfaEffect::LaunchUrl(u))?;
Ok(JsValue::undefined())
}
/// `xfa.host.setFocus(target)`: asks to focus the node, given as a node object or as a SOM
/// expression resolved from the current node; an expression that names no node is passed on
/// as written.
fn set_focus(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
let target = arg(args, 0);
let som = match target.as_object() {
@@ -1182,11 +1195,14 @@ fn set_focus(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsVal
Ok(JsValue::undefined())
}
/// `app.execMenuItem(name)`: the effect for the request the shared menu table gives the name
/// (Save opens Save As too); an unknown name does nothing.
fn exec_menu_item(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
let item = text(&arg(args, 0), ctx)?;
match item.as_str() {
"SaveAs" | "Save" => effect(ctx, XfaEffect::SaveAs)?,
"Print" => effect(ctx, XfaEffect::Print)?,
match crate::menu_item(&item) {
Some(crate::Request::SaveAs) => effect(ctx, XfaEffect::SaveAs)?,
Some(crate::Request::Print) => effect(ctx, XfaEffect::Print)?,
Some(crate::Request::ShowPanel(p)) => effect(ctx, XfaEffect::ShowPanel(p))?,
_ => {}
}
Ok(JsValue::undefined())