feat(forms): page_scripts reads a page's open/close actions and its annotations' page actions (#169)

Co-authored-by: Peter Yatsyshin <p.yatsyshin@googlemail.com>
This commit is contained in:
FredAppsandPeter Yatsyshin committed 2026-11-11 10:48:07 +01:00
1 parent e13d0a04ca
commit a0b4c88ba8
4 files changed
+99 -4

No files matched your search

+3 -3
View File
@@ -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;
+1 -1
View File
@@ -85,7 +85,7 @@ pub(crate) fn action_fields(doc: &Document, o: &Object) -> Vec<String> {
/// 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<String> {
pub(crate) fn qualified_name(doc: &Document, r: ObjRef) -> Option<String> {
let mut parts = Vec::new();
let mut seen = std::collections::HashSet::new();
let mut cur = Some(r);
+67
View File
@@ -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<String>,
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<PageScript> {
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<String> {
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)?;
+28
View File
@@ -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");
}