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"); +}