//! Field scripts beyond the AF calls: the hook through which the engine runs JavaScript. //! //! `forms` does not depend on a JavaScript engine (architecture §3: `forms → js` is not an //! allowed edge). It calls a [`Scripts`] implementation at the points Acrobat runs field //! events — Keystroke (on commit) and Validate before a value is accepted, Calculate in the //! calculation order, Format when the appearance is drawn — and applies what the script asks //! for: the event's value, `rc`, and changes to other fields. use std::collections::HashSet; use pdfcraft_cos::{Document, ObjRef, Object, PdfString}; use crate::{Field, FieldKind, FieldValue, FormError, fields, flags}; /// The field events forms run scripts for. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum FieldEvent { Keystroke, Validate, Calculate, Format, } impl FieldEvent { /// `event.name`. pub fn name(self) -> &'static str { match self { FieldEvent::Keystroke => "Keystroke", FieldEvent::Validate => "Validate", FieldEvent::Calculate => "Calculate", FieldEvent::Format => "Format", } } } /// A change a script made to a field (`None`: unchanged). #[derive(Clone, Debug, Default, PartialEq)] pub struct FieldChange { pub name: String, /// Optional exact object target for malformed forms with duplicate field names. pub object: Option, pub value: Option>, /// This value came from a Calculate script; redraw it through the field's Format script. pub formatted: bool, pub read_only: Option, pub required: Option, /// `display.visible` 0, `hidden` 1, `noPrint` 2, `noView` 3. pub display: Option, /// Normal push-button caption, applied to every widget of the field. pub caption: Option, pub text_color: Option>, pub fill_color: Option>, pub rect: Option<[f64; 4]>, pub stroke_color: Option>, pub text_size: Option, pub line_width: Option, pub border_style: Option, pub user_name: Option, pub icon: Option>, /// A combo box's or list box's new items (export value, text), from `setItems`. pub options: Option>, /// JavaScript actions the script installed with `setAction`, by trigger name (`MouseUp`, /// `OnFocus`, `Keystroke`, …), each replacing what the field ran for that trigger. pub actions: Vec<(String, String)>, } /// What a script did. #[derive(Clone, Debug, PartialEq)] pub struct ScriptResult { /// `event.rc`. pub rc: bool, /// `event.value` afterwards. pub value: String, /// Whether the script assigned `event.value`, even the value it already had. pub value_set: bool, pub changes: Vec, /// Why it rejected the value (its alert), for the error shown. pub message: Option, } /// Runs field scripts (JavaScript). pub trait Scripts { /// Whether the host stopped this edit's callbacks. fn stopped(&self) -> bool { false } /// Run `script` for `event` on `target`, whose value is `value`; `fields` is the whole form. fn run(&mut self, event: FieldEvent, script: &str, target: &Field, value: &str, fields: &[Field], doc: &Document) -> ScriptResult; /// [`Self::run`] again for an appearance, with dates written in English names (`util.printd`) /// because no font of the field can draw the date language's (#5303); what it logs was /// already logged by the first run. `None` when it can't: no English dates here, or the /// script failed. What it returns is what the script made, even when that is the value. fn rerun_with_english_dates( &mut self, _event: FieldEvent, _script: &str, _target: &Field, _value: &str, _fields: &[Field], _doc: &Document, ) -> Option { None } } /// No script engine (or JavaScript turned off): scripts change nothing. pub struct NoScripts; impl Scripts for NoScripts { /// Provide the no-JavaScript fallback: accept the current field value without additional script mutations. fn run(&mut self, _: FieldEvent, _: &str, _: &Field, value: &str, _: &[Field], _: &Document) -> ScriptResult { ScriptResult { rc: true, value: value.to_string(), value_set: false, changes: Vec::new(), message: None } } } /// Run the Keystroke (commit) and Validate scripts for a new text value. Returns the value to /// store, or why it is rejected. pub(crate) fn accept(doc: &mut Document, f: &Field, value: &str, scripts: &mut dyn Scripts) -> Result<(String, bool), FormError> { let mut v = value.to_string(); let mut refresh_field = false; for (event, script, default) in [ (FieldEvent::Keystroke, &f.actions.scripts.keystroke, "The value entered does not match the format of the field"), (FieldEvent::Validate, &f.actions.scripts.validate, "The value entered is not valid for the field"), ] { let Some(js) = script else { continue }; let all = fields(doc); let r = scripts.run(event, js, f, &v, &all, doc); refresh_field |= r.changes.iter().any(|change| change.name == f.name && change.object.is_none_or(|id| id == f.obj)); apply_changes(doc, &r.changes, &f.name, Some(f.obj), scripts)?; if !r.rc { return Err(FormError::Invalid(r.message.unwrap_or_else(|| format!("{default} [ {} ]", f.name)))); } v = r.value; } Ok((v, refresh_field)) } /// The text a custom Format script shows for `value`, if the field has one. pub(crate) fn formatted(doc: &Document, f: &Field, value: &str, scripts: &mut dyn Scripts) -> Option { let js = f.actions.scripts.format.as_ref()?; let r = scripts.run(FieldEvent::Format, js, f, value, &fields(doc), doc); Some(r.value) } /// Immediate and aliased MK references of one widget; cycles and depth are bounded. fn widget_mk_refs(doc: &Document, widget: ObjRef) -> Vec { let mut value = doc.get(widget).as_dict().and_then(|d| d.get(b"MK")).cloned(); let mut seen = Vec::new(); for _ in 0..64 { let Some(Object::Ref(reference)) = value else { break }; if seen.contains(&reference) { break; } seen.push(reference); value = Some(doc.get(reference).as_ref().clone()); } seen } /// Keep target widgets' shared indirect MK in place, while outside widgets retain old colors. fn protect_other_mk_owners(doc: &mut Document, field: &Field, owners: &std::collections::HashMap>) -> Result<(), FormError> { let own: HashSet<_> = field.widgets.iter().map(|w| w.obj).collect(); let refs: HashSet<_> = field.widgets.iter().filter_map(|w| doc.get(w.obj).as_dict().and_then(|d| d.reference(b"MK"))).collect(); for reference in refs { for widget in owners.get(&reference).into_iter().flatten().filter(|w| !own.contains(w)) { if !widget_mk_refs(doc, *widget).contains(&reference) { continue; } let mk = doc.get(*widget).as_dict().and_then(|d| d.get(b"MK")).map(|m| doc.resolve(m)).and_then(|v| v.as_dict().cloned()).unwrap_or_default(); doc.update_dict(*widget, |d| d.set(b"MK".to_vec(), Object::Dict(mk)))?; } } Ok(()) } /// Most items a script may give a choice field ([`FieldChange::options`]). const MAX_SCRIPT_OPTIONS: usize = 10_000; /// Write a combo box's or list box's items (export value, text) as its `/Opt`: a plain text /// where the two are the same, a pair where they differ (ISO 32000-2 Table 231). The selected /// indices (`/I`) no longer apply and go; the value is written after, and the field redrawn. fn write_options(doc: &mut Document, f: &Field, options: &[(String, String)]) -> Result<(), FormError> { if !matches!(f.kind, FieldKind::Combo | FieldKind::List) { return Err(FormError::Invalid(format!("{} is not a combo box or list box: it has no items to set", f.name))); } if options.len() > MAX_SCRIPT_OPTIONS { return Err(FormError::Invalid(format!("a script may give a field at most {MAX_SCRIPT_OPTIONS} items"))); } let opt = options .iter() .map(|(export, text)| { if export == text { Object::String(PdfString::text(text)) } else { Object::Array(vec![Object::String(PdfString::text(export)), Object::String(PdfString::text(text))]) } }) .collect(); doc.update_dict(f.obj, |d| { d.set(b"Opt".to_vec(), Object::Array(opt)); d.remove(b"I"); })?; crate::author::redraw_field_object(doc, &f.name, f.obj) } /// Install `script` as field `f`'s JavaScript for `trigger` (a `setAction` trigger name), /// replacing the action it had there: the field's `/AA` for Keystroke, Validate, Calculate and /// Format, each widget's `/A` (Mouse Up) or `/AA` for the rest. fn set_action(doc: &mut Document, f: &Field, trigger: &str, script: &str) -> Result<(), FormError> { let event = match trigger { "Keystroke" => Some("keystroke"), "Validate" => Some("validate"), "Calculate" => Some("calculate"), "Format" => Some("format"), "MouseUp" => Some("mouse_up"), _ => None, }; if let Some(event) = event { // The field the script's object stands for, even when another shares its name. return set_script_of(doc, f, event, Some(script)); } let key: &[u8] = match trigger { "MouseDown" => b"D", "MouseEnter" => b"E", "MouseExit" => b"X", "OnFocus" => b"Fo", "OnBlur" => b"Bl", other => return Err(FormError::Invalid(format!("unknown action trigger {other:?}"))), }; let mut action = pdfcraft_cos::Dict::new(); action.set(b"S".to_vec(), Object::name("JavaScript")); action.set(b"JS".to_vec(), PdfString::text(script)); let action = doc.add(Object::Dict(action)); for w in &f.widgets { let mut aa = doc.get(w.obj).as_dict().and_then(|d| d.get(b"AA").map(|a| doc.resolve(a))).and_then(|a| a.as_dict().cloned()).unwrap_or_default(); aa.set(key.to_vec(), Object::Ref(action)); doc.update_dict(w.obj, |d| d.set(b"AA".to_vec(), Object::Dict(aa)))?; } Ok(()) } /// [`formatted`] with dates in English names, for widgets whose fonts can't draw the date /// language's; `None` when the field has no Format script or the English run didn't happen. pub(crate) fn formatted_in_english(doc: &Document, f: &Field, value: &str, scripts: &mut dyn Scripts) -> Option { let js = f.actions.scripts.format.as_ref()?; scripts.rerun_with_english_dates(FieldEvent::Format, js, f, value, &fields(doc), doc).map(|r| r.value) } /// Apply script changes to other fields (values written without re-running their scripts; /// read-only, required and visibility as flags; button captions). `except` is the event's own /// field, whose value the event sets. pub(crate) fn apply_changes( doc: &mut Document, changes: &[FieldChange], except: &str, except_object: Option, scripts: &mut dyn Scripts, ) -> Result<(), FormError> { if changes.is_empty() { return Ok(()); } // Validate every colour before applying any part of the script's changes. for c in changes { for color in [&c.text_color, &c.fill_color, &c.stroke_color].into_iter().flatten() { js_color(color)?; } } let all = fields(doc); let mut mk_owners: std::collections::HashMap> = std::collections::HashMap::new(); if changes.iter().any(|c| c.fill_color.is_some() || c.stroke_color.is_some()) { for field in &all { for widget in &field.widgets { for reference in widget_mk_refs(doc, widget.obj) { mk_owners.entry(reference).or_default().push(widget.obj); } } } } for c in changes { let Some(f) = all.iter().find(|f| f.name == c.name && c.object.is_none_or(|obj| f.obj == obj)) else { continue }; // New items go first, so a value among them is written against them. let reread; let f = match &c.options { Some(options) => { write_options(doc, f, options)?; reread = fields(doc).into_iter().find(|x| x.obj == f.obj).unwrap_or_else(|| f.clone()); &reread } None => f, }; for (trigger, script) in &c.actions { set_action(doc, f, trigger, script)?; } if let Some(v) = &c.value && !except_object.map_or(c.name == except, |obj| f.obj == obj) && *v != f.value { let value = match f.kind { FieldKind::Text => Some(FieldValue::Text(v.first().cloned().unwrap_or_default())), FieldKind::CheckBox => Some(f.check_box_value(v.first().map(String::as_str))), FieldKind::Radio => Some(FieldValue::Radio(v.first().cloned())), FieldKind::Combo | FieldKind::List => Some(FieldValue::Choice(v.clone())), _ => None, }; if let Some(value) = value { let mut tmp = f.clone(); tmp.flags &= !flags::NO_TOGGLE_TO_OFF; tmp.actions = Default::default(); crate::write_value(doc, &tmp, &value, &mut NoScripts)?; if c.formatted { crate::redraw_calculated_value(doc, f, v, scripts)?; } } } let mut ff = f.flags; for (on, bit) in [(c.read_only, flags::READ_ONLY), (c.required, flags::REQUIRED)] { match on { Some(true) => ff |= bit, Some(false) => ff &= !bit, None => {} } } if ff != f.flags { doc.update_dict(f.obj, |d| d.set(b"Ff".to_vec(), Object::Int(ff as i64)))?; } if let Some(display) = c.display { for w in &f.widgets { let old = doc.get(w.obj).as_dict().and_then(|d| d.int(b"F")).unwrap_or(0); // Hidden 2, Print 4, NoView 32 (Table 167). let base = old & !(2 | 4 | 32); let new = match display { 1 => base | 2 | 4, 2 => base, 3 => base | 4 | 32, _ => base | 4, }; if new != old { doc.update_dict(w.obj, |d| d.set(b"F".to_vec(), Object::Int(new)))?; } } } if c.fill_color.is_some() || c.stroke_color.is_some() { protect_other_mk_owners(doc, f, &mk_owners)?; } let appearance = crate::LookPatch { text: c.text_color.as_deref().map(js_color).transpose()?.flatten(), fill: c.fill_color.as_deref().map(js_color).transpose()?, border: c.stroke_color.as_deref().map(js_color).transpose()?, width: c.line_width, style: c .border_style .as_deref() .map(|style| match style { "solid" => Ok(crate::BorderStyle::Solid), "dashed" => Ok(crate::BorderStyle::Dashed), "beveled" => Ok(crate::BorderStyle::Beveled), "inset" => Ok(crate::BorderStyle::Inset), "underline" => Ok(crate::BorderStyle::Underline), _ => Err(FormError::Invalid("unsupported borderStyle".into())), }) .transpose()?, ..Default::default() }; if appearance != crate::LookPatch::default() || c.text_size.is_some() || c.user_name.is_some() { crate::author::set_props_object( doc, &c.name, f.obj, &crate::FieldProps { appearance: (appearance != crate::LookPatch::default()).then_some(appearance), font_size: c.text_size, tooltip: c.user_name.clone(), ..Default::default() }, )?; } if let Some(rect) = c.rect { if !rect.iter().all(|v| v.is_finite() && v.abs() <= 1e7) || rect[2] < rect[0] || rect[3] < rect[1] { return Err(FormError::Invalid("invalid script rectangle".into())); } let widget = f.widgets.first().ok_or_else(|| FormError::Invalid("field has no widget rectangle".into()))?; doc.update_dict(widget.obj, |d| d.set(b"Rect".to_vec(), Object::Array(rect.into_iter().map(Object::Real).collect())))?; crate::author::redraw_field_object(doc, &c.name, f.obj)?; } if let Some(icon) = c.icon { if f.kind != FieldKind::PushButton { return Err(FormError::Invalid("icons require a push button".into())); } if icon.is_some_and(|id| id >> 48 != 0) { return Err(FormError::Invalid("invalid button icon handle".into())); } let reference = icon.map(|id| pdfcraft_cos::ObjRef::new((id >> 16) as u32, id as u16)); if let Some(reference) = reference { let obj = doc.get(reference); let valid = match &*obj { Object::Stream(stream) => stream.dict.name(b"Subtype") == Some(b"Form".as_slice()), _ => false, }; if !valid { return Err(FormError::Invalid("button icon is not a form in this document".into())); } } for widget in &f.widgets { let mut mk = doc .get(widget.obj) .as_dict() .and_then(|d| d.get(b"MK").map(|m| doc.resolve(m))) .and_then(|m| m.as_dict().cloned()) .unwrap_or_default(); match reference { Some(r) => mk.set(b"I".to_vec(), Object::Ref(r)), None => { mk.remove(b"I"); } } doc.update_dict(widget.obj, |d| d.set(b"MK".to_vec(), Object::Dict(mk)))?; } crate::author::redraw_field_object(doc, &c.name, f.obj)?; } if let Some(caption) = &c.caption { if f.kind != FieldKind::PushButton { return Err(FormError::Invalid(format!("{} is not a push button", f.name))); } crate::author::set_props_object(doc, &f.name, f.obj, &crate::FieldProps { caption_all: Some(caption.clone()), ..Default::default() })?; } } Ok(()) } /// An Acrobat colour array as the RGB the look model stores, `None` for transparent (`["T"]`). /// Gray and CMYK convert as Acrobat's `color.convert` does. Anything else (an unknown colour /// space, the wrong number of components, or a component that is not a finite number between /// 0 and 1) is refused, and with it the script's changes. fn js_color(values: &[String]) -> Result, FormError> { let invalid = || FormError::Invalid("JavaScript field colours must be T, G, RGB or CMYK arrays with finite components between 0 and 1".into()); let (space, parts) = values.split_first().ok_or_else(invalid)?; let mut numbers = [0.0; 4]; if parts.len() > numbers.len() { return Err(invalid()); } for (slot, part) in numbers.iter_mut().zip(parts) { *slot = part.trim().parse::().ok().filter(|v| v.is_finite() && (0.0..=1.0).contains(v)).ok_or_else(invalid)?; } match (space.as_str(), parts.len()) { ("T", 0) => Ok(None), ("G", 1) => Ok(Some([numbers[0]; 3])), ("RGB", 3) => Ok(Some([numbers[0], numbers[1], numbers[2]])), ("CMYK", 4) => { let [c, m, y, k] = numbers; Ok(Some([1.0 - (c + k).min(1.0), 1.0 - (m + k).min(1.0), 1.0 - (y + k).min(1.0)])) } _ => Err(invalid()), } } /// Apply what a button or console script changed (values, read-only, required, visibility), /// then recalculate. pub fn apply_script_changes(doc: &mut Document, changes: &[FieldChange], scripts: &mut dyn Scripts) -> Result<(), FormError> { apply_changes(doc, changes, "", None, scripts)?; // Acrobat's automatic calculation is driven by value changes. A keypad caption, // position or visibility update must not execute every calculated field again. if changes.iter().any(|change| change.value.is_some()) { crate::recalculate_with(doc, scripts)?; } Ok(()) } /// Document-level JavaScripts (the catalog's `/Names /JavaScript` tree), in name order: they /// define the functions field scripts call. pub fn document_scripts(doc: &Document) -> Vec { document_scripts_named(doc).into_iter().map(|(_, js)| js).collect() } /// Most `/Kids` entries the document JavaScript name tree walk reads in all. const MAX_QUEUED_KIDS: usize = 1 << 20; /// [`document_scripts`] with their names. pub fn document_scripts_named(doc: &Document) -> Vec<(String, String)> { let Some(root) = doc.root() else { return Vec::new() }; let cat = doc.get(root); let Some(names) = cat.as_dict().and_then(|c| c.get(b"Names")).map(|n| doc.resolve(n)) else { return Vec::new() }; let Some(tree) = names.as_dict().and_then(|n| n.get(b"JavaScript")).cloned() else { return Vec::new() }; let mut out: Vec<(String, String)> = Vec::new(); let mut scripts = crate::script_budget(); // Kids are indirect references (ISO 32000-2 §7.9.6), queued unresolved, each the first time // it is listed: a node listed many times, or among its own kids, must not multiply the work, // and the stack holds only references (a direct object there would be copied whole, once // for every node listing it). Only the root may be direct. Nodes are told apart by object // number, as `Document::get` finds them, so `5 0 R` and `5 1 R` are one node. let mut seen: HashSet = tree.as_ref().map(|r| r.num).into_iter().collect(); // The `/Names` arrays read, by object number: nodes sharing one would otherwise each list // all its scripts again. let mut names_read: HashSet = HashSet::new(); let mut stack = vec![tree]; let mut visited = 0; // Every `/Kids` entry read, over the whole walk. Nodes sharing one long `/Kids` array would // otherwise read all of it once per node. let mut queued = 0usize; while let Some(raw) = stack.pop() { visited += 1; if visited > 10_000 { break; } let node = doc.resolve(&raw); let Some(d) = node.as_dict() else { continue }; if let Some(kids) = d.get(b"Kids") { in_array(doc, kids, |kids| { // Past the limit nothing more is read, but the nodes already queued still are. // Pushed in reverse, so the kids are read in the order listed. let room = MAX_QUEUED_KIDS.saturating_sub(queued); queued = queued.saturating_add(kids.len().min(room)); let new: Vec = kids.iter().take(room).filter_map(Object::as_ref).filter(|r| seen.insert(r.num)).collect(); stack.extend(new.into_iter().rev().map(Object::Ref)); }); } if let Some(pairs) = d.get(b"Names") && pairs.as_ref().is_none_or(|r| names_read.insert(r.num)) { in_array(doc, pairs, |pairs| { for pair in pairs.chunks(2) { let [k, v] = pair else { continue }; let key = match &*doc.resolve(k) { Object::String(s) => s.to_text(), _ => continue, }; if let Some(js) = crate::script(doc, v, &mut scripts) { out.push((key, js)); } } }); } } out.sort(); out } /// A document-level action trigger whose JavaScript the viewer runs (ISO 32000-2 §12.6.3, /// Table 199): the catalog's `/OpenAction`, or its `/AA` entries. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum DocumentTrigger { /// The catalog `/OpenAction`, once the document has opened. Open, /// `/AA /WS`, before the document is saved. WillSave, /// `/AA /WP`, before the document is printed. WillPrint, } /// Most actions read for one document trigger, along its `/Next` chain. A chain can be cyclic /// or arbitrarily long; the rest are ignored. const MAX_CHAINED_ACTIONS: usize = 256; /// The JavaScript of the catalog actions for `trigger`, in the order a viewer runs them: the /// action, then its `/Next` actions depth first (§12.6.2). Actions of other types (a `/GoTo` /// open action, say) are skipped; their `/Next` chains are still followed. Bounded: each object /// is read once and at most [`MAX_CHAINED_ACTIONS`] actions are visited. pub fn document_action_scripts(doc: &Document, trigger: DocumentTrigger) -> Vec { let Some(root) = doc.root() else { return Vec::new() }; let cat = doc.get(root); let Some(cat) = cat.as_dict() else { return Vec::new() }; let first = match trigger { DocumentTrigger::Open => cat.get(b"OpenAction").cloned(), DocumentTrigger::WillSave | DocumentTrigger::WillPrint => { let key: &[u8] = if trigger == DocumentTrigger::WillSave { b"WS" } else { b"WP" }; cat.get(b"AA").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().and_then(|d| d.get(key)).cloned()) } }; chained_scripts(doc, first) } /// The JavaScript of action `first` and of the actions chained after it, in the order they run: /// the action, then its `/Next` actions depth first (ISO 32000-2 §12.6.2). Actions of other /// types are skipped; their `/Next` chains are still followed. Bounded: each object is read /// once and at most [`MAX_CHAINED_ACTIONS`] actions are visited. pub(crate) fn chained_scripts(doc: &Document, first: Option) -> Vec { let mut budget = crate::script_budget(); let mut seen: HashSet = HashSet::new(); let mut pending: Vec = first.into_iter().collect(); let mut visited = 0usize; let mut out = Vec::new(); while let Some(raw) = pending.pop() { visited += 1; if visited > MAX_CHAINED_ACTIONS { break; } if let Some(r) = raw.as_ref() && !seen.insert(r.num) { continue; } let action = doc.resolve(&raw); if let Some(list) = action.as_array() { // Pushed in reverse so they run in the order listed. pending.extend(list.iter().take(MAX_CHAINED_ACTIONS).rev().cloned()); continue; } let Some(d) = action.as_dict() else { continue }; if d.name(b"S") == Some(b"JavaScript".as_slice()) && let Some(js) = crate::script(doc, &raw, &mut budget) { out.push(js); } if let Some(next) = d.get(b"Next") { pending.push(next.clone()); } } out } /// Call `f` on the array `o` is, or refers to, read in place: resolving a direct array would /// copy it. fn in_array(doc: &Document, o: &Object, f: impl FnOnce(&[Object])) { match o { Object::Ref(r) => { if let Some(a) = doc.get(*r).as_array() { f(a); } } Object::Array(a) => f(a), _ => {} } } /// 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)?; let cat = doc.get(root).as_dict().cloned().unwrap_or_default(); let mut names = cat.get(b"Names").map(|n| doc.resolve(n)).and_then(|n| n.as_dict().cloned()).unwrap_or_default(); let tree = names.get(b"JavaScript").map(|t| doc.resolve(t)).and_then(|t| t.as_dict().cloned()).unwrap_or_default(); if tree.contains(b"Kids") { return Err(FormError::Invalid("the document's JavaScript name tree has kids; edit it with a full editor".into())); } let mut pairs: Vec = tree.get(b"Names").map(|n| doc.resolve(n)).and_then(|n| n.as_array().cloned()).unwrap_or_default(); let mut kept = Vec::new(); for pair in pairs.chunks(2) { if let [k, v] = pair && !matches!(&*doc.resolve(k), Object::String(s) if s.to_text() == name) { kept.push((k.clone(), v.clone())); } } if let Some(js) = js { let mut action = pdfcraft_cos::Dict::new(); action.set(b"S".to_vec(), Object::name("JavaScript")); action.set(b"JS".to_vec(), PdfString::text(js)); let a = doc.add(Object::Dict(action)); kept.push((Object::String(PdfString::text(name)), Object::Ref(a))); } kept.sort_by_key(|(k, _)| match k { Object::String(s) => s.to_text(), _ => String::new(), }); pairs = kept.into_iter().flat_map(|(k, v)| [k, v]).collect(); let mut t = pdfcraft_cos::Dict::new(); t.set(b"Names".to_vec(), Object::Array(pairs)); names.set(b"JavaScript".to_vec(), Object::Dict(t)); doc.update_dict(root, |c| c.set(b"Names".to_vec(), Object::Dict(names)))?; Ok(()) } /// Field Properties ▸ Format / Validate / Calculate ▸ custom script, or Actions ▸ Mouse Up ▸ /// Run a JavaScript: set (or remove, with `None`) field `name`'s script for `event` — one of /// `keystroke`, `format`, `validate`, `calculate` (the field's `/AA` K, F, V, C) or `mouse_up` /// (its widgets' `/A`). Calculated fields join the calculation order (`/CO`). pub fn set_field_script(doc: &mut Document, name: &str, event: &str, js: Option<&str>) -> Result<(), FormError> { let all = fields(doc); let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?; set_script_of(doc, f, event, js) } /// [`set_field_script`] for the field `f` itself, which may share its name with another. fn set_script_of(doc: &mut Document, f: &Field, event: &str, js: Option<&str>) -> Result<(), FormError> { let action = |doc: &mut Document, js: &str| { let mut a = pdfcraft_cos::Dict::new(); a.set(b"S".to_vec(), Object::name("JavaScript")); a.set(b"JS".to_vec(), PdfString::text(js)); Object::Ref(doc.add(Object::Dict(a))) }; if event == "mouse_up" { for w in &f.widgets { let a = js.map(|j| action(doc, j)); doc.update_dict(w.obj, |d| match a { Some(a) => d.set(b"A".to_vec(), a), None => { d.remove(b"A"); } })?; } return Ok(()); } let key: &[u8] = match event { "keystroke" => b"K", "format" => b"F", "validate" => b"V", "calculate" => b"C", other => return Err(FormError::Invalid(format!("unknown field event {other:?} (keystroke, format, validate, calculate, mouse_up)"))), }; let mut aa = doc.get(f.obj).as_dict().and_then(|d| d.get(b"AA").map(|a| doc.resolve(a))).and_then(|a| a.as_dict().cloned()).unwrap_or_default(); match js { Some(j) => { let a = action(doc, j); aa.set(key.to_vec(), a); } None => { aa.remove(key); } } doc.update_dict(f.obj, |d| { if aa.is_empty() { d.remove(b"AA"); } else { d.set(b"AA".to_vec(), Object::Dict(aa)); } })?; if key == b"C" { crate::calc_order::keep_listed(doc, f.obj, js.is_some())?; } Ok(()) }