From a0b4c88ba8387aef5caf9afb67db0b04c46efd79 Mon Sep 17 00:00:00 2001
From: FredApps <153173854+FredApps@users.noreply.github.com>
Date: Wed, 11 Nov 2026 10:48:07 +0100
Subject: [PATCH] feat(forms): page_scripts reads a page's open/close actions
and its annotations' page actions (#169)
Co-authored-by: Peter Yatsyshin
---
crates/forms/src/lib.rs | 6 ++--
crates/forms/src/read.rs | 2 +-
crates/forms/src/scripting.rs | 67 +++++++++++++++++++++++++++++++++++
crates/forms/src/tests.rs | 28 +++++++++++++++
4 files changed, 99 insertions(+), 4 deletions(-)
diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs
index 89d7e2998f..fc4bf266b5 100644
--- a/crates/forms/src/lib.rs
+++ b/crates/forms/src/lib.rs
@@ -31,8 +31,8 @@ pub use author::{
};
pub use calc_order::{CalculatedField, CalculationIssue, CalculationOrder, calculation_order, set_calculation_order};
pub use scripting::{
- FieldChange, FieldEvent, NoScripts, ScriptResult, Scripts, apply_script_changes, document_scripts, document_scripts_named, set_document_script,
- set_field_script,
+ FieldChange, FieldEvent, NoScripts, PageEvent, PageScript, ScriptResult, Scripts, apply_script_changes, document_scripts, document_scripts_named,
+ page_scripts, set_document_script, set_field_script,
};
mod appearances;
@@ -46,7 +46,7 @@ pub use read::selected_options;
pub use read::{Fields, acroform, adopted_page_fields, fields, fields_with_adopted};
#[cfg(test)]
use read::{MAX_SCRIPT_BYTES, PageWalk, enumerate, form_reads};
-use read::{first_by_export, script, script_budget};
+use read::{first_by_export, qualified_name, script, script_budget};
pub use tab_order::{TabOrder, move_in_tab_order, set_tab_order, tab_orders};
#[cfg(test)]
use values::has_password_flag;
diff --git a/crates/forms/src/read.rs b/crates/forms/src/read.rs
index 4a7904f620..537237254a 100644
--- a/crates/forms/src/read.rs
+++ b/crates/forms/src/read.rs
@@ -85,7 +85,7 @@ pub(crate) fn action_fields(doc: &Document, o: &Object) -> Vec {
/// The fully qualified name of the field that `r` (a field or one of its widgets) belongs to,
/// from the partial names (`/T`) up its `/Parent` chain.
-fn qualified_name(doc: &Document, r: ObjRef) -> Option {
+pub(crate) fn qualified_name(doc: &Document, r: ObjRef) -> Option {
let mut parts = Vec::new();
let mut seen = std::collections::HashSet::new();
let mut cur = Some(r);
diff --git a/crates/forms/src/scripting.rs b/crates/forms/src/scripting.rs
index 3d66dd0918..f6546db546 100644
--- a/crates/forms/src/scripting.rs
+++ b/crates/forms/src/scripting.rs
@@ -468,6 +468,73 @@ fn in_array(doc: &Document, o: &Object, f: impl FnOnce(&[Object])) {
}
}
+/// When a page's actions run: as it becomes the page being read, or stops being it.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum PageEvent {
+ Open,
+ Close,
+}
+
+/// A script a page runs as it opens or closes, with the field whose widget carries it (`None`:
+/// the page's own action, or an annotation that isn't a field's).
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct PageScript {
+ pub field: Option,
+ pub script: String,
+}
+
+/// Most annotations of one page whose actions are read.
+const MAX_PAGE_ANNOTATIONS: usize = 10_000;
+
+/// The JavaScript page `page` (0-based) runs for `event`, in the order it runs: the page's own
+/// action (`/AA /O` or `/C`), then each annotation's, in the page's `/Annots` order (`/AA /PO`
+/// then `/PV` as the page opens, `/PC` then `/PI` as it closes). Other kinds of action, and
+/// actions chained with `/Next`, are left out.
+pub fn page_scripts(doc: &Document, page: usize, event: PageEvent) -> Vec {
+ let Some(page_ref) = crate::page_refs(doc).get(page).copied() else { return Vec::new() };
+ let page_obj = doc.get(page_ref);
+ let Some(pd) = page_obj.as_dict() else { return Vec::new() };
+ let (own, keys): (&[u8], [&[u8]; 2]) = match event {
+ PageEvent::Open => (b"O", [b"PO", b"PV"]),
+ PageEvent::Close => (b"C", [b"PC", b"PI"]),
+ };
+ let additional = |d: &pdfcraft_cos::Dict| d.get(b"AA").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned());
+ let mut budget = crate::script_budget();
+ let mut out = Vec::new();
+ if let Some(action) = additional(pd).and_then(|aa| aa.get(own).cloned())
+ && let Some(script) = javascript(doc, &action, &mut budget)
+ {
+ out.push(PageScript { field: None, script });
+ }
+ let annots = pd.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default();
+ for item in annots.iter().take(MAX_PAGE_ANNOTATIONS) {
+ let annot = doc.resolve(item);
+ let Some(ad) = annot.as_dict() else { continue };
+ let Some(aa) = additional(ad) else { continue };
+ let field = match item {
+ Object::Ref(r) if ad.name(b"Subtype") == Some(b"Widget".as_slice()) => crate::qualified_name(doc, *r),
+ _ => None,
+ };
+ for k in keys {
+ if let Some(action) = aa.get(k)
+ && let Some(script) = javascript(doc, action, &mut budget)
+ {
+ out.push(PageScript { field: field.clone(), script });
+ }
+ }
+ }
+ out
+}
+
+/// The script of a JavaScript action (`/S /JavaScript`); `None` for any other action.
+fn javascript(doc: &Document, action: &Object, budget: &mut pdfcraft_cos::DecodeBudget) -> Option {
+ let a = doc.resolve(action);
+ if a.as_dict()?.name(b"S")? != b"JavaScript" {
+ return None;
+ }
+ crate::script(doc, action, budget)
+}
+
/// Add (or replace) a document-level JavaScript named `name`.
pub fn set_document_script(doc: &mut Document, name: &str, js: Option<&str>) -> Result<(), FormError> {
let root = doc.root().ok_or(FormError::NoForm)?;
diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs
index caeba9491d..7d8bdc4673 100644
--- a/crates/forms/src/tests.rs
+++ b/crates/forms/src/tests.rs
@@ -7317,3 +7317,31 @@ fn simple_calculation_counts_the_first_value_of_a_multiple_selection_list() {
recalculate(&mut doc).unwrap();
assert_eq!(field(&fields(&doc), "total").value, ["4"]);
}
+
+/// A page with open and close actions of its own (one script a stream), an animation's widget
+/// with page actions (one of them not a script), buttons acting on mouse down, and a screen
+/// annotation with a page-open action.
+fn page_actions_fixture() -> Document {
+ document(&[
+ "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R 6 0 R] >> >>".into(), // 1
+ "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), // 2
+ "<< /Type /Page /Parent 2 0 R /AA << /O << /S /JavaScript /JS (page_open\\(\\);) >> /C << /S /JavaScript /JS 7 0 R >> >> /Annots [4 0 R 5 0 R 6 0 R 8 0 R] >>".into(), // 3
+ "<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (anim) /Rect [0 0 100 100] /P 3 0 R /AA << /PO << /S /JavaScript /JS (start\\(\\);) >> /PV << /S /JavaScript /JS (start\\(\\);) >> /PC << /S /JavaScript /JS (stop\\(\\);) >> /PI << /S /URI /URI (https://example.com) >> >> >>".into(), // 4
+ "<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (step) /Rect [0 0 10 10] /P 3 0 R /AA << /D << /S /JavaScript /JS (step\\(\\);) >> >> >>".into(), // 5
+ "<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (both) /Rect [20 0 30 10] /P 3 0 R /AA << /D << /S /JavaScript /JS (down\\(\\);) >> /U << /S /JavaScript /JS (up\\(\\);) >> >> >>".into(), // 6
+ "<< /Length 7 >>\nstream\nclose()\nendstream".into(), // 7
+ "<< /Type /Annot /Subtype /Screen /Rect [0 0 1 1] /AA << /PO << /S /JavaScript /JS (screen\\(\\);) >> >> >>".into(), // 8
+ ])
+}
+
+#[test]
+fn page_scripts_are_the_pages_own_then_each_annotations_in_order() {
+ let doc = page_actions_fixture();
+ let open = page_scripts(&doc, 0, PageEvent::Open);
+ let got: Vec<(Option<&str>, &str)> = open.iter().map(|s| (s.field.as_deref(), s.script.as_str())).collect();
+ assert_eq!(got, [(None, "page_open();"), (Some("anim"), "start();"), (Some("anim"), "start();"), (None, "screen();")]);
+ let close = page_scripts(&doc, 0, PageEvent::Close);
+ let got: Vec<(Option<&str>, &str)> = close.iter().map(|s| (s.field.as_deref(), s.script.trim())).collect();
+ assert_eq!(got, [(None, "close()"), (Some("anim"), "stop();")], "a URI action is not a script");
+ assert!(page_scripts(&doc, 1, PageEvent::Open).is_empty(), "no such page");
+}