mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-09 04:22:20 +00:00
369 lines
15 KiB
Rust
369 lines
15 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 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,
|
|
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>,
|
|
/// Move/resize the first widget, in PDF user space.
|
|
pub rect: Option<[f64; 4]>,
|
|
}
|
|
|
|
/// 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<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;
|
|
|
|
/// [`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],
|
|
) -> Option<ScriptResult> {
|
|
None
|
|
}
|
|
}
|
|
|
|
/// 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(), 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, 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)
|
|
}
|
|
|
|
/// [`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<String> {
|
|
let js = f.actions.scripts.format.as_ref()?;
|
|
scripts.rerun_with_english_dates(FieldEvent::Format, js, f, value, &fields(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). `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)))?;
|
|
}
|
|
}
|
|
}
|
|
if let Some(rect) = c.rect {
|
|
crate::set_props(doc, &c.name, &crate::FieldProps { rect: Some((0, rect)), ..Default::default() })?;
|
|
}
|
|
}
|
|
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()
|
|
}
|
|
|
|
/// 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<u32> = 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<u32> = 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<ObjRef> = 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
|
|
}
|
|
|
|
/// 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),
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
/// 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" {
|
|
crate::calc_order::keep_listed(doc, f.obj, js.is_some())?;
|
|
}
|
|
Ok(())
|
|
}
|