From e8eb840ec53d8fa1e1ac070b2e3b67c86bc21ecf Mon Sep 17 00:00:00 2001
From: Eric Kaden <608341+EricKaden@users.noreply.github.com>
Date: Mon, 19 Oct 2026 20:59:14 -0400
Subject: [PATCH 1/7] =?UTF-8?q?M6:=20form=20buttons=20show=20the=20side=20?=
=?UTF-8?q?panels=20(execMenuItem=20ShowHide=E2=80=A6)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
crates/automation/src/a11y.rs | 1 +
crates/engine/src/js.rs | 11 ++++++++--
crates/engine/src/tests.rs | 27 ++++++++++++++++++++++++
crates/engine/src/xfa.rs | 2 ++
crates/js/src/lib.rs | 39 ++++++++++++++++++++++++++++++++++-
crates/js/src/tests.rs | 36 ++++++++++++++++++++++++++++++++
crates/js/src/xfa.rs | 9 +++++---
crates/ui-egui/src/js_ui.rs | 13 +++++++++++-
crates/ui-egui/tests/forms.rs | 15 ++++++++++++++
parity/acrobat-features.toml | 2 +-
10 files changed, 147 insertions(+), 8 deletions(-)
diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs
index 212454973..3a1422ec7 100644
--- a/crates/automation/src/a11y.rs
+++ b/crates/automation/src/a11y.rs
@@ -657,6 +657,7 @@ fn request_json(r: &pdfcraft_engine::js::Request) -> Value {
R::Focus(f) => json!({ "focus": f }),
R::Beep => json!({ "beep": true }),
R::SaveAs => json!({ "save_as": true }),
+ R::ShowPanel(p) => json!({ "panel": format!("{p:?}").to_lowercase() }),
}
}
diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs
index b797bc8d8..82ee90c5d 100644
--- a/crates/engine/src/js.rs
+++ b/crates/engine/src/js.rs
@@ -7,7 +7,7 @@
//! off, scripts don't run and AF calls keep working natively.
use pdfcraft_forms::{Field, FieldChange, FieldEvent, FieldKind, FieldProps, ScriptResult, Scripts};
-pub use pdfcraft_js::{Limits, Outcome, Request};
+pub use pdfcraft_js::{Limits, Outcome, Panel, Request};
use crate::{DocId, Edit, EditError, Session};
@@ -29,7 +29,14 @@ impl JsOutput {
fn absorb(&mut self, o: &Outcome) {
self.alerts.extend(o.alerts.iter().cloned());
self.console.extend(o.console.iter().cloned());
- self.requests.extend(o.requests.iter().cloned());
+ // A field's own scripts (keystroke, format, validate, calculate) run unasked: only a
+ // button's may show or hide a side panel.
+ for r in &o.requests {
+ match r {
+ Request::ShowPanel(_) => self.console.push("A field script asked to show a side panel; only a button may show a side panel".into()),
+ r => self.requests.push(r.clone()),
+ }
+ }
self.errors.extend(o.error.iter().cloned());
}
diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
index f125c41da..72854e37a 100644
--- a/crates/engine/src/tests.rs
+++ b/crates/engine/src/tests.rs
@@ -6106,3 +6106,30 @@ fn undo_never_makes_an_unsaved_document_clean() {
s.undo(new).unwrap();
assert!(!s.get(new).unwrap().dirty, "once saved, undo back to the saved state is clean");
}
+
+#[test]
+fn an_xfa_button_may_show_a_side_panel_and_other_events_may_not() {
+ let xdp = r##"
+
+
+
+
+ Attachments
+
+
+
+
+
+
+"##;
+ let mut s = Session::new();
+ let id = s.open("files.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].files[0]".into(), activity: "click".into() }).unwrap();
+ let out = s.take_js_output(id);
+ assert_eq!(out.requests, [pdfcraft_js::Request::ShowPanel(pdfcraft_js::Panel::Attachments)], "{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 shows a panel: {out:?}");
+ assert!(out.console.iter().any(|c| c.contains("show a side panel")), "{:?}", out.console);
+}
diff --git a/crates/engine/src/xfa.rs b/crates/engine/src/xfa.rs
index 0f37933af..313bfd1ca 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::ShowPanel(_) => Some("show a side panel"),
XfaEffect::SetFocus(_) => Some("move the focus"),
_ => None,
}
@@ -626,6 +627,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::ShowPanel(p) => self.run.out.requests.push(Request::ShowPanel(*p)),
XfaEffect::SetFocus(som) => {
if let Some(name) = self.by_som().get(som).cloned() {
self.run.out.requests.push(Request::Focus(name));
diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs
index d8d9fa8ba..987d28da4 100644
--- a/crates/js/src/lib.rs
+++ b/crates/js/src/lib.rs
@@ -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`), `console` (`println`,
//! `show`, `clear`), `display` and `color`.
//!
@@ -159,6 +159,19 @@ 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 {
+ Bookmarks,
+ /// Page thumbnails.
+ Pages,
+ Attachments,
+ Signatures,
+ Layers,
}
/// What running a script did.
@@ -565,6 +578,29 @@ fn launch_url(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult Option {
+ 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,
+ "SaveAs" | "Save" => Request::SaveAs,
+ _ => return None,
+ })
+}
+
+fn exec_menu_item(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult {
+ let item = text(&arg(args, 0), ctx)?;
+ if let Some(r) = menu_item(&item) {
+ host(ctx)?.borrow_mut().requests.push(r);
+ }
+ Ok(JsValue::undefined())
+}
+
fn response(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult {
// No one to answer: as if the dialog was cancelled.
Ok(JsValue::null())
@@ -931,6 +967,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)
diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs
index 61d3a71e5..bfb53996f 100644
--- a/crates/js/src/tests.rs
+++ b/crates/js/src/tests.rs
@@ -1132,3 +1132,39 @@ mod session {
assert!(o.abandoned || o.error.as_deref().is_some_and(|e| e.contains("too long")), "{o:?}");
}
}
+
+mod menu_items {
+ use super::*;
+ use crate::xfa::{XfaDoc, XfaEffect, XfaEvent, run_xfa};
+
+ #[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,
+ ]
+ );
+ }
+
+ #[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);
+ }
+}
diff --git a/crates/js/src/xfa.rs b/crates/js/src/xfa.rs
index a2361da7e..8f64f9049 100644
--- a/crates/js/src/xfa.rs
+++ b/crates/js/src/xfa.rs
@@ -112,6 +112,8 @@ pub enum XfaEffect {
MessageBox(String),
/// `xfa.host.resetData()`: the named fields, or every field when empty.
ResetData(Vec),
+ /// `app.execMenuItem("ShowHide…")`: show or hide a side panel.
+ ShowPanel(crate::Panel),
Print,
SaveAs,
LaunchUrl(String),
@@ -1049,9 +1051,10 @@ fn set_focus(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult JsResult {
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())
diff --git a/crates/ui-egui/src/js_ui.rs b/crates/ui-egui/src/js_ui.rs
index df461fc6e..98b2afbd8 100644
--- a/crates/ui-egui/src/js_ui.rs
+++ b/crates/ui-egui/src/js_ui.rs
@@ -2,7 +2,7 @@
//! the JavaScript console (⌘J), Document JavaScripts, and Preferences ▸ JavaScript.
use egui::{Align, Layout};
-use pdfcraft_engine::js::{JsOutput, Request};
+use pdfcraft_engine::js::{JsOutput, Panel, Request};
use pdfcraft_engine::{DocId, Edit};
use crate::theme::{self, Tokens};
@@ -67,6 +67,17 @@ impl PdfCraftApp {
&[("u", &u)],
),
Request::SaveAs => self.run_command("file.save_as"),
+ Request::ShowPanel(p) => {
+ let panel = match p {
+ Panel::Bookmarks => crate::RightPanel::Bookmarks,
+ Panel::Pages => crate::RightPanel::Pages,
+ Panel::Attachments => crate::RightPanel::Attachments,
+ Panel::Signatures => crate::RightPanel::Signatures,
+ Panel::Layers => crate::RightPanel::Layers,
+ };
+ // Show/Hide: a panel already showing closes.
+ self.right = (self.right != Some(panel)).then_some(panel);
+ }
Request::Focus(_) | Request::Beep | Request::Reset(_) => {}
}
}
diff --git a/crates/ui-egui/tests/forms.rs b/crates/ui-egui/tests/forms.rs
index e153dcca9..6b2add486 100644
--- a/crates/ui-egui/tests/forms.rs
+++ b/crates/ui-egui/tests/forms.rs
@@ -1072,3 +1072,18 @@ fn auto_size_fields_shrink_and_wrap_typed_text_to_fit_their_box() {
// An explicit size is kept, as in Acrobat: the text may then run past the box.
assert_eq!(on_screen_fit(&h, "fixed", long).0, 10.0);
}
+
+#[test]
+fn a_form_button_shows_and_hides_the_attachments_panel() {
+ let mut h = harness();
+ h.state_mut().set_option("panel", "none").unwrap();
+ h.run_steps(2);
+ let id = h.state().views[0].id;
+ h.state_mut().run_button_script(id, "size", "app.execMenuItem('ShowHideFileAttachment');");
+ h.run_steps(2);
+ assert_eq!(h.state().right, Some(pdfcraft_ui_egui::RightPanel::Attachments));
+ // Show/Hide, as its name says: asked again, it closes.
+ h.state_mut().run_button_script(id, "size", "app.execMenuItem('ShowHideFileAttachment');");
+ h.run_steps(2);
+ assert_eq!(h.state().right, None);
+}
diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml
index 7aca7d5c8..f47140442 100644
--- a/parity/acrobat-features.toml
+++ b/parity/acrobat-features.toml
@@ -5137,7 +5137,7 @@ 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/automation.rs::javascript_through_tools"]
-notes = "boa (pure Rust) with the forms object model: event, getField/Field (value, readonly, display, colours, items, check boxes), app, util.printf/printd/printx, console, display, color; no annotations, layers or dialogs (app.response returns null) yet."
+notes = "boa (pure Rust) with the forms object model: event, getField/Field (value, readonly, display, colours, items, check boxes), app (with execMenuItem for the side panels, Print and Save As, from a button only), util.printf/printd/printx, console, display, color; no annotations, layers or dialogs (app.response returns null) yet."
[[feature]]
id = "form.js-console"
From 74326b455c1073b3a3bd69d08f7900a0a4f329b7 Mon Sep 17 00:00:00 2001
From: Eric Kaden <608341+EricKaden@users.noreply.github.com>
Date: Mon, 19 Oct 2026 21:02:37 -0400
Subject: [PATCH 2/7] M6: a field's own script may not open Save As
---
crates/engine/src/js.rs | 5 ++++-
crates/engine/src/tests.rs | 23 +++++++++++++++++++++++
2 files changed, 27 insertions(+), 1 deletion(-)
diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs
index 82ee90c5d..73be05660 100644
--- a/crates/engine/src/js.rs
+++ b/crates/engine/src/js.rs
@@ -30,10 +30,13 @@ impl JsOutput {
self.alerts.extend(o.alerts.iter().cloned());
self.console.extend(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.
+ // button's may show or hide a side panel, or open Save As.
for r in &o.requests {
match r {
Request::ShowPanel(_) => self.console.push("A field script asked to show a side panel; only a button may show a side panel".into()),
+ Request::SaveAs => {
+ self.console.push("A field script asked to save the document as a new file; only a button may open Save As".into())
+ }
r => self.requests.push(r.clone()),
}
}
diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
index 72854e37a..4c19b8011 100644
--- a/crates/engine/src/tests.rs
+++ b/crates/engine/src/tests.rs
@@ -6133,3 +6133,26 @@ fn an_xfa_button_may_show_a_side_panel_and_other_events_may_not() {
assert!(out.requests.is_empty(), "only a click shows a panel: {out:?}");
assert!(out.console.iter().any(|c| c.contains("show a side panel")), "{:?}", out.console);
}
+
+#[test]
+fn a_fields_own_script_may_not_save_as_or_show_a_panel() {
+ let mut s = Session::new().with_clock(|| 1_700_000_000);
+ let id = s.open("order.pdf", None, Arc::new(scripted_form()), None).unwrap();
+ s.apply(
+ id,
+ Edit::SetFieldScript {
+ name: "qty".into(),
+ event: "validate".into(),
+ script: Some("app.execMenuItem('SaveAs'); app.execMenuItem('ShowHideFileAttachment');".into()),
+ },
+ )
+ .unwrap();
+ s.take_js_output(id);
+ s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("4".into()) }).unwrap();
+ let out = s.take_js_output(id);
+ assert!(out.requests.is_empty(), "a validate script runs unasked: {out:?}");
+ assert_eq!(out.console.iter().filter(|c| c.contains("only a button may")).count(), 2, "{:?}", out.console);
+ // A button's script may.
+ let o = s.run_javascript(id, "app.execMenuItem('SaveAs');", Some("go")).unwrap();
+ assert_eq!(o.requests, [js::Request::SaveAs]);
+}
From 9366af3badb6ebfc096507b4add4e51779fbbd8a Mon Sep 17 00:00:00 2001
From: Eric Kaden <608341+EricKaden@users.noreply.github.com>
Date: Mon, 19 Oct 2026 22:01:10 -0400
Subject: [PATCH 3/7] M6: side panels have fixed automation ids, and the parity
note says where Print works
---
crates/automation/src/a11y.rs | 2 +-
crates/js/src/lib.rs | 13 +++++++++++++
crates/js/src/tests.rs | 6 ++++++
parity/acrobat-features.toml | 2 +-
4 files changed, 21 insertions(+), 2 deletions(-)
diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs
index 3a1422ec7..a9e13cbc3 100644
--- a/crates/automation/src/a11y.rs
+++ b/crates/automation/src/a11y.rs
@@ -657,7 +657,7 @@ fn request_json(r: &pdfcraft_engine::js::Request) -> Value {
R::Focus(f) => json!({ "focus": f }),
R::Beep => json!({ "beep": true }),
R::SaveAs => json!({ "save_as": true }),
- R::ShowPanel(p) => json!({ "panel": format!("{p:?}").to_lowercase() }),
+ R::ShowPanel(p) => json!({ "panel": p.id() }),
}
}
diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs
index 987d28da4..281898c4a 100644
--- a/crates/js/src/lib.rs
+++ b/crates/js/src/lib.rs
@@ -174,6 +174,19 @@ pub enum Panel {
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.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Outcome {
diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs
index bfb53996f..ec4cfcc5e 100644
--- a/crates/js/src/tests.rs
+++ b/crates/js/src/tests.rs
@@ -1137,6 +1137,12 @@ mod menu_items {
use super::*;
use crate::xfa::{XfaDoc, XfaEffect, XfaEvent, run_xfa};
+ #[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"]);
+ }
+
#[test]
fn exec_menu_item_shows_side_panels_prints_and_saves() {
let o = go(
diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml
index f47140442..12e11d82c 100644
--- a/parity/acrobat-features.toml
+++ b/parity/acrobat-features.toml
@@ -5137,7 +5137,7 @@ 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/automation.rs::javascript_through_tools"]
-notes = "boa (pure Rust) with the forms object model: event, getField/Field (value, readonly, display, colours, items, check boxes), app (with execMenuItem for the side panels, Print and Save As, from a button only), util.printf/printd/printx, console, display, color; no annotations, layers or dialogs (app.response returns null) yet."
+notes = "boa (pure Rust) with the forms object model: event, getField/Field (value, readonly, display, colours, items, check boxes), app (with execMenuItem for the side panels and Save As, from a button only, and Print where this.print() works), util.printf/printd/printx, console, display, color; no annotations, layers or dialogs (app.response returns null) yet."
[[feature]]
id = "form.js-console"
From c90afe54cfdf08cd27ce2ca43e091d36f889c647 Mon Sep 17 00:00:00 2001
From: Eric Kaden <608341+EricKaden@users.noreply.github.com>
Date: Mon, 19 Oct 2026 22:22:03 -0400
Subject: [PATCH 4/7] M6: say why a form's Save opens Save As
---
crates/js/src/lib.rs | 2 ++
1 file changed, 2 insertions(+)
diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs
index 281898c4a..aff27f951 100644
--- a/crates/js/src/lib.rs
+++ b/crates/js/src/lib.rs
@@ -601,6 +601,8 @@ pub(crate) fn menu_item(name: &str) -> Option {
"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,
})
From a0ae8a39961de99120d7e027660c97f0f049d052 Mon Sep 17 00:00:00 2001
From: Eric Kaden <608341+EricKaden@users.noreply.github.com>
Date: Mon, 19 Oct 2026 23:43:02 -0400
Subject: [PATCH 5/7] M6: doc comments for the functions this change touches
---
crates/automation/src/a11y.rs | 2 ++
crates/engine/src/js.rs | 3 +++
crates/engine/src/tests.rs | 4 ++++
crates/js/src/lib.rs | 5 +++++
crates/js/src/tests.rs | 4 ++++
crates/js/src/xfa.rs | 2 ++
crates/ui-egui/tests/forms.rs | 2 ++
7 files changed, 22 insertions(+)
diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs
index a9e13cbc3..19afb16a4 100644
--- a/crates/automation/src/a11y.rs
+++ b/crates/automation/src/a11y.rs
@@ -646,6 +646,8 @@ impl Automation {
}
}
+/// A script's request to the viewer as the JSON that running a script reports; 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 {
diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs
index 73be05660..9b057900f 100644
--- a/crates/engine/src/js.rs
+++ b/crates/engine/src/js.rs
@@ -26,6 +26,9 @@ impl JsOutput {
self.alerts.is_empty() && self.console.is_empty() && self.requests.is_empty() && self.errors.is_empty()
}
+ /// 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.
fn absorb(&mut self, o: &Outcome) {
self.alerts.extend(o.alerts.iter().cloned());
self.console.extend(o.console.iter().cloned());
diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
index 4c19b8011..6b0a97e98 100644
--- a/crates/engine/src/tests.rs
+++ b/crates/engine/src/tests.rs
@@ -6107,6 +6107,8 @@ fn undo_never_makes_an_unsaved_document_clean() {
assert!(!s.get(new).unwrap().dirty, "once saved, undo back to the saved state is clean");
}
+/// An XFA button's click script may show the attachments panel; a text field's exit script
+/// asking the same is noted on the console and ignored.
#[test]
fn an_xfa_button_may_show_a_side_panel_and_other_events_may_not() {
let xdp = r##"
@@ -6134,6 +6136,8 @@ fn an_xfa_button_may_show_a_side_panel_and_other_events_may_not() {
assert!(out.console.iter().any(|c| c.contains("show a side panel")), "{:?}", out.console);
}
+/// A validate script that asks for Save As and a side panel gets neither, with a console note
+/// for each; a button's script may still open Save As.
#[test]
fn a_fields_own_script_may_not_save_as_or_show_a_panel() {
let mut s = Session::new().with_clock(|| 1_700_000_000);
diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs
index aff27f951..1eb3d60e8 100644
--- a/crates/js/src/lib.rs
+++ b/crates/js/src/lib.rs
@@ -608,6 +608,8 @@ pub(crate) fn menu_item(name: &str) -> Option {
})
}
+/// `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 {
let item = text(&arg(args, 0), ctx)?;
if let Some(r) = menu_item(&item) {
@@ -941,6 +943,9 @@ fn color_object(ctx: &mut Context) -> JsObject {
o.build()
}
+/// Installs the form object model's globals: the document's functions and properties
+/// (`getField`, `numFields`, `pageNum`, `info`, …), `app`, `util`, `console`, `display` and
+/// `color`.
fn install(ctx: &mut Context) -> JsResult<()> {
let globals: [(&str, usize, NativeFunction); 9] = [
("getField", 1, NativeFunction::from_fn_ptr(get_field)),
diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs
index ec4cfcc5e..a78ce67de 100644
--- a/crates/js/src/tests.rs
+++ b/crates/js/src/tests.rs
@@ -1137,12 +1137,15 @@ 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(
@@ -1166,6 +1169,7 @@ mod menu_items {
);
}
+ /// `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() };
diff --git a/crates/js/src/xfa.rs b/crates/js/src/xfa.rs
index 8f64f9049..093ba633b 100644
--- a/crates/js/src/xfa.rs
+++ b/crates/js/src/xfa.rs
@@ -1049,6 +1049,8 @@ fn set_focus(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult JsResult {
let item = text(&arg(args, 0), ctx)?;
match crate::menu_item(&item) {
diff --git a/crates/ui-egui/tests/forms.rs b/crates/ui-egui/tests/forms.rs
index 6b2add486..47a5c8b52 100644
--- a/crates/ui-egui/tests/forms.rs
+++ b/crates/ui-egui/tests/forms.rs
@@ -1073,6 +1073,8 @@ fn auto_size_fields_shrink_and_wrap_typed_text_to_fit_their_box() {
assert_eq!(on_screen_fit(&h, "fixed", long).0, 10.0);
}
+/// A button script's `ShowHideFileAttachment` opens the attachments panel, and running it again
+/// closes it.
#[test]
fn a_form_button_shows_and_hides_the_attachments_panel() {
let mut h = harness();
From b6cd6b44ca57e82ffc90580a17737a95899ab6b6 Mon Sep 17 00:00:00 2001
From: Eric Kaden <608341+EricKaden@users.noreply.github.com>
Date: Tue, 20 Oct 2026 09:24:10 -0400
Subject: [PATCH 6/7] M6: doc comments for the functions around this change
---
crates/automation/src/a11y.rs | 9 +++++++++
crates/engine/src/js.rs | 1 +
crates/js/src/lib.rs | 9 +++++++++
crates/js/src/tests.rs | 10 ++++++++++
crates/js/src/xfa.rs | 11 +++++++++++
crates/ui-egui/tests/forms.rs | 3 +++
6 files changed, 43 insertions(+)
diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs
index 19afb16a4..a11f1132f 100644
--- a/crates/automation/src/a11y.rs
+++ b/crates/automation/src/a11y.rs
@@ -575,6 +575,8 @@ impl Automation {
}
impl Automation {
+ /// Recognition settings from the arguments: `dpi` (clamped to 72–600), `language` (refused
+ /// unless the models read it) and `skip_text_pages`, each left at its default when absent.
pub(crate) fn ocr_settings(&self, a: &Args) -> Result {
let mut settings = pdfcraft_engine::ocr::OcrSettings::default();
if let Some(d) = a.opt_num("dpi")? {
@@ -592,6 +594,9 @@ impl Automation {
Ok(settings)
}
+ /// Recognizes text in each of `paths` and writes the result under the same file name in
+ /// `folder`, reporting per file its output, word count and skipped pages, or its error. A
+ /// path outside the root refuses the whole call before any work starts.
pub(crate) fn ocr_recognize_files(&mut self, a: &Args) -> Result {
let settings = self.ocr_settings(a)?;
let folder = self.resolve(a.str("folder")?, true)?;
@@ -623,6 +628,8 @@ impl Automation {
Ok(json!({ "files": out }))
}
+ /// Recognizes text on the open document's `pages` (every page when absent) and adds it,
+ /// reporting the total word count and, per page, why it was skipped or what it found.
pub(crate) fn ocr_recognize(&mut self, a: &Args) -> Result {
let settings = self.ocr_settings(a)?;
let pages = if a.opt_ints("pages")?.is_some() { self.pages(a, "pages")? } else { Vec::new() };
@@ -638,6 +645,8 @@ impl Automation {
Ok(json!({ "words": found.iter().map(|p| p.words.len()).sum::(), "pages": list }))
}
+ /// Whether text recognition's models are installed, the directories searched for them (in
+ /// order), and the languages it reads, each as a code and a name.
pub(crate) fn ocr_status(&self) -> Result {
use pdfcraft_engine::ocr;
let dirs: Vec = ocr::Models::search_dirs().iter().map(|d| d.to_string_lossy().into_owned()).collect();
diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs
index 9b057900f..025037b8f 100644
--- a/crates/engine/src/js.rs
+++ b/crates/engine/src/js.rs
@@ -22,6 +22,7 @@ pub struct JsOutput {
}
impl JsOutput {
+ /// Whether scripts produced nothing: no alerts, console lines, requests or errors.
pub fn is_empty(&self) -> bool {
self.alerts.is_empty() && self.console.is_empty() && self.requests.is_empty() && self.errors.is_empty()
}
diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs
index 1eb3d60e8..6b4e7f875 100644
--- a/crates/js/src/lib.rs
+++ b/crates/js/src/lib.rs
@@ -573,6 +573,8 @@ fn message(v: &JsValue, key: &str, ctx: &mut Context) -> JsResult {
}
}
+/// `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 {
let m = message(&arg(args, 0), "cMsg", ctx)?;
host(ctx)?.borrow_mut().alerts.push(m);
@@ -580,11 +582,14 @@ fn alert(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult
Ok(JsValue::from(1))
}
+/// `app.beep()`: queues a request to beep.
fn beep(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult {
host(ctx)?.borrow_mut().requests.push(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 {
let u = message(&arg(args, 0), "cURL", ctx)?;
host(ctx)?.borrow_mut().requests.push(Request::LaunchUrl(u));
@@ -618,6 +623,8 @@ fn exec_menu_item(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<
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 {
// No one to answer: as if the dialog was cancelled.
Ok(JsValue::null())
@@ -920,6 +927,8 @@ fn printx(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult
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)> = vec![
("transparent", vec![s("T")]),
diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs
index a78ce67de..e3ce86efe 100644
--- a/crates/js/src/tests.rs
+++ b/crates/js/src/tests.rs
@@ -1040,6 +1040,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(),
@@ -1051,6 +1053,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() });
@@ -1090,6 +1095,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
@@ -1110,6 +1117,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());
diff --git a/crates/js/src/xfa.rs b/crates/js/src/xfa.rs
index 093ba633b..6b87b2e57 100644
--- a/crates/js/src/xfa.rs
+++ b/crates/js/src/xfa.rs
@@ -997,17 +997,22 @@ fn resolver(ctx: &mut Context, from: usize, many: bool) -> JsResult {
// ── 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 {
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 {
let list = text(&arg(args, 0), ctx)?;
let names: Vec = list.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_string).collect();
@@ -1015,22 +1020,28 @@ fn reset_data(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult JsResult {
effect(ctx, XfaEffect::Print)?;
Ok(JsValue::undefined())
}
+/// `xfa.host.beep()`: asks 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.
fn goto_url(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult {
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 {
let target = arg(args, 0);
let som = match target.as_object() {
diff --git a/crates/ui-egui/tests/forms.rs b/crates/ui-egui/tests/forms.rs
index 47a5c8b52..85b0ac03d 100644
--- a/crates/ui-egui/tests/forms.rs
+++ b/crates/ui-egui/tests/forms.rs
@@ -1050,6 +1050,9 @@ fn on_screen_fit(h: &Harness<'static, PdfCraftApp>, name: &str, text: &str) -> (
}
}
+/// Text typed into an auto-size field shrinks to fit the box's width, while short text keeps a
+/// larger size; a multiline auto-size field wraps and shrinks only as far as the box's height
+/// needs, and a fixed 10 pt field keeps its size.
#[test]
fn auto_size_fields_shrink_and_wrap_typed_text_to_fit_their_box() {
let mut h = auto_size_harness();
From ae69aea6273499f1eee5ec869518a42a707d0117 Mon Sep 17 00:00:00 2001
From: Eric Kaden <608341+EricKaden@users.noreply.github.com>
Date: Thu, 22 Oct 2026 09:22:27 -0400
Subject: [PATCH 7/7] M6: doc comments on every Panel variant
---
crates/js/src/lib.rs | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs
index 3299f9d6e..4a4b5e64b 100644
--- a/crates/js/src/lib.rs
+++ b/crates/js/src/lib.rs
@@ -167,11 +167,15 @@ pub enum Request {
/// 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,
}