use crate::actions::JsActionHandler;
use crate::facade::FormAccess;
use crate::tree::{FieldId, FieldTree, FieldValue};
use crate::writeback::{
apply_text_with_display, collect_fields, effective_field_type, ensure_indirect_acroform,
inherited, Located, WritebackError,
};
use lopdf::{Dictionary, Document, Object, ObjectId};
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
use xfa_js_sandboxed::{ExecCtx, FieldValues, XfaJsError, XfaJsRuntime};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CalcUpdate {
pub name: String,
pub value: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CalcFailure {
pub name: String,
pub message: String,
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct CalcOutcome {
pub updated: Vec<CalcUpdate>,
pub failed: Vec<CalcFailure>,
pub scripts_run: usize,
}
fn new_runtime() -> Result<XfaJsRuntime, WritebackError> {
XfaJsRuntime::new().map_err(|e| WritebackError::Malformed(format!("script engine: {e}")))
}
fn run_script(
rt: &mut XfaJsRuntime,
script: &str,
fields: &mut FieldValues,
event_value: &str,
) -> Result<Option<String>, XfaJsError> {
let mut ctx = ExecCtx::new(fields, Arc::new(AtomicBool::new(false)));
ctx.event_new_text = Some(event_value);
rt.execute_acroform_event(script, ctx)
}
pub fn recalculate(doc: &mut Document) -> Result<CalcOutcome, WritebackError> {
let has_form = doc
.catalog()
.map(|c| c.get(b"AcroForm").is_ok())
.unwrap_or(false);
if !has_form {
return Ok(CalcOutcome::default());
}
ensure_indirect_acroform(doc);
let fields = collect_fields(doc)?;
let terminal: Vec<&Located> = fields.iter().filter(|l| !has_field_kids(doc, l)).collect();
let mut values = FieldValues::new();
for l in &terminal {
values.set(l.fqn.clone(), stored_text(doc, l));
}
let order = calculation_order(doc, &terminal);
let mut outcome = CalcOutcome::default();
if order.is_empty() {
return Ok(outcome);
}
let mut rt = new_runtime()?;
for idx in order {
let target = terminal[idx];
let Some(script) = action_script(doc, target.id, b"C") else {
continue;
};
outcome.scripts_run += 1;
let snapshot = values.clone();
let current = values.get(&target.fqn).unwrap_or("").to_string();
let result = run_script(&mut rt, &script, &mut values, ¤t);
let new_target = match result {
Ok(Some(v)) => v,
Ok(None) => {
values = snapshot;
continue;
}
Err(e) => {
values = snapshot;
outcome.failed.push(CalcFailure {
name: target.fqn.clone(),
message: e.to_string(),
});
continue;
}
};
values.set(target.fqn.clone(), new_target);
let changed: Vec<(String, String)> = values
.iter()
.filter(|(name, v)| snapshot.get(name) != Some(*v))
.map(|(n, v)| (n.to_string(), v.to_string()))
.collect();
for (name, value) in changed {
let Some(l) = terminal.iter().find(|l| l.fqn == name) else {
continue;
};
match write_text(doc, &mut rt, &values, l, &value) {
Ok(()) => {
outcome.updated.retain(|u| u.name != name);
outcome.updated.push(CalcUpdate { name, value });
}
Err(message) => {
values.set(name.clone(), snapshot.get(&name).unwrap_or("").to_string());
outcome.failed.push(CalcFailure { name, message });
}
}
}
}
Ok(outcome)
}
fn write_text(
doc: &mut Document,
rt: &mut XfaJsRuntime,
values: &FieldValues,
l: &Located,
value: &str,
) -> Result<(), String> {
let is_text = doc
.get_object(l.id)
.and_then(|o| o.as_dict())
.ok()
.and_then(|d| effective_field_type(doc, d))
.is_some_and(|ft| ft == b"Tx");
if !is_text {
return Err("a calculated value can only be written to a text field".to_string());
}
if stored_text(doc, l) == value && has_appearance(doc, l) {
return Ok(());
}
let display = format_display(doc, rt, values, l, value);
apply_text_with_display(doc, l, value, display.as_deref())
.map(|_| ())
.map_err(|e| e.to_string())
}
fn format_display(
doc: &Document,
rt: &mut XfaJsRuntime,
values: &FieldValues,
l: &Located,
value: &str,
) -> Option<String> {
let script = action_script(doc, l.id, b"F")?;
let mut scratch = values.clone();
match run_script(rt, &script, &mut scratch, value) {
Ok(Some(shown)) if shown != value => Some(shown),
_ => None,
}
}
fn has_appearance(doc: &Document, l: &Located) -> bool {
doc.get_object(l.id)
.and_then(|o| o.as_dict())
.map(|d| d.has(b"AP"))
.unwrap_or(false)
|| l.kids.iter().any(|k| {
doc.get_object(*k)
.and_then(|o| o.as_dict())
.map(|d| d.has(b"AP"))
.unwrap_or(false)
})
}
fn has_field_kids(doc: &Document, l: &Located) -> bool {
l.kids.iter().any(|&k| {
doc.get_object(k)
.and_then(|o| o.as_dict())
.map(|d| d.get(b"T").is_ok())
.unwrap_or(false)
})
}
fn calculation_order(doc: &Document, terminal: &[&Located]) -> Vec<usize> {
let has_script = |l: &Located| action_script(doc, l.id, b"C").is_some();
let listed: Vec<usize> = co_ids(doc)
.into_iter()
.filter_map(|id| terminal.iter().position(|l| l.id == id))
.filter(|&i| has_script(terminal[i]))
.collect();
if !listed.is_empty() {
return listed;
}
terminal
.iter()
.enumerate()
.filter(|(_, l)| has_script(l))
.map(|(i, _)| i)
.collect()
}
fn co_ids(doc: &Document) -> Vec<ObjectId> {
let Some(af) = doc
.catalog()
.ok()
.and_then(|c| c.get(b"AcroForm").ok())
.and_then(|o| resolve_dict(doc, o))
else {
return Vec::new();
};
let arr = match af.get(b"CO") {
Ok(Object::Array(a)) => a.clone(),
Ok(Object::Reference(id)) => match doc.get_object(*id) {
Ok(Object::Array(a)) => a.clone(),
_ => return Vec::new(),
},
_ => return Vec::new(),
};
arr.iter().filter_map(|o| o.as_reference().ok()).collect()
}
fn resolve_dict<'a>(doc: &'a Document, obj: &'a Object) -> Option<&'a Dictionary> {
match obj {
Object::Dictionary(d) => Some(d),
Object::Reference(id) => doc.get_object(*id).and_then(|o| o.as_dict()).ok(),
_ => None,
}
}
fn stored_text(doc: &Document, l: &Located) -> String {
let Some(dict) = doc.get_object(l.id).and_then(|o| o.as_dict()).ok() else {
return String::new();
};
match inherited(doc, dict, b"V") {
Some(Object::Array(items)) => items
.first()
.and_then(|o| lopdf::decode_text_string(o).ok())
.unwrap_or_default(),
Some(Object::Name(n)) => String::from_utf8_lossy(&n).into_owned(),
Some(o @ Object::String(..)) => lopdf::decode_text_string(&o).unwrap_or_default(),
_ => String::new(),
}
}
fn action_script(doc: &Document, field: ObjectId, trigger: &[u8]) -> Option<String> {
let dict = doc.get_object(field).and_then(|o| o.as_dict()).ok()?;
let aa = resolve_dict(doc, dict.get(b"AA").ok()?)?;
let action = resolve_dict(doc, aa.get(trigger).ok()?)?;
match action.get(b"S").ok()? {
Object::Name(n) if n == b"JavaScript" => {}
_ => return None,
}
let js = match action.get(b"JS").ok()? {
Object::Reference(id) => doc.get_object(*id).ok()?,
other => other,
};
match js {
o @ Object::String(..) => lopdf::decode_text_string(o).ok(),
Object::Stream(s) => {
let bytes = s
.decompressed_content()
.unwrap_or_else(|_| s.content.clone());
lopdf::decode_text_string(&Object::String(bytes, lopdf::StringFormat::Literal)).ok()
}
_ => None,
}
}
pub struct SandboxedJsHandler {
runtime: XfaJsRuntime,
}
impl SandboxedJsHandler {
pub fn new() -> Result<Self, XfaJsError> {
Ok(Self {
runtime: XfaJsRuntime::new()?,
})
}
}
fn tree_values(tree: &FieldTree) -> (FieldValues, Vec<(String, FieldId)>) {
let mut values = FieldValues::new();
let mut ids = Vec::new();
for id in tree.terminal_fields() {
let name = tree.fully_qualified_name(id);
values.set(name.clone(), tree.get_value(&name).unwrap_or_default());
ids.push((name, id));
}
(values, ids)
}
impl JsActionHandler for SandboxedJsHandler {
fn on_keystroke(&mut self, _: &mut FieldTree, _: FieldId, _: &str, _: &str) -> bool {
true
}
fn on_validate(&mut self, _: &mut FieldTree, _: FieldId, _: &str) -> bool {
true
}
fn on_format(&mut self, tree: &FieldTree, field_id: FieldId, js: &str) -> Option<String> {
let (mut values, _) = tree_values(tree);
let name = tree.fully_qualified_name(field_id);
let current = values.get(&name).unwrap_or("").to_string();
run_script(&mut self.runtime, js, &mut values, ¤t)
.ok()
.flatten()
}
fn on_calculate(
&mut self,
tree: &mut FieldTree,
field_id: FieldId,
js: &str,
) -> Option<String> {
let (mut values, ids) = tree_values(tree);
let before = values.clone();
let name = tree.fully_qualified_name(field_id);
let current = values.get(&name).unwrap_or("").to_string();
let result = run_script(&mut self.runtime, js, &mut values, ¤t)
.ok()
.flatten()?;
for (other, id) in ids {
if other != name && values.get(&other) != before.get(&other) {
tree.get_mut(id).value = Some(FieldValue::Text(
values.get(&other).unwrap_or("").to_string(),
));
}
}
Some(result)
}
}