pdfcraft/crates/forms/src/scripting.rs

314 lines
12 KiB
Rust

//! 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 pdfcraft_cos::{Document, 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,
pub value: Option<Vec<String>>,
pub read_only: Option<bool>,
pub required: Option<bool>,
/// `display.visible` 0, `hidden` 1, `noPrint` 2, `noView` 3.
pub display: Option<i32>,
}
/// What a script did.
#[derive(Clone, Debug, PartialEq)]
pub struct ScriptResult {
/// `event.rc`.
pub rc: bool,
/// `event.value` afterwards.
pub value: String,
pub changes: Vec<FieldChange>,
/// Why it rejected the value (its alert), for the error shown.
pub message: Option<String>,
}
/// Runs field scripts (JavaScript).
pub trait Scripts {
/// 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]) -> ScriptResult;
}
/// No script engine (or JavaScript turned off): scripts change nothing.
pub struct NoScripts;
impl Scripts for NoScripts {
fn run(&mut self, _: FieldEvent, _: &str, _: &Field, value: &str, _: &[Field]) -> ScriptResult {
ScriptResult { rc: true, value: value.to_string(), 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, FormError> {
let mut v = value.to_string();
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);
apply_changes(doc, &r.changes, &f.name)?;
if !r.rc {
return Err(FormError::Invalid(r.message.unwrap_or_else(|| format!("{default} [ {} ]", f.name))));
}
v = r.value;
}
Ok(v)
}
/// 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<String> {
let js = f.actions.scripts.format.as_ref()?;
let r = scripts.run(FieldEvent::Format, js, f, value, &fields(doc));
Some(r.value)
}
/// Apply script changes to other fields (values written without re-running their scripts;
/// read-only, required and visibility as flags). `except` is the event's own field, whose value
/// the event sets.
pub(crate) fn apply_changes(doc: &mut Document, changes: &[FieldChange], except: &str) -> Result<(), FormError> {
if changes.is_empty() {
return Ok(());
}
let all = fields(doc);
for c in changes {
let Some(f) = all.iter().find(|f| f.name == c.name) else { continue };
if let Some(v) = &c.value
&& c.name != except
&& *v != f.value
{
let value = match f.kind {
FieldKind::Text => FieldValue::Text(v.first().cloned().unwrap_or_default()),
FieldKind::CheckBox => FieldValue::Check(!v.is_empty()),
FieldKind::Radio => FieldValue::Radio(v.first().cloned()),
FieldKind::Combo | FieldKind::List => FieldValue::Choice(v.clone()),
_ => continue,
};
let mut tmp = f.clone();
tmp.flags &= !flags::NO_TOGGLE_TO_OFF;
tmp.actions = Default::default();
crate::write_value(doc, &tmp, &value, &mut NoScripts)?;
}
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)))?;
}
}
}
}
Ok(())
}
/// 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, "")?;
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<String> {
document_scripts_named(doc).into_iter().map(|(_, js)| js).collect()
}
/// [`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")).map(|t| doc.resolve(t)) else { return Vec::new() };
let mut out: Vec<(String, String)> = Vec::new();
let mut stack = vec![(*tree).clone()];
let mut seen = 0;
while let Some(node) = stack.pop() {
seen += 1;
if seen > 10_000 {
break;
}
let Some(d) = node.as_dict() else { continue };
if let Some(kids) = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()) {
stack.extend(kids.iter().map(|k| (*doc.resolve(k)).clone()));
}
if let Some(pairs) = d.get(b"Names").map(|n| doc.resolve(n)).and_then(|n| n.as_array().cloned()) {
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) {
out.push((key, js));
}
}
}
}
out.sort();
out
}
/// 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<Object> = 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()))?.clone();
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" {
let af_ref = crate::author::ensure_form(doc)?;
let mut co: Vec<Object> = doc
.get(af_ref)
.as_dict()
.and_then(|d| d.get(b"CO").cloned())
.map(|c| doc.resolve(&c))
.and_then(|c| c.as_array().cloned())
.unwrap_or_default();
co.retain(|o| o.as_ref() != Some(f.obj));
if js.is_some() {
co.push(Object::Ref(f.obj));
}
doc.update_dict(af_ref, |d| {
if co.is_empty() {
d.remove(b"CO");
} else {
d.set(b"CO".to_vec(), Object::Array(co));
}
})?;
}
Ok(())
}